☰
自研堡垒机底层协议开发:SSH消息解析与命令审计实战
2026/10/22 10:45:08 网站建设 项目流程

前阵子我们把自己内部那套运维入口系统从“跳板转发”改成了“协议级管控”,等于把堡垒机底层协议开发这条链路从头走了一遍。堡垒机这个名字不新鲜,市面上商用产品和开源跳板机都不少,但很多人只见过网页管理界面,真正决定一台堡垒机能不能扛住生产环境、能不能把每一条指令都审明白、能不能让录屏和回放不卡顿的,恰恰是很少有人愿意碰的底层协议代码。今天这篇就围绕自己动手做堡垒机协议层的核心思路来聊,适合正在搭内部运维平台、或者准备做安全管控系统的同学参考。

1. 从“套壳跳板机”到“协议层自研”:到底在补什么课

1.1 表面需求是统一入口,真正要解的是会话级管控

大多数团队对堡垒机的第一诉求,是“让所有人从一个入口登录服务器”。入口统一之后,权限收口了,操作记录也有了,看起来万事大吉。但这只是最浅的一层需求。稍微往下挖一点就会发现,真正的难点是会话级管控:谁的账号在什么时间登录了哪台机器,登录之后执行了什么命令,回显内容是什么,有没有触发高危操作,传了哪些文件,这些信息必须完整、准确、可追溯。

要实现这种级别的管控,光靠在外面套一层转发是不够的。普通的TCP转发只能看到“有人连过来了、有流量在跑”,却不知道流量里面是ls还是rm,是vi编辑器里的翻页还是故意的危险命令。想拿到命令级、回显级、甚至键盘输入级的内容,就必须在SSH协议内部去做解析和介入。这就是“底层协议开发”和“套壳跳板机”之间最本质的差别。

1.2 常规跳板方案的三个硬伤

既然开头说了“套壳”不够,那它到底差在哪?我把接触过的几个典型问题归纳成三条硬伤。

第一条,只有转发,没有内容。老式跳板机就是一个透明转发器,用户连上来之后,流量被原封不动导到目标机器。优点是不碰协议、稳定性高,缺点是对流量内容完全无感。等风控部门找过来要“高危命令阻断记录”的时候,拿不出一张能看的操作报表。

第二条,链路是明文拼装,扛不住协议状态变化。有人尝试用两个子进程拼接:一边接收用户连接,另一边再发起目标连接,然后把两边的标准输入输出用管道接起来。这种做法在只跑shell的时候还能凑合,一旦涉及窗口变化、SCP断点续传、SFTP多通道、双因子认证交互,管道拼接就出现各种莫名其妙的断连和花屏,因为SSH是带状态的消息协议,不是一条简单的字节管道。

第三条,没有会话上下文,没法做基于会话的授权。举个例子,一个运维工单只批了三十分钟,三十分钟后必须强制踢掉会话;或者某个目标主机只允许跑白名单命令,其他一律拒绝。这种场景需要把“一次SSH会话”当作一个完整的业务对象来跟踪,而套壳方案里根本没有“会话”这个概念,只有一对一对的字节流。

1.3 必须动到底层协议的三个典型场景

什么样的业务需求摆出来,基本就宣告了套壳方案的死刑?我从实际项目里挑了三个最典型的。

场景A:高危命令自动阻断。用户在终端里输入了带rm -rf的指令,堡垒机要立刻拦截,不只是弹个提示,而是不让这条命令真正落到目标机。这个需求看着简单,真正做起来却发现,用户可能是在vim里输入这段文字然后保存,也可能是通过粘贴板粘进来的。单纯匹配“有没有rm字符串”会误杀到怀疑人生,必须结合当前终端模式、输入来源、命令行的完整性来判断。

场景B:操作录像和回放要做到命令级检索。录屏不稀奇,视频格式的录屏随处可做。但客户要求的是“按命令检索录像”,也就是输入一个关键字,能把所有执行过包含该关键字的命令的片段找出来。这就要求在协议层同步保存结构化的操作流水,而不是只留一份视频。

