ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

自研远程桌面连接中继服务器实现

自研远程桌面连接中继服务器实现 各位好我是黒漂技术佬。前面几篇咱们聊了项目的缘起和整体架构今天正式进入「中继服务器」这个系列。先给一句话定调中继服务器是整个项目里最无聊、也最该无聊的那部分——它不解密、不缓存、不做信令协商只干两件事按房间号把两条连接配对然后把字节流原样转发。无聊意味着攻击面小意味着不容易出 bug对安全软件来说这是优点不是缺点。本篇只讲中继本身不碰加密细节那是后面几篇的主角。你会看到一个房间是怎么被表示出来的、一条新连接进来要经过哪 7 道校验、为什么同角色的新连接能顶掉旧的、转发时为什么对端不在就直接丢、连接断开时打印了哪些统计、以及启动前那些「宁可拒绝启动也不将就」的自检。一、先想清楚中继到底要知道多少很多人一上来就想给中继加一堆功能设备发现、多会话、权限分级、信令协商……但功能越多攻击面越大bug 也越多。本项目的设计哲学是「能不知道的就不让它知道」。中继只需要知道三件事就能干活这条连接是 agent 还是 viewer角色它要进哪个房间房间号它手里的令牌对不对身份凭证。至于房间里传的是画面还是键盘事件、内容是什么中继一概不关心也看不到——因为那部分是端到端加密的密钥中继没有。这种「刻意让自己变笨」的设计恰恰是安全性的来源之一就算 VPS 被攻破攻击者也只是拿到了一个只会转发密文的哑管道。二、两个核心数据结构Room 与 Peer项目源码里中继服务器用两个类把整个状态装得干干净净没有任何花哨的东西。classRoom:一个房间分别持有 agent 与 viewer 两条连接。def__init__(self,name:str):self.namename self.peers:Dict[str,Peer]{}# 角色 - Peerself.createdtime.time()defother(self,role:str)-Optional[Peer]:# 给定自己的角色返回房间里的另一个人returnself.peers.get(viewerifroleagentelseagent)defis_full(self)-bool:returnagentinself.peersandviewerinself.peersRoom的语义简单到一句话一个房间最多就两个人——一个 agent、一个 viewer。它的peers字典用「角色」当键所以「对端是谁」这件事永远能 O(1) 查到不需要遍历。classPeer:def__init__(self,ws,role:str,room:Room,nonce:bytes,hello,remote:str):self.wsws self.rolerole self.roomroom self.noncenonce# 这个连接的随机值后面派生密钥要用self.hellohello# 原始 HELLO 内容self.remoteremote# 对端 IP:端口仅用于日志self.startedtime.time()# 入房时刻self.bytes_in0self.msgs_in0self.bytes_out0self.msgs_out0Peer把一条连接需要的所有上下文打包在一起。nonce和hello后面配对成功时要用到那四个计数bytes_in/msgs_in/bytes_out/msgs_out是为断开时打统计准备的。注意nonce和hello这类涉及后续加密的字段中继只是暂存并转发自己从不去解读内容。全局还有一个rooms: Dict[str, Room]字典用房间名当键。所有房间共享一把asyncio.Lock保证并发加人、退人时不会交错出问题。三、握手校验一条新连接要过 7 道关连接刚进来时中继先在HANDSHAKE_TIMEOUT 15.0秒内等它的第一句话。这 15 秒是硬门槛超时直接close(1008, handshake timeout)踢掉——既挡住慢吞吞的恶意连接也顺带把公网扫描机器人的耐心耗尽。第一句话必须是HELLO。然后按严格顺序走下面 7 步任何一步不过就立刻断开绝不「先收进来再说」。顺序很重要先 cheap 的、先能挡住最多废连接的检查放前面避免无谓解析。第 1 步15 秒内必须完成 HELLO。上面说了超时即断。这是第一道闸门。第 2 步首包必须能解析且类型必须是 HELLO。收上来的原始字节先交给协议层拆成(类型, 载荷)。如果解析失败或者类型根本不是HELLO直接close(1008, expected HELLO)。换言之连接上来不报身份就别想往下走。第 3 步role 必须是 agent 或 viewer。从 HELLO 里取出role字段判断是不是那两个合法值。非法角色比如拼写错、或故意塞个奇怪值一律close(1008, bad role)。中继只认两种人。第 4 步room 非空。房间号不能是空字符串。空房间名没意义直接close(1008, no room)。第 5 步房间号匹配若配置了 expected_room。这一项的存在感比较微妙。中继可以配置一个「只服务某固定房间」的开关如果配了而对方报上来的房间名对不上就close(1008, wrong room)。源码注释里特意写明了房间名不是安全边界。真正的保护是靠端到端加密这里拦房间号只是防止「两个人阴差阳错连进同一个房间、互相看到对方的画面」这种尴尬。别把它当成一道安全闸门——它不扛攻击。第 6 步令牌用 hmac.compare_digest 做常量时间比较。取出 HELLO 里的token和配置里的expected_token比对ifnothmac.compare_digest(token,expected_token):awaitws.close(code1008,reasonbad token)return为什么不用普通的因为是「短路比较」——一旦某个字符对不上就立刻返回 False。攻击者可以利用「比较耗时随前缀匹配长度变化」这个细微差别逐个字节猜令牌这叫时序侧信道攻击。hmac.compare_digest会恒定时间比完所有字节无论对错耗时都一样把这条路堵死。关于它为什么重要讲两个密钥域的那一篇会专门展开。第 7 步nonce 必须是合法 hex 且至少 8 字节。nonce 是后面派生会话密钥要用到的随机值由客户端生成。中继要求它能正确解析成十六进制字节串且长度 8字节。太短的 nonce 随机性不够直接close(1008, bad nonce)。过了这 7 关连接才算真正「入房」。可以看到整个握手没有数据库、没有外部调用所有判断都在内存里几微秒搞定——这就是「极简」的代价换来的稳健。四、顶替逻辑同角色新连接接管旧连接一个真实场景你家里的 viewer 连上了中途断了重连或者你在公司电脑上 Agent 重启了。这时候同一个角色比如又来一个 agent要进同一个房间房间里已经有一个同角色的旧连接占着位。处理办法很干脆新的进来旧的被请出去。oldroom.peers.get(role)ifoldisnotNone:awaitold.ws.close(code1000,reasonreplaced by new connection)room.peers[role]peer# 新连接上位旧的那个收到code1000正常关闭 原因replaced by new connection干净利落地退出。而新连接则占据这个角色位。这样设计的好处是你不用去手动「清掉僵尸连接」重连自然就接管了对端几乎无感。同时中继还会通过RELAY通知类型给被顶掉那一侧发一个replaced提示让它在界面上知道「你的连接被新连接顶替了」。注意这里只对「同角色」生效。agent 顶 agent、viewer 顶 viewer一个 agent 和一个 viewer 才是「配对」永远不会互相顶。这正是房间只有两人的语义体现。五、转发逻辑对端不在就直接丢绝不缓存配对成功后房间就进入了「搬运工」模式。中继的转发循环极其朴素asyncformessageinws:peer.bytes_inlen(message)peer.msgs_in1targetroom.other(role)# 对端iftargetisNone:continue# 对端不在直接丢弃awaittarget.ws.send(message)target.bytes_outlen(message)target.msgs_out1这里有两个值得拎出来说的点。第一对端不在时直接丢弃不缓存。源码注释写得很直白「不缓存。远程桌面缓存只会造成延迟。」远程桌面的实时性比「不丢一帧」重要得多。如果把暂时发不出去的帧攒起来等对端连上来再一股脑倒过去用户看到的就是一大段「快进回放」——又卡又没意义。所以宁可丢了让上层靠关键帧自愈。第二中继只认类型、不读内容。转发时它只从消息第一个字节知道这是FRAME还是CTRL还是别的用来在 DEBUG 日志里打一行「哪个房间、谁发给谁、什么类型、多少字节」。但载荷内容它从不记录——即使是 DEBUG 级别也只记类型和大小绝不会把画面或键鼠数据写进日志。这一点在源码里是硬约束不是靠自觉。六、断开清理打印会话账本空房间就回收一条连接不管怎么结束正常关闭、异常断开、被顶替最后都会走到cleanupLOG.info(房间 [%s] -%s 来自 %s 会话 %.1fs 收 %s/%d 条 发 %s/%d 条,name,role,remote,time.time()-gone.started,fmt_bytes(gone.bytes_in),gone.msgs_in,fmt_bytes(gone.bytes_out),gone.msgs_out)ifnotroom.peers:delrooms[name]LOG.info(房间 [%s] 已空回收,name)这条日志非常实用你能看到这次会话持续了多久、收了多少字节、发了多少字节、多少条消息。对排查「为什么延迟高」「为什么流量大」很有帮助。字节数会用fmt_bytes自动换算成 KB/MB读起来舒服。如果房间里最后一个成员也走了room.peers空了整个Room对象直接从全局rooms字典里删掉——内存立刻回收不会留下一堆空房间占着字典。这就是所谓「房间空了回收」没有定时清理、没有引用计数泄漏的小工具那种隐疾。另外如果走掉的是房间里的一方中继还会给还留在房间里的另一方发一个peer_left通知让它的界面能及时提示「对端已离开」。七、启动自检宁可拒绝启动也不将就一个面向安全的中继最怕的是「带着错误配置悄悄跑起来」。所以启动时有一道自检关卡令牌为空或短于 16 位直接拒绝启动返回退出码 2。日志会明确说「配对令牌太短少于 16 位建议 32 位以上」。为什么是 16因为令牌是纯靠「猜」来突破的唯一屏障太短等于没设防。建议值给到 32 位随机串是强度与可输入性的折中。配置里出现了common.password打印警告。这点反直觉但很重要中继不需要端到端密码。密码只应给 Agent 和 Viewer。如果 VPS 配置里误填了 password中继会好心提醒你删掉——因为它「拿到也用不上反而违背了『中继看不到内容』的设计」。这既是一个安全提醒也是一种「设计意图的强制执行」。这两道检查保证了一个部署上线时的中继要么配置是健康的要么干脆起不来绝不会「勉强能跑但存在隐患」。八、自签证书现场生成指纹当场打印中继默认启用 TLS但公网 VPS 不可能为每个自托管项目去申请 CA 签名证书而且签发需要域名验证对纯 IP 不友好。于是项目选择自签证书如果配置里没给现成证书就在启动时用 openssl 现场生成openssl req-x509-newkeyrsa:2048-nodes\-keyoutrelay.key-outrelay.crt-days3650\-subj/CNalspd-desk-relayrsa:2048足够有效期 10 年-days 3650主题是固定的/CNalspd-desk-relay。如果系统里连 openssl 都找不到启动会明确提示「请先安装apt install -y openssl」而不是给个莫名其妙的错误。证书生成后中继会立刻算它的 SHA256 指纹并打印出来fphashlib.sha256(ssl.PEM_cert_to_DER_cert(pem)).hexdigest()print( 证书 SHA256 : %s%fmt_fp(fp))这个指纹是给两端做「可选钉扎」用的——你想严格校验对端证书就把这串填到两端的tls.pinned_fingerprint。但注意源码紧接着就提醒若公司网络做 TLS 中间人解密请把钉扎留空否则连不上。关于「为什么 TLS 只是马甲、为什么不强制钉扎」本系列第 5 篇会专门展开。TLS 上下文的配置也透着「够用就好」的务实ctxssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)ctx.minimum_versionssl.TLSVersion.TLSv1_2# 最低 TLS 1.2淘汰老旧协议ctx.load_cert_chain(cert,key)服务启动时max_size16MB、ping_interval20、ping_timeout20、compressionNone——前两个用来及时发现死连接最后的compressionNone是因为载荷已经是 JPEG 密文再加压缩纯属浪费 CPU。九、小结极简不是偷懒是主动收窄攻击面把这一篇串起来看中继服务器的全部本事就是用Room/Peer两个结构把「最多两人的房间」表示清楚7 步握手校验顺序固定、错一步即断且第 6 步用常量时间比较防时序攻击同角色新连接顶替旧连接重连无感转发只认类型不读内容对端不在直接丢、不缓存断开时打印会话账本空房间立即回收启动自检卡死「短令牌」和「误填 password」两个雷自签证书现场生成指纹当场给你。你会发现它刻意不去做的事情不解密、不缓存、不做设备发现、不存日志内容和它做了的事情一样重要。安全软件里能删掉的功能就是能删掉的攻击面。把中继收敛成「只配对、只转发」的极简角色正是它敢说自己安全的底气。
返回列表