一套键鼠,一次操作,同时打进几十台 Windows——被控端别说什么 agent 了,连个额外软件都不用装,靠的全是系统自带的 RDP。

这功能最近做进了七亿批量远程管理工具里:九宫格那栏点开就是个「群控」模式,一个窗口里几十台机器同屏,你在主控那格敲字、点鼠标,其他被控格跟着同步动。同事头一回见还以为我这边装了同步软件,其实被控那几十台干干净净,什么都没装。

底下原理说出来不复杂:FreeRDP 的客户端库自带输入注入的 API——freerdp_input_send_keyboard_event 和 _mouse_event,把同一组键鼠事件程序化地广播进多个 RDP 会话就行。真正折腾人的,全在那句"不复杂"之外。我按类别把这些坑捋一捋,每条都是真机撞出来的,网上基本搜不到成体系的记录。下面每条只给"现象 → 根因 → 关键那一行",整套实现就不铺开了。

一、FreeRDP 和协议层

Windows 端一上来就有三个必踩的。

第一,连接前必须先调 WSAStartup,不然后面 getaddrinfo 全挂,报的错还看不出是这回事。第二,OpenSSL 3 把 MD4 挪进了 legacy provider,NLA 认证会报 SEC_E_UNKNOWN_CREDENTIALS——得把 provider 显式唤回来。第三,设置的 Domain 字段不能是 NULL,给个 "."。

# openssl.cnf:让 MD4 回到 NLA 能用
openssl_conf = openssl_init
[openssl_init]
providers = provider_sect
[provider_sect]
legacy = legacy_sect

鼠标这块有个特别典型的:DOWN / UP 事件不能 OR 上 PTR_FLAGS_MOVE。带上它,事件会被吞,表现就是"鼠标能动、就是点不了"。为这个我查了大半天,一开始还怀疑是被控那边安全策略拦了。

// ✗ 反例:移动和按下揉在一发里 → 引擎把 press 吃掉
send(PTR_FLAGS_DOWN | PTR_FLAGS_MOVE | PTR_FLAGS_BUTTON1, x, y);
// ✓ 正解:移动归移动,按下/抬起各发各的
send(PTR_FLAGS_MOVE, x, y);
send(PTR_FLAGS_DOWN | PTR_FLAGS_BUTTON1, x, y);
send(PTR_FLAGS_BUTTON1, x, y);