场景C:对目标机进行命令白名单管控。只允许特定用户在那台机器上执行特定几条命令,其他命令全部拒绝。exec通道下相对好处理,因为命令是整串提交的;shell通道下就麻烦了,用户输入是逐字节到达的,必须靠输入缓冲和语义判断才能还原出一条完整命令。这些需求无一例外都指向同一个答案:得在协议层做开发。

2. SSH会话全链路透视:堡垒机看到的不是命令,而是消息

2.1 SSH不是一次连接,而是一组消息的连续流

做底层协议开发,第一件事是扭转一个观念:SSH不是一条“管道”,而是一组消息的连续流。如果停留在管道思维,后面所有功能都会做得束手束脚。

SSH协议可以粗略分成三层。最底层是传输层,负责两台机器之间建立加密通道,协商算法、交换密钥;上面是用户认证层,负责验证用户身份;再往上是连接层,支持在一条加密连接里同时跑多个channel,每个channel可以承载一次终端会话、一次端口转发、或者一次SFTP子系统。日常开发里我打交道最多的就是连接层里的channel和消息。

生活里可以这样类比:SSH连接好比一根电话线,channel就是电话线上同时进行的多路通话。每路通话里说的话,对应的是channel data消息;每路通话的接通和挂断,对应的是channel open和channel close。各路通话还不会互相串音,是因为每条channel都有独立的窗口大小和编号。

协议层开发需要处理的常见消息大致是下面这些:

消息方向消息类型作用
客户端 -> 服务端SSH_MSG_SERVICE_REQUEST请求开启服务,比如ssh-userauth
客户端 -> 服务端SSH_MSG_USERAUTH_REQUEST提交认证信息,密码、公钥、键盘交互等
客户端 -> 服务端SSH_MSG_CHANNEL_OPEN申请打开一个新的信道
服务端 -> 客户端SSH_MSG_CHANNEL_OPEN_CONFIRMATION回复信道已创建
客户端 -> 服务端SSH_MSG_CHANNEL_REQUEST信道上请求一个动作,比如申请终端、执行命令
双向SSH_MSG_CHANNEL_DATA信道上传输实际数据
双向SSH_MSG_CHANNEL_WINDOW_ADJUST调整信道接收窗口,防止数据淹没
双向SSH_MSG_CHANNEL_EOF / CLOSE结束信道

2.2 堡垒机的插入点:在协议状态机里做“看得见的手”

堡垒机在协议链路里的角色,是中间人:对内,它要扮演SSH服务端,接收用户的连接和认证;对外,它要扮演SSH客户端,去向目标主机发起连接。用户看到的堡垒机IP,其实是一个协议中转站。

中转不是简单的字节搬运。真正的协议级堡垒机,会在消息层做“看得见的手”:用户发来CHANNEL_OPEN时,堡垒机可以先不急着放行,而是根据策略判断这次会话是否允许建立;用户发来CHANNEL_REQUEST时,如果请求的是打开shell或者执行命令,堡垒机可以记录下来,并决定是放行、拒绝还是追加提示;用户发来CHANNEL_DATA时,堡垒机可以把这源源不断的字节流输入一个解析器,提取命令、拦截危险操作、同时落盘存证。

从实现角度看,堡垒机不再是A到B的管道,而是从A到B再到C的一次协议状态同步。用户和目标机各自认为自己是在跟对方直接通信,实际上它们都在跟堡垒机对话。这个位置非常微妙:做得好了,用户无感知,审计完整;做得不好,用户的每一次按键都能感觉到延迟,断线重连也很容易出错。

2.3 命令审计的边界:pty下的字节流陷阱

命令审计是底层协议开发里最容易被低估的一块。很多第一次做这个事的人都会问:不就是把用户输入包里的字符串抓出来,看看有没有敏感命令吗?真做了才知道,这里全是坑。

