最近在开发基于rdp协议的组件,因为需要用到rdp的虚拟通道拓展,前后折腾了好几天,这里记录一下顺带给后面做这部分的小伙伴一个参考。
什么是静态虚拟通道
虚拟通道的能力拓展是在RDP协议的协商阶段进行announce的,这部分在BCGR那一部分文档介绍的比较清楚、MCS就是Multi-Channel Support,RDP协议有许多通道拓展,例如RDPDR、RDPEI、RDPCLIRDR等,MCS通过使用多通道(channels)来实现,每个通道可以处理特定类型的数据传输。虚拟动态通道可以随时创建和销毁,并且静态虚拟通道是依赖于静态通道来进行建立的。
另外RDP协议也留给了开发者一些接口、这样我们就能开发自己的拓展通道了。
配置dll注册表
我们需要自己开发一个dll,然后将它配置到注册表中,这样TS Service在启动MSTSC的时候就能找到我们的拓展了。
在注册表中,将子项添加到以下位置之一:
HKEY_CURRENT_USER \ Software \ Microsoft \ Terminal Server Client \ Default \ Addins
HKEY_CURRENT_USER \ Software \ Microsoft \ Terminal Server Client \ connection \ Addins
例如,我们需要开发的通道拓展是"rdpgw"(需要注意的是通道名称建议用英文,并且字符串长度不要超过7),那么我们的配置的目录项就是
HKEY_CURRENT_USER \ Software \ Microsoft \ Terminal Server Client \ Default \ Addins\Default\RDPGW,如果你的机器不存在这个目录的话,就自己创建。
\Default\Addins项下的条目适用于所有连接。\connection\Addins键下的条目仅适用于由connection标识的连接。可以使用连接管理器创建和管理连接。
可以为子项指定任何名称。它必须包含REG_SZ或REG_EXPAND_SZ值,并且可以选择包含REG_DWORD值。REG_SZ或REG_EXPAND_SZ值的语法如下。
Name = DLLname
如果Name是REG_EXPAND_SZ值,则它可以包含在运行时扩展的未扩展环境变量。
DLLname的值可以是完全限定路径。如果DLLname不包含路径,则使用标准 DLL 搜索策略。
例如:
编写DLL
需要注意这个DLL一定要抛出去一个函数BOOL VCAPITYPE VirtualChannelEntry(PCHANNEL_ENTRY_POINTS pEntryPoints)
,一个字都不能差,因为这个是TS服务调用你的DLL的入口函数签名。至于其中使用的一些API,可以查看下面表格中的文档。
功能 | 描述 |
---|---|
虚拟通道初始化 | 注册客户端要使用的虚拟通道的名称,并提供VirtualChannelInitEvent回调函数,远程桌面服务通过该函数通知客户端有关影响客户端连接的事件。 |
虚拟频道开放 | 打开指定虚拟通道的客户端,并提供VirtualChannelOpenEvent回调函数,远程桌面服务通过该回调函数通知客户端有关影响虚拟通道的事件。 |
虚拟通道写入 | 将数据写入虚拟通道。远程桌面服务将此数据发送到虚拟通道的服务器端。服务器端调用WTSVrtualChannelRead函数读取数据。 |
虚拟通道关闭 | 关闭虚拟通道。 |
这是作者写的demo,至于日志框架使用的是spdlog,这个无关紧要。这里面许多方法都是异步的,所以调用的时候一定要注意如果你使用了堆分配的内存时、不要在他函数返回后立刻释放,一定要在对应的回调事件中处理。
需要注意的是这里一定要声明一个全局变量来保存pEntryPoints,因为这个方法是异步的,VirtualChannelEntry函数返回后,该指针不再有效 。并且一定要在VirtualChannelEntry
里面调用VirtualChannelInit
方法,因为这个API规定必须在这个方法里面调用,其他地方调用都会返回失败。
注意channel配置了一个选项是CHANNEL_OPTION_SHOW_PROTOCOL、它决定了数据通过 VirtualChannelWrite
函数传递给服务器端插件时的呈现方式。如果设置了这个选项,服务器端插件会看到完整的虚拟通道协议,包括协议中的 CHANNEL_PDU_HEADER
,如果未设置这个选项,服务器端插件只会看到通过 VirtualChannelWrite
传递的数据部分。CHANNEL_PDU_HEADER
是虚拟通道协议中的一个结构,用于描述传输的数据的头部信息。如果选项被设置,服务器端插件能够看到这个头部信息,否则只能看到实际传递的数据,而不包括头部信息。
我们先定义一些全局变量
constexpr auto CHANNEL_NAME = "rdpgw";
//store pEntryPoints
PCHANNEL_ENTRY_POINTS Global_Entry_Points = NULL;
LPVOID RdpgwInitHandle = NULL;
DWORD RdpgwHandle = 0;
//buffer
BYTE* RdpgwRecieveBuff = NULL;
DWORD RecieveBuffLen = 0;
DWORD RecieveBuffSize = 0;
VirtualChannelEntry
VIRTUALCHANNELENTRY Virtualchannelentry;
BOOL VCAPITYPE Virtualchannelentry(
[in] PCHANNEL_ENTRY_POINTS pEntryPoints
)
{...}
//https://learn.microsoft.com/en-us/windows/win32/api/cchannel/nc-cchannel-virtualchannelentry
//Pointer to a CHANNEL_ENTRY_POINTS structure that contains pointers to the client-side virtual channel functions.
//This pointer is no longer valid after the VirtualChannelEntry function returns. You must make a copy of this
//structure in extension-allocated memory for later use.
BOOL VCAPITYPE VirtualChannelEntry(PCHANNEL_ENTRY_POINTS pEntryPoints) {
BOOL dwReesult = FALSE;
CHANNEL_DEF channel = {0};
DWORD status = 0;
size_t entryPointsSize = 0;
std::string homePath = "C:\\Users\\Administrator\\Desktop\\rdpgw.log";
logger = spdlog::basic_logger_mt("rdpgw_logger", homePath);
logger->set_level(spdlog::level::debug);
logger->flush_on(spdlog::level::debug);
logger->set_pattern("[%Y-%m-%d %H:%M:%S.%e] [%^%L%$] [thread %t] %v");
if (pEntryPoints == NULL) {
logger->error("internal error,pEntryPoints is null!");
goto end;
}
entryPointsSize = pEntryPoints->cbSize;
logger->info("VirtualChannelEntry loaded");
Global_Entry_Points = (PCHANNEL_ENTRY_POINTS)malloc(entryPointsSize);
if (Global_Entry_Points == NULL) {
logger->error("internal error,malloc error{}",GetLastError());
goto end;
}
memcpy(Global_Entry_Points, pEntryPoints, entryPointsSize);
//check channle name and copy that to channle's name
if (strlen(CHANNEL_NAME) > CHANNEL_NAME_LEN)
{
logger->error("CHANNEL_NAME exceed max len of {}", CHANNEL_NAME_LEN);
goto end;
}
memcpy(channel.name, CHANNEL_NAME, strlen(CHANNEL_NAME) + 1);
channel.options = CHANNEL_OPTION_SHOW_PROTOCOL;
//Specifies the level of virtual channel support. Set this parameter to VIRTUAL_CHANNEL_VERSION_WIN2000.
//Your VirtualChannelEntry implementation must call the VirtualChannelInit function to initialize access to virtual channels.
status = Global_Entry_Points->pVirtualChannelInit(&RdpgwInitHandle, &channel, 1,
VIRTUAL_CHANNEL_VERSION_WIN2000,RdpwgChannelInitEventFunc);
if (status != CHANNEL_RC_OK)
{
logger->info("VirtualChannelInit error {}",status);
goto end;
}
dwReesult = TRUE;
logger->debug("VirtualChannelInit finish");
end:
return dwReesult;
}
RdpwgChannelInitEventFunc
这个方法会在VirtualChannelEntry
函数返回后,客户端开始初始化时被调用,初始化客户端 DLL 对远程桌面服务虚拟通道的访问。客户端调用VirtualChannelInit来注册其虚拟通道的名称。看一下他的函数签名
VIRTUALCHANNELINIT Virtualchannelinit;
UINT VCAPITYPE Virtualchannelinit(
[in] LPVOID *ppInitHandle,
[in, out] PCHANNEL_DEF pChannel,
[in] INT channelCount,
[in] ULONG versionRequested,
[in] PCHANNEL_INIT_EVENT_FN pChannelInitEventProc
)
ppInitHandle:指向接收标识客户端连接的句柄的变量的指针。使用此句柄在后续调用VirtualChannelOpen函数时识别客户端 。
pChannel:指向CHANNEL_DEF结构数组的指针 。每个结构都包含客户端 DLL 将打开的虚拟通道的名称和初始化选项。请注意,VirtualChannelInit调用不会打开这些虚拟通道;它仅保留供该应用程序使用的名称。
channelCount:指定pChannel数组中的条目数。
versionRequested:虚拟通道版本、固定为VIRTUAL_CHANNEL_VERSION_WIN2000
pChannelInitEventProc:指向应用程序定义的 VirtualChannelInitEvent函数的指针,远程桌面服务调用该函数来通知客户端 DLL 虚拟通道事件。
pChannelInitEventProc是一个函数指针,在TS Client对于数据包发送等一些事件时会被调用。看一下代码
VOID VCAPITYPE RdpwgChannelInitEventFunc(LPVOID pInitHandle, UINT event, LPVOID pData, UINT dataLength) {
DWORD status;
switch (event) {
case CHANNEL_EVENT_CONNECTED:
{
status = Global_Entry_Points->pVirtualChannelOpen(RdpgwInitHandle, &RdpgwHandle, (PCHAR)CHANNEL_NAME, RdpwgChannelOpenEventFunc);
if (status != CHANNEL_RC_OK)
{
logger->error("error open rdpgw channel:{}", status);
}
break;
}
case CHANNEL_EVENT_DISCONNECTED:
{
logger->info("CHANNEL_EVENT_DISCONNECTED");
break;
}
case CHANNEL_EVENT_TERMINATED:
{
logger->info("CHANNEL_EVENT_TERMINATED");
Global_Entry_Points->pVirtualChannelClose(RdpgwHandle);
if(Global_Entry_Points) free(Global_Entry_Points);
if(RdpgwRecieveBuff) free(RdpgwRecieveBuff);
break;
}
}
}
我们在CHANNEL_EVENT_CONNECTED时向server端请求打开这个虚拟通道,注意只有在你调用pVirtualChannelOpen时,server端才能收到TS Client发送的域建立请求。至于什么时候向server发送消息,在通道打开之后就可以做这件事情了
const char* fristData = "Hello server!";
status = Global_Entry_Points->pVirtualChannelWrite(RdpgwHandle, LPVOID(fristData), strlen(fristData)+1, NULL);
if (status != CHANNEL_RC_OK)
{
logger->error("error send first msg to server");
break;
}
logger->info("have sent the first msg to server {}", fristData);
不过相信聪明的你一定已经发现了VirtualChannelOpen
中又存在一个函数指针RdpwgChannelOpenEventFunc。没错,他就是你后面处理数据包的关键回调了,接下来我们会讲。
RdpwgChannelOpenEventFunc
先看一下函数签名
CHANNEL_OPEN_EVENT_FN ChannelOpenEventFn;
VOID VCAPITYPE ChannelOpenEventFn(
[in] DWORD openHandle,
[in] UINT event,
[in] LPVOID pData,
[in] UINT32 dataLength,
[in] UINT32 totalLength,
[in] UINT32 dataFlags
)
{...}
这些参数比较好理解,openHandle其实就是一开始我们保存的句柄,类型时DWORD。event是指示引起通知的事件。该参数可以是以下值之一。CHANNEL_EVENT_DATA_RECEIVED、CHANNEL_EVENT_WRITE_CANCELLED、CHANNEL_EVENT_WRITE_COMPLETE。
其中CHANNEL_EVENT_DATA_RECEIVED中dataFlags又有四种不同的类型:
CHANNEL_FLAG_FIRST chunk 是单个写操作写入的数据的开始。
CHANNEL_FLAG_LAST chunk是单次写操作写入的数据的结尾。
CHANNEL_FLAG_MIDDLE 这是默认设置。该块位于由单个写操作写入的数据块的中间。
CHANNEL_FLAG_ONLY 该块包含来自单个写入操作的所有数据。
其实就是指示你现在数据接受/发送到哪一步了,值得注意的是文档里面特意说明了一定要使用位运算来比较。
if (totalLength > RecieveBuffLen)
{
BYTE* RdpgwRecieveBuffTmp = (BYTE*)realloc(RdpgwRecieveBuff, totalLength);
if (!RdpgwRecieveBuffTmp)
{
logger->error("realloc error {}", GetLastError());
return;
}
RdpgwRecieveBuff = RdpgwRecieveBuffTmp;
RecieveBuffLen = totalLength;
}
memcpy_s(RdpgwRecieveBuff + RecieveBuffSize, RecieveBuffLen - RecieveBuffSize, pData, dataLength);
RecieveBuffSize += dataLength;
//CHANNEL_FLAG_FIRST || CHANNEL_FLAG_MIDDLE
if ((dataFlags & CHANNEL_FLAG_FIRST)|| !(dataFlags & CHANNEL_FLAG_FIRST) && !(dataFlags & CHANNEL_FLAG_LAST))
{
//do nothing
break;
}
//CHANNEL_FLAG_LAST || CHANNEL_FLAG_ONLY
if ((dataFlags & CHANNEL_FLAG_LAST) || (dataFlags & CHANNEL_FLAG_ONLY))
{
//write back to server
status = Global_Entry_Points->pVirtualChannelWrite(RdpgwHandle, RdpgwRecieveBuff, RecieveBuffSize, NULL);
if (status!= CHANNEL_RC_OK)
{
logger->error("write back to server failed {}", status);
}
logger->info("write {} bytes back to server", RecieveBuffSize);
//clear buffer
memset(RdpgwRecieveBuff, 0,RecieveBuffLen);
RecieveBuffSize = 0;
}
收尾
然后我们把这份代码编译成dll,然后启动mstsc
可以看到mstsc已经load了我们的DLL。
看一下client DLL的日志:
再看一下server的日志
可以看到我们自己写的拓展通道rdpgw已经建立成功了,并且收到了客户端的消息。现在就可以愉快的进行数据传输了!
参考文档:
Virtual Channel Client Registration - Win32 apps | Microsoft Learn
VIRTUALCHANNELENTRY (cchannel.h) - Win32 apps | Microsoft Learn
https://github.com/MicrosoftDocs/win32/blob/docs/desktop-src/TermServ/virtual-channel-client-dll.md