键盘必须按扫描码发,不能发 unicode。X11 键码要先转 PC scancode,Win32 那边用 MapVirtualKey 加 lParam 的扩展位。拿 unicode 发特殊键会乱码,你按 Delete,对面打出来一个 (。

// Delete = 扫描码 0x53 + 扩展位(不是按字符发)
freerdp_input_send_keyboard_event(in, KBD_FLAGS_DOWN    | KBD_FLAGS_EXTENDED, 0x53);
freerdp_input_send_keyboard_event(in, KBD_FLAGS_RELEASE | KBD_FLAGS_EXTENDED, 0x53);

鼠标坐标要按目标机的分辨率归一化,不然点击落到别处——几台机器缩放比例不一样,差一点就点歪。

GDI 初始化必须在 PostConnect 回调里调(gdi_init);图省事放在 context_new 之后,直接段错误。

inst->PostConnect = OnPostConnect;   // gdi_init 只能挂这儿
BOOL OnPostConnect(freerdp* i){ gdi_init(i, PIXEL_FORMAT_BGRX32); return TRUE; }

剪贴板(CLIPRDR)是这一层最绕的,一口气五条:得走客户端框架;主循环里要用 freerdp_check_event_handles(),只 check_fds 收不到通道消息;capabilities 只能在 MonitorReady 里回,不能在 ServerCapabilities 回;客户端得先自发一次 format list、等 ACK,服务端才肯推;至于"只 ACK 不落地"——真因是 caps 里声明了不支持长格式名,却还是带了格式名。

二、渲染和流畅度

协议轮询一定要挪到后台线程,UI 只在"帧计数变化"时才重画。不这么做,画面明明静止,CPU 也狂刷。PictureBox 记得开双缓冲,不然闪。

还有个我自己造的 bug:重写刷新逻辑那一版,漏更新了缓存的宽高。结果鼠标广播时两个尺寸都是 0,直接早退——现象是"鼠标全哑了",查了半天才发现根子在尺寸,不在鼠标。

三、多会话与线程安全

FreeRDP 不是线程安全的。有次 UI 线程调 set_resolution,正好和后台 poll 抢同一个会话,那一路会话就被搞坏了:被放大那一格不刷新,退回去也是坏的,只能重连。

四、WinForms 焦点(最阴的一类)

点过一次按钮之后,焦点就留在按钮上了。这时候按空格,等于勾了「群控」那个勾选框;按回车,等于又点了一次那个按钮。表现出来就是"群控莫名其妙重启""回车把格子断开了"。

更阴的是:回车和 Tab 属于"对话框键",焦点在按钮上时,KeyPreview 的 KeyDown 根本不触发。你写了半天键盘钩子,压根拦不住——得换个入口去截。

// KeyPreview 的 KeyDown 收不到回车(焦点在按钮上时);按钮会自己把回车当"点击"
// 正解:在 ProcessCmdKey 里截 —— 它早于按钮的对话框键处理,且子控件焦点都会冒泡到这
protected override bool ProcessCmdKey(ref Message m, Keys k)
{
    if ((k & Keys.KeyCode) == Keys.Enter) { /* 广播该键 + 吃掉 */ return true; }
    return base.ProcessCmdKey(ref m, k);
}

五、打包和发布

自包含发布不带 ffmpeg 的原生闭包——libfreerdp3 是静态 import 的——结果依赖 120 个 DLL、151MB。手动把能桩的桩掉,瘦到 112MB,又收拾到 56MB。这块也是七亿批量远程管理工具发版时最费劲的一段。

dotnet publish 也不会自动带上 freerdp_shim.dll 和 libfreerdp* / libwinpr* / libcrypto / libssl / av* / zlib 这些原生库,发版和混淆的时候得单独挂进项目。

<!-- 原生闭包挂进项目,否则 publish 出来缺 DLL,群控静默失效 -->
<Content Include="native\**\*" CopyToOutputDirectory="PreserveNewest"
         Link="%(RecursiveDir)%(Filename)%(Extension)" />

六、混淆兼容(要发商业版就会撞)

这条和之前那篇混淆一开,SFTP 就"假死"其实是同一个坑的两种表现:P/Invoke 得给每条 DllImport 写死 EntryPoint,因为混淆会改托管方法名,默认拿方法名当导出名就找不到了。

// 混淆后托管名会被改;写死 EntryPoint = 原生导出名,才找得到 native 函数
[DllImport("freerdp_shim.dll", EntryPoint = "shim_connect_ex")]
static extern IntPtr shim_connect_ex(string host, int port, string u, string p, int w, int h);

公开类型一改名,System.Text.Json 反序列化还会静默拿 null,一声不吭。

七、验证环境这点事

wine 跑不了真连——它没实现 ucrtbase._aligned_recalloc,一撞就崩。所以这方面的结论只能在真 Windows 上跑,虚拟机里都不算数。另外顺手提醒一句:pkill -f 会误杀执行它的那个 shell(命令文本里就含程序名),这坑我之前单独写过一次。

什么时候能用,什么时候别用

适用场景挺明确:一批同型号、同用途的 Windows 机器,做批量部署、排障、演示的时候,一台主控带一片被控,能省掉挨个连的功夫。

局限也得说在前头。异构机器、还有那些爱弹更新弹广告的机器,要慎用——某台突然弹个窗,广播过去的点击就落到错地方了。还有就是安全桌面、UAC、锁屏这类界面,键鼠注入根本进不去,这跟引擎没关系,是系统本身就不让。

群控这事,我们其实从给 Linux 单拉群控窗口那阵就在琢磨,Windows 这条一直到 FreeRDP 这套啃完才算真正打通。想试的可以去官网翻说明书,七亿批量远程管理工具里那个九宫格群控,就是它落地的样子。