第一个坑是pty模式。用户在终端里操作时,SSH客户端会先发送一个“申请伪终端”的请求,告诉服务端“我要一个终端”。一旦pt y建立,用户的所有按键、终端控制序列、粘贴内容都会走CHANNEL_DATA里的字节流。你在shell里输入cat /etc/passwd,其实是先来了一个c、一个a、一个t,然后回车。如果堡垒机只按“数据块”抓内容,可能前一个包只有ca,后一个包才有t和回车,直接导致命令识别残缺。

第二个坑是终端控制序列。用户按方向键、退格键、Ctrl+C、Ctrl+L,都会产生特殊字节序列。正则表达式如果直接匹配用户的原始输入,会把rm -rf拆得七零八落。正确做法是先做控制序列清洗,再按“回车”为边界拼装命令行。

第三个坑是exec与shell的差异。exec模式下,用户提交的是“单条命令整体执行”,比如SCP会在远端执行scp -t /tmp这样的内部命令,这种时候命令是完整可见的。shell模式下则完全不一样,用户是在一个持续会话里交互,无法只靠一个包判断一条命令是否结束。所以协议层的审计模块,必须维护每个会话的“输入行缓冲”,结合是否申请了pty、是否处于shell模式、控制序列情况,才能还原出真正的命令。这也是命令识别从“能用”走向“好用”的分水岭。

3. 底层协议开发的技术选型:我为什么最终选了这套组合

3.1 语言选型:并发模型决定开发效率

堡垒机底层协议开发的语言选型,本质上是在问一个问题:你要用什么样的并发模型去处理成百上千条同时存在的SSH会话?每条会话都是独立的,它们之间的数据不能互相干扰,且每条会话内部还包含两个方向的读写流。

C/C++性能当然没问题,但手写状态机和内存管理会占掉大量开发时间,尤其是团队规模和项目周期都有压力的时候。Python上手快,却会在高并发连接时被GIL和内存占用拖住,SSH本身是CPU密集加IO密集的混合体,纯Python方案越到后期越吃力。Java可以,只是协议开发里大量用到轻量级的并发单元,偏向“每个会话一个协程”的模型更自然一些。

最终我选了Go为主力语言。原因很直接:goroutine天生适合“每会话一个协程”的模型,channel语义和SSH的channel概念非常契合,编辑器级别的协议库生态比较完整,编译产物又是一个静态二进制,部署到跳板机上省去一堆依赖管理问题。用Go写代理类服务,代码量通常比C++少一半以上,并发行为又比Python可控得多。

3.2 协议库与状态机:别重复造轮子,但要知道轮子在哪漏气

真的从零实现SSH协议的所有细节,是不推荐的。光是加密算法协商、消息填充、Hmac验证、密钥交换,就能耗掉一个开发组数月时间,而且极难保证与所有客户端版本兼容。当前的主流做法是:找一个成熟的SSH协议库作为底座,库负责加密、消息编解码、连接维持,而业务层负责策略注入和数据处理。

但这不意味着我们只是调几个API。协议层开发的核心工作,是把库暴露的“原始消息事件”和业务层的“会话策略”粘起来。以Go生态为例,底层的SSH库提供了ServerConfig和ClientConfig,可以分别创建服务端和客户端角色;库内部已经把连接层channel、窗口、数据包都处理好了,但要想在中间插一段审计钩子,就要自己动手组装一条“先入站认证、再出站连接、再桥接双方channel”的链路。

用库还有一个容易被忽视的点:要关注库对channel请求的处理粒度。有些库会把pty-req、shell、exec、subsystem这些请求封成高级API,直接暴露一个“Session”给你用,这种封装对写普通工具很方便,但对写堡垒机反而碍事,因为我们需要精确获取每个channel请求的类型、参数、返回状态。所以选型时我会偏向“越接近消息层越好”的库,宁可多写一点类型判断,也要保住对协议细节的控制力。

