☰
自研远程桌面连接中继服务器实现
2026/9/29 21:46:15 网站建设 项目流程

各位好,我是黒漂技术佬。前面几篇咱们聊了项目的缘起和整体架构,今天正式进入「中继服务器」这个系列。先给一句话定调:中继服务器是整个项目里最无聊、也最该无聊的那部分——它不解密、不缓存、不做信令协商,只干两件事:按房间号把两条连接配对,然后把字节流原样转发。无聊,意味着攻击面小,意味着不容易出 bug,对安全软件来说这是优点不是缺点。

本篇只讲中继本身,不碰加密细节(那是后面几篇的主角)。你会看到:一个房间是怎么被表示出来的、一条新连接进来要经过哪 7 道校验、为什么同角色的新连接能顶掉旧的、转发时为什么对端不在就直接丢、连接断开时打印了哪些统计、以及启动前那些「宁可拒绝启动也不将就」的自检。

一、先想清楚:中继到底要知道多少

很多人一上来就想给中继加一堆功能:设备发现、多会话、权限分级、信令协商……但功能越多,攻击面越大,bug 也越多。本项目的设计哲学是「能不知道的就不让它知道」。

中继只需要知道三件事就能干活:

  • 这条连接是 agent 还是 viewer(角色);
  • 它要进哪个房间(房间号);
  • 它手里的令牌对不对(身份凭证)。

至于房间里传的是画面还是键盘事件、内容是什么,中继一概不关心也看不到——因为那部分是端到端加密的,密钥中继没有。这种「刻意让自己变笨」的设计,恰恰是安全性的来源之一:就算 VPS 被攻破,攻击者也只是拿到了一个只会转发密文的哑管道。

二、两个核心数据结构:Room 与 Peer

项目源码里,中继服务器用两个类把整个状态装得干干净净,没有任何花哨的东西。

classRoom:"""一个房间:分别持有 agent 与 viewer 两条连接。"""def__init__(self,name:str):self.name=name self.peers:Dict[str,"Peer"]={}# 角色 -> Peerself.created=time.time()defother(self,role:str)->Optional["Peer"]:# 给定自己的角色,返回房间里的另一个人returnself.peers.get("viewer"ifrole=="agent"else"agent")defis_full(self)->bool:return"agent"inself.peersand"viewer"inself.peers

Room的语义简单到一句话:一个房间最多就两个人——一个 agent、一个 viewer。它的peers字典用「角色」当键,所以「对端是谁」这件事永远能 O(1) 查到,不需要遍历。

classPeer:def__init__(self,ws,role:str,room:Room,nonce:bytes,hello,remote:str):self.ws=ws self.role=role self.room=room self.nonce=nonce# 这个连接的随机值,后面派生密钥要用self.hello=hello# 原始 HELLO 内容self.remote=remote# 对端 IP:端口,仅用于日志self.started=time.time()# 入房时刻self.bytes_in=0self.msgs_in=0self.bytes_out=0self.msgs_out=0

Peer把一条连接需要的所有上下文打包在一起。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(code=1008,reason="bad token")return

为什么不用普通的==?因为==是「短路比较」——一旦某个字符对不上就立刻返回 False。攻击者可以利用「比较耗时随前缀匹配长度变化」这个细微差别,逐个字节猜令牌(这叫时序侧信道攻击)。hmac.compare_digest会恒定时间比完所有字节,无论对错耗时都一样,把这条路堵死。关于它为什么重要,讲两个密钥域的那一篇会专门展开。

第 7 步:nonce 必须是合法 hex 且至少 8 字节。nonce 是后面派生会话密钥要用到的随机值,由客户端生成。中继要求它:能正确解析成十六进制字节串,且长度>= 8字节。太短的 nonce 随机性不够,直接close(1008, "bad nonce")。

过了这 7 关,连接才算真正「入房」。可以看到,整个握手没有数据库、没有外部调用,所有判断都在内存里几微秒搞定——这就是「极简」的代价换来的稳健。

四、顶替逻辑:同角色新连接接管旧连接

一个真实场景:你家里的 viewer 连上了,中途断了重连;或者你在公司电脑上 Agent 重启了。这时候同一个角色(比如又来一个 agent)要进同一个房间,房间里已经有一个同角色的旧连接占着位。

处理办法很干脆:新的进来,旧的被请出去。

old=room.peers.get(role)ifoldisnotNone:awaitold.ws.close(code=1000,reason="replaced by new connection")room.peers[role]=peer# 新连接上位