3.3 模块划分:连接接入、认证转发、指令提取、会话录像四个子系统

架构上,我把整个协议层拆成四个子系统,它们各自独立、通过接口协作。这个划分经历了几轮重构才稳定下来,最初把所有逻辑塞在一个大文件里,结果连加一个日志字段都要小心翼翼。

连接接入模块负责监听端口、限制并发连接数、管理连接生命周期。任何一个潜在恶意连接或异常断线,都该在这一层被兜住,而不是漏到下游。

认证转发模块负责用户身份验证和目标主机连接的建立。它要区分三类凭据:用户的堡垒机口令、用户的动态验证码/双因子信息、目标主机的托管凭据。很多初版实现都会在这里犯同一个错:把目标主机的密码直接暴露给客户端,正确做法是用户只需要认证堡垒机身份,目标主机那一步由堡垒机用自己的托管凭据去建立。

指令提取模块负责解析字节流,还原命令输入,并执行危险指令的匹配与阻断。它不直接碰网络收发,而是消费上游送来的消息队列。

会话录像模块负责把会话全过程的字节流、时间戳、终端尺寸变化、操作命令流水存储下来。它是审计体系里最吃存储的一块,合理的落盘策略会在后文展开。

子系统核心职责常见坑
连接接入监听、限流、生命周期管理忘记做最大连接数限制
认证转发用户认证、目标机凭据管理密钥暴露给客户端
指令提取字节流解析、命令还原、策略匹配直接用正则抓原始包
会话录像数据落盘、回放格式、检索索引终端尺寸变化导致画错位

4. 落地实战:手写一个最小SSH协议代理内核

4.1 服务端骨架:监听、握手与鉴权

所有复杂功能都是从最小闭环开始的。我建议先实现一个“用户从堡垒机登录后,能成功到达目标机并执行命令”的最小代理,再逐步往上加审计、策略、录像。

第一步是生成堡垒机自己的主机密钥对,用于和用户建立SSH连接。密钥生成后配置到服务端结构中。代码骨架大致长这样:

// 实际工程中请替换为项目选用的SSH协议库 import ( "sshlib" "net" ) func handleConn(raw net.Conn, cfg *sshlib.ServerConfig) { // 接受SSH握手,完成用户认证,返回一个连接对象 conn, chans, reqs, err := sshlib.NewServerConn(raw, cfg) if err != nil { log.Printf("握手失败: %v", err) raw.Close() return } // 服务端连接建立成功后,进入请求分发 go sshlib.DiscardRequests(reqs) for newChannel := range chans { // 每个channel代表一次会话的申请 go handleChannel(newChannel) } }

这段代码里,ServerConfig里面要配置好认证回调函数,当用户提交用户名密码时,回调会被触发。这里就可以接上你自己的人员库、双因子校验、甚至是用户密码改为“一次性动态口令”的逻辑。

4.2 认证接管:从用户密码到目标机密钥的跨层传递

认证是堡垒机存在感最强的一环,也是协议层开发最先遇到完整业务逻辑的地方。用户在客户端执行ssh user@堡垒机IP -p 2299,输入密码后,这个密码首先流到堡垒机的ServerConn认证回调里。校验通过后,堡垒机再充当客户端,向真正的目标机发起连接。

很多人在这里踩了坑:以为用户输入的密码可以直接“透传”给目标机。这样做的坏处非常明显,一旦堡垒机侧的记录被翻看,所有目标机的密码都会以明文或可逆密文的形式存在。更合理的做法是,用户密码只用于登录堡垒机,目标机连接用堡垒机托管的凭据去建立。用户的身份和可访问的目标机范围是两套数据,在第一次握手时就彻底分开。

代码上,目标机连接的建立逻辑大致是:

func dialTarget(targetAddr string) (*sshlib.ClientConn, error) { clientCfg := &sshlib.ClientConfig{ User: "deploy", Auth: []sshlib.AuthMethod{sshlib.PublicKeys(loadManagedKey())}, HostKeyCallback: verifyHostKey, // 目标机指纹校验 } conn, err := sshlib.Dial("tcp", targetAddr, clientCfg) return conn, err }

这段逻辑里最关键的是HostKeyCallback,稍后我会单独讲为什么它是安全底线。

4.3 信道转发与窗口处理

认证通过后,用户发来的CHANNEL_OPEN请求会到达堡垒机。堡垒机要做两件事:一是接受用户侧的channel,二是同时向目标机发一个CHANNEL_OPEN。两边都成功后,就进入了数据转发状态。

数据转发的核心是两个方向独立搬运数据,但在SSH里搬运的是channel data消息,不能简单用TCP的io.Copy就完事。SSH底层有窗口机制:接收方会通告自己还能接收多少字节,发送方只能在窗口范围内发数据,防止接收方缓冲区被冲爆。代理层如果没处理好窗口,最直接的表现是“终端操作卡顿、输入响应慢、滚动日志时掉帧”。

一个实用的做法是给每条channel配置独立的读写缓冲区,并设定合理的窗口初始值。代码形态可以这样简写:

func bridgeChannels(userCh, targetCh sshlib.Channel) { done := make(chan struct{}, 2) go func() { io.Copy(userCh, targetCh) // 目标返回给用户 userCh.CloseWrite() done <- struct{}{} }() go func() { io.Copy(targetCh, userCh) // 用户输入给目标 targetCh.CloseWrite() done <- struct{}{} }() <-done <-done }

这段代码还没有审计钩子,但已经能跑通基础转发。生产环境里,我会在io.Copy的位置换成自定义的copyWithAudit函数,在中间插入字节流的旁路处理。

4.4 审计钩子的正确挂点:在数据通路上拦而不是在外面数包

审计钩子最容易犯的错误,是把日志放在“连接结束之后”才统一分析。有的初版实现是转发结束后,再从临时文件里读回数据做命令提取。这种方案只能做事后审计,根本做不了实时阻断。

正确做法是把钩子挂在数据通路的时间线上。具体来说,在用户数据包到达目标机之前、目标机回显数据包返回用户之前,各放一个处理点。用户输入经过“输入审计钩子”,这里做命令还原、危险指令匹配;匹配命中时,直接切断channel,或向用户写入一段红色警告,再向目标机发送一条合法的exit命令。目标回显经过“输出审计钩子”,主要做日志落盘和敏感信息脱敏,比如登录回显里偶尔会带出数据库密码。

代码上的插入点,实际上就是在每次CHANNEL_DATA消息被解包之后、调用目标channel的send之前。这个位置是协议层开发里最有价值的单点,它同时决定了命令审计、高危阻断、录像回放的质量。

5. 上线前必须踩平的那些坑:兼容性、并发与录像

5.1 终端尺寸漂移:录屏错位的真实元凶

底层代理跑起来之后,最早暴露的问题之一就是录屏错位。用户明明把窗口拉宽了一倍,录像却不跟随变化,回放时左边大半块屏幕被截掉,命令换行也全乱了。查了半天,根因在协议消息上。

用户调整本地终端窗口大小时,SSH客户端会向服务端发送一个CHANNEL_REQUEST,请求类型是window-change,附带行数和列数。如果一个纯透明代理只做数据转发,不把这个请求转发给目标机,目标机上的shell其实感知不到终端尺寸变化,依然按老尺寸排版输出。到了录像回放阶段,因为录像上下文里记录的尺寸和真实窗口不一致,画面就错位了。

修复方案并不复杂:在通道请求这一层做一次“显式放行”,识别到window-change之后,更新会话上下文里终端宽高,再把这个请求原样转发给目标channel。同理,pty-req里带有的初始尺寸也要被解析和存储,作为录像文件的初始画面参数。

5.2 高并发下的背压与连接池参数

堡垒机上线的第一个大促日,我盯着监控面板看并发会话数一路爬升。等会话数量超过一个阈值之后,新连接开始频繁握手失败,内存曲线也出现了明显台阶。问题出在两方面。

一方面是最大会话数没有限制。每个SSH会话都对应着若干goroutine和缓冲区,无上限地接受连接,最终一定会把内存打爆。后来我在连接接入模块加了全局信号量,超过设定的最大会话数就直接拒绝新连接,并给出友好提示。这个阈值要根据机器内存和单会话占用反复压测,我们的经验值是普通配置条件下单机压到几百个并发在线会话就差不多了。

另一方面是缓冲区参数的设置。默认的读写缓冲区应对普通命令还好,可一旦用户执行tail -f或批量任务,回显数据量会瞬间打满缓冲区。这时候如果目标侧的输出速度大于用户侧的TCP消费速度,背压会一路传导到SSH窗口调整,表现就是回显延迟。我们最后把回显方向的数据缓冲区调大,并给数据传输方向加了单独的流量控制参数。

5.3 目标机指纹校验和“中间人”那个坑

这是整个底层协议开发里我几乎用最高优先级对待的一个问题。堡垒机向目标机建立连接时,如果不对目标机的主机密钥做校验,就等于打开了中间人攻击的大门:谁都能伪造一台同名目标机,坐在堡垒机前面,接收所有运维操作和敏感命令。

解决方案是目标机指纹的完整生命周期管理。第一次连接目标机时,记录主机公钥指纹,并让管理员人工确认;之后的每一次连接,都严格比对指纹,一旦发现变化,立即中止连接并告警。这里要提醒一点:不能只校验IP或者主机名,因为IP可以被抢占,主机名可以被DNS劫持。只有公钥指纹才是目标机身份的唯一可靠凭据。

Go的SSH库原生支持配置HostKeyCallback,在实际项目里,我把这个回调映射到一个内部API,连接前查询“这台目标机当前登记的指纹是什么”,连接时比对“收到的指纹是什么”,不匹配直接返回错误。这一层判断放在协议握手阶段,成本极低,收益极高。

5.4 子系统兼容:SFTP/SCP 直接被通道“吃掉”

SSH代理上线初期,我们接到最多的工单是“SFTP连接非常慢”和“SCP传大文件老断”。当时很困惑,因为shell连接完全正常。排查之后发现,问题出在子系统请求上。

SFTP并不是在普通shell通道里执行命令,而是在SSH连接层申请一个subsystemchannel,channel请求里携带sftp字样。初版实现只处理了shell和exec,遇到subsystem请求时直接忽略或者错误处理,导致客户端能握手成功,但子通道迟迟建立不起来,表现就是反复超时、连接假死。SCP则又有不同,它底层实际是一个exec scp -t 目标路径的命令,走的是exec通道,但在传输过程中会混合文本协议和二进制数据,如果代理层把带有二进制的数据当纯文本解析,就会把文件内容冲坏。

处理办法是在通道请求分发的地方,把请求类型完整分类:shell、exec、subsystem、pty-req、window-change、env全部按各自语义处理。对于SFTP,可以选择两条路:要么直接放行,不做内容审计;要么走独立的文件传输审计模块,记录文件名、大小、方向等元数据。两条路各有取舍,直接放行性能好但审计粗;独立处理审计细但开发成本高。我建议第一阶段先放行并记录元数据,跑稳定了再考虑文件级深度扫描。

如果让我重来一遍,我会先跑通最小SSH代理,再考虑RDP;先把shell和exec的审计做扎实,再扩展SFTP;录像不能只想着一帧一帧存屏,记录字节流和时间戳才是性价比最高的方案。底层协议开发最大的特点就是“开弓没有回头箭”,消息解析的粒度定粗了,后面每个功能都跟着受影响;定细了,前期工作量翻倍。多做增量,少做宏大的第一版,这是我在这个项目上最有体感的一条经验。

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

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

立即咨询