旧的那个收到code=1000(正常关闭)+ 原因replaced by new connection,干净利落地退出。而新连接则占据这个角色位。这样设计的好处是:你不用去手动「清掉僵尸连接」,重连自然就接管了,对端几乎无感。同时中继还会通过RELAY通知类型给被顶掉那一侧发一个replaced提示,让它在界面上知道「你的连接被新连接顶替了」。

注意这里只对「同角色」生效。agent 顶 agent、viewer 顶 viewer;一个 agent 和一个 viewer 才是「配对」,永远不会互相顶。这正是房间只有两人的语义体现。

五、转发逻辑:对端不在就直接丢,绝不缓存

配对成功后,房间就进入了「搬运工」模式。中继的转发循环极其朴素:

asyncformessageinws:peer.bytes_in+=len(message)peer.msgs_in+=1target=room.other(role)# 对端iftargetisNone:continue# 对端不在,直接丢弃awaittarget.ws.send(message)target.bytes_out+=len(message)target.msgs_out+=1

这里有两个值得拎出来说的点。

第一,对端不在时直接丢弃,不缓存。源码注释写得很直白:「不缓存。远程桌面缓存只会造成延迟。」远程桌面的实时性比「不丢一帧」重要得多。如果把暂时发不出去的帧攒起来,等对端连上来再一股脑倒过去,用户看到的就是一大段「快进回放」——又卡又没意义。所以宁可丢了,让上层靠关键帧自愈。

第二,中继只认类型、不读内容。转发时它只从消息第一个字节知道这是FRAME还是CTRL还是别的,用来在 DEBUG 日志里打一行「哪个房间、谁发给谁、什么类型、多少字节」。但载荷内容它从不记录——即使是 DEBUG 级别,也只记类型和大小,绝不会把画面或键鼠数据写进日志。这一点在源码里是硬约束,不是靠自觉。

六、断开清理:打印会话账本,空房间就回收

一条连接不管怎么结束(正常关闭、异常断开、被顶替),最后都会走到cleanup:

LOG.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"/CN=alspd-desk-relay"

rsa:2048足够,有效期 10 年(-days 3650),主题是固定的/CN=alspd-desk-relay。如果系统里连 openssl 都找不到,启动会明确提示「请先安装:apt install -y openssl」,而不是给个莫名其妙的错误。

证书生成后,中继会立刻算它的 SHA256 指纹并打印出来:

fp=hashlib.sha256(ssl.PEM_cert_to_DER_cert(pem)).hexdigest()print(" 证书 SHA256 : %s"%fmt_fp(fp))

这个指纹是给两端做「可选钉扎」用的——你想严格校验对端证书,就把这串填到两端的tls.pinned_fingerprint。但注意,源码紧接着就提醒:若公司网络做 TLS 中间人解密,请把钉扎留空,否则连不上。关于「为什么 TLS 只是马甲、为什么不强制钉扎」,本系列第 5 篇会专门展开。

TLS 上下文的配置也透着「够用就好」的务实:

ctx=ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)ctx.minimum_version=ssl.TLSVersion.TLSv1_2# 最低 TLS 1.2,淘汰老旧协议ctx.load_cert_chain(cert,key)

服务启动时max_size=16MB、ping_interval=20、ping_timeout=20、compression=None——前两个用来及时发现死连接,最后的compression=None是因为载荷已经是 JPEG + 密文,再加压缩纯属浪费 CPU。

九、小结:极简不是偷懒,是主动收窄攻击面

把这一篇串起来看,中继服务器的全部本事就是:

  1. 用Room/Peer两个结构,把「最多两人的房间」表示清楚;
  2. 7 步握手校验,顺序固定、错一步即断,且第 6 步用常量时间比较防时序攻击;
  3. 同角色新连接顶替旧连接,重连无感;
  4. 转发只认类型不读内容,对端不在直接丢、不缓存;
  5. 断开时打印会话账本,空房间立即回收;
  6. 启动自检卡死「短令牌」和「误填 password」两个雷;
  7. 自签证书现场生成,指纹当场给你。

你会发现,它刻意不去做的事情(不解密、不缓存、不做设备发现、不存日志内容)和它做了的事情一样重要。安全软件里,能删掉的功能就是能删掉的攻击面。把中继收敛成「只配对、只转发」的极简角色,正是它敢说自己安全的底气。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询