☰
正反向隔离装置TCP/UDP穿透原理与最小可运行实现
2026/10/8 15:55:01 网站建设 项目流程

简介:面向网络运维、安全研发及工控隔离场景下的技术工程师,资源演示正反向隔离装置上TCP与UDP协议的穿透实现,重点解决隔离设备两侧业务系统无法直接通信时的数据转发问题。资源共包含11个文件,压缩后仅1.68MB,内有2份ini参数配置文件、2份cpp源码工程、TCP和UDP对应的代理可执行程序及测试程序、1份txt使用说明和1份整体方案PPT;配置项与源码相结合,能帮助读者快速定位监听端口、目标地址、转发策略等关键逻辑。作者在原有TCP穿透基础上新增了UDP穿透分支,并单独提供udp_send与udp_recv测试入口,便于对照验证两种协议在隔离装置中的会话保持与数据回传机制。资源体积小、目录结构清晰,适合直接作为协议穿透学习样本或二次开发脚手架,已有1248人学习下载,对于需要快速搭建隔离环境验证通信链路的开发者来说,是一份轻量且实用的参考材料。

1. 正反向隔离装置为什么容不下一条裸TCP链路

变电站的运维终端要连调度网的 Oracle 数据库,网关上明明开了 1521 端口,流量就是出不去;外网侧一台采集服务器的 UDP 码流,内网业务想接收,隔离装置把报文一封,业务直接黑屏。这不是配置错,是正反向隔离装置的设计逻辑就是这个:裸 TCP/UDP 不允许穿过物理隔离边界。标题里的 tcp/udp 穿透 demo,就是把 TCP 流和 UDP 报文"翻译"成隔离装置能识别的应用层块数据,在不绕过装置安全功能的前提下,让业务感知不到中间那道墙。它解决的场景集中在电力、石化、轨道交通这类强隔离网络里,适合正在做等保改造、被隔离装置卡过业务、想快速验证一条链路能不能通的工程师。下面从原理、代码、参数到现场踩坑,按一条完整可复现的路径往下讲。

2. 分清正反向隔离装置的脾气:协议边界与穿透前提

2.1 正向隔离和反向隔离是两套完全不同的传输规则

正向隔离装置解决的是"内网主动往外网送数据"的合规问题,常见场景是调度数据从安全 I 区送到 III 区。它允许内网侧主动发起,数据单向穿过装置,外网侧只能被动接收,不能反过来向内网发起连接。反向隔离装置则反过来,外网侧可以发起请求,内网侧做出响应,但内网侧不能主动找外网。两套规则在装置内部的报文格式、链路管理和并发约束上完全不一样,很多第一次写 demo 的人把正反向当成一对对称通道来设计,觉得正向能 push 反向也能 push,结果在反向侧直接翻车。

再从 TCP 和 UDP 协议的区别来看,TCP 是面向连接的,有三次握手、序号确认、重传机制,对乱序和丢包都很敏感;UDP 是面向报文的,发完就不管,业务对丢包和时延的容忍度全靠在应用层自己兜底。隔离装置本身不解析四层信息,它只处理应用层块数据,所以 TCP 的流语义、UDP 的报文语义都必须由穿透层自己模拟和还原。这也是通用内网穿透工具在隔离场景里失效的原因——它们靠建立透明的四层隧道或打洞去绕过网络设备,而隔离装置根本不允许建立这种连接,只放行它认识的协议。

2.2 穿透的本质:把连接语义装进装置认识的报文里

穿透层要做的事,是把 TCP 流切成块,给每块标上会话 ID 和序号,然后封装成装置认识的帧格式送过去;对端收到后按序号重组,还原成流。对 UDP 来说,每条报文本身就是一个完整块,穿透层只需加上会话 ID 和报文序号,接收方就能还原报文边界。这个设计里有三个必须想清楚的点:TCP 流切块后要支持乱序重组和丢块重传,否则业务层的 TCP 重传会把穿透层的重复流量放大好几倍;UDP 报文必须保留边界信息,接收方才能把报文完整交给业务;隔离装置的响应通道很受限,正向装置外网侧通常不支持向内网回数据,反向装置内网侧不能主动外发,所以确认和重传机制必须绕着装置规则走。

2.3 demo 程序的边界:什么能穿、什么不该穿

写 demo 前先把边界画清楚。能穿的是业务连接语义本身,包括 TCP 流的可靠搬运和 UDP 报文的边界还原。不能穿的是"绕过隔离装置安全审核"这件事——穿透层只做格式适配和传输控制,不替代装置的访问控制、白名单和审计日志。真实现场里装置两侧通常还挂着防火墙和认证网关,demo 可以不放这些进来,但代码必须留好适配层接口。我一般会在代码和设计文档里明确写一句:穿透层只做传输,不做安全决策。这句话放到评审材料里,能省掉后面很多解释。

3. 搭一个最小可跑的穿透 demo:正向 TCP 反向 UDP

3.1 项目结构和帧格式定义

demo 按三个模块组织:local_agent 跑在业务发起侧,gw_client 是和隔离装置打交道的适配层,remote_agent 跑在业务接收侧。为了让逻辑可读,代码用 Python 写,gw_client 里的 send_block 和 recv_block 两个方法就是厂商 SDK 的替换点,现场把它换成装置厂商提供的接口函数即可。

帧格式是穿透层的地基,正向和反向建议共用一套布局:帧头 6 字节里放 magic(2 字节)、类型 type(1 字节)和数据块长度 len(2 字节),随后是 2 字节会话 ID、4 字节块序号、4 字节总块数,最后是 payload 和 2 字节 CRC16。帧总长必须控制在装置允许的单块最大长度以内,这个值的设定放在第 4 章单独讲。

3.2 正向隔离侧让 TCP 业务穿透:内网主动推、外网重组转发

正向穿透的思路是:内网侧 local_agent 监听业务端口,发现连接后分配一个会话 ID,把 TCP 流按块切开,逐块交给 gw_client 送过装置;外网侧 remote_agent 收到块后按会话和序号重组,再与目标服务建立真实连接写入数据。注意正向装置外网侧不能主动回发,所以目标服务的应答要由内网侧下一次主动发送时捎带回去,或者单独走一条反向链路,这就是标题里"正反向"要并列出现的原因。

这里给出正向 TCP 穿透的核心代码。

# local_agent_tcp.py —— 正向隔离侧,内网业务发起端 import socket, struct, time DATA_BLOCK_SIZE = 2048 # 单块 payload 上限,见第 4 章 SESS_BUCKET = {} # session_id -> {conn, acked_seq} SESS_ID = 1000 # 内网侧会话号从 1000 开始 def make_frame(sess_id, seq, total, payload): # 帧结构:magic(2) + type(1) + len(2) + sess(2) + seq(4) + total(4) + payload + crc16(2) hdr = struct.pack('>H B H H I I', 0x5A5A, 0x01, len(payload), sess_id, seq, total) crc = calc_crc16(hdr + payload) return hdr + payload + crc def handle_conn(sock, gw): global SESS_ID SESS_ID += 1 sess = SESS_ID seq = 0 # 0 号帧是会话声明,对端收到后知道新 TCP 连接开始 gw.send_block(make_frame(sess, 0, 0, b'HELLO')) while True: data = sock.recv(4096) if not data: break # 每块不超过 DATA_BLOCK_SIZE,不足一块也单独成块 for i in range(0, len(data), DATA_BLOCK_SIZE): chunk = data[i:i + DATA_BLOCK_SIZE] seq += 1 gw.send_block(make_frame(sess, seq, 0, chunk)) # 等待对端捎带回执,回执里带最新确认序号 ack = gw.recv_block() if ack: _, aseq = parse_ack(ack) if sess in SESS_BUCKET: SESS_BUCKET[sess].append(aseq)

代码逻辑很直白:handle_conn 每从业务 socket 收到一段 TCP 数据,就按 DATA_BLOCK_SIZE 切块,每块生成一个帧,帧里带会话 ID 和递增序号。发送后立即等一个回执块,回执不是独立的确认报文,而是由对端在下一次主动发送时顺带返程捎带。参数上 seq 从 1 开始递增,0 号帧专门用于声明新会话,避免对端把业务数据拼进上一条连接里。需要说明的是,这个简化版本假设帧按序到达,如果隔离装置或中间网络存在乱序,remote 侧就得按 seq 落位到 slot 再交付,代码量会多一截,但思路就是按序号排好、按连续序号向上交付。

# remote_agent_tcp.py —— 正向隔离侧,外网业务接收端 import socket TARGET_HOST = '10.0.0.5' # 内网业务想访问的外网目标服务 TARGET_PORT = 1521 REASSEMBLY = {} # sess_id -> {target_sock, last_seq} def recv_tcp_loop(gw): while True: frame = gw.recv_block() sess, seq, total, payload = parse_frame(frame) if seq == 0: # 新会话声明,主动接管目标端口 sock = socket.create_connection((TARGET_HOST, TARGET_PORT)) REASSEMBLY[sess] = {'sock': sock, 'last_seq': 0} continue entry = REASSEMBLY.get(sess) if not entry: continue # 按序写入真实 TCP 连接;本简化版不做乱序缓存 entry['sock'].sendall(payload) entry['last_seq'] = seq

remote_agent 收到 0 号帧时新建会话并连接目标服务,之后每个数据帧都直接 sendall 写入目标 TCP 连接。这里做了一个明确假设:帧按序到达。实际现场如果装置不保证顺序,要在 entry 里加一个 dict 按 seq 缓存,把已经连续的块交付,窗口大小可以参考下一条连接的重传队列长度。会话 ID 分配,我习惯让内网侧从 1000 起、外网侧从 9000 起,两侧区间错开,抓包时一眼能看出报文方向。

3.3 反向隔离侧让 UDP 业务穿透:轮询拉取模式

反向隔离装置允许外网侧发起请求、内网侧应答,但内网侧不能主动外发。很多 UDP 业务是外网往内网推数据,比如采集设备向内网终端送码流,这正好和装置的规则相反。常见做法是内网侧定期发一个"拉取请求",外网侧把缓存里的 UDP 业务报文打包在响应里带回来。这个模式天然引入一个延迟,延迟上限约等于轮询间隔。

# remote_agent_udp.py —— 反向装置外网侧,收 UDP 并缓存 import socket UDP_CACHE = [] # 元素格式: (sess_id, payload) def udp_recv_loop(udp_sock): while True: data, addr = udp_sock.recvfrom(2048) sess_id = 9000 + (addr[1] % 256) # 用源端口映射会话 UDP_CACHE.append((sess_id, data)) if len(UDP_CACHE) > 2000: UDP_CACHE.pop(0) def on_poll(sess_id): # 响应拉取请求:按会话取出全部缓存,清空队列 out = [item for item in UDP_CACHE if item[0] == sess_id] UDP_CACHE[:] = [x for x in UDP_CACHE if x[0] != sess_id] return out
# local_agent_udp.py —— 反向装置内网侧,轮询拉取 import time POLL_INTERVAL = 0.1 # 秒,业务时延敏感就调小,注意装置拉取频率上限 def poll_loop(gw, udp_sock, local_addr): next_poll = 0 while True: now = time.time() if now >= next_poll: # 拉取请求里带期望的会话号 for sess_id, payload in gw.request_pull(SESS_ID): udp_sock.sendto(payload, local_addr) next_poll = now + POLL_INTERVAL time.sleep(0.005)

POLL_INTERVAL 是反向穿透最重要的参数,直接决定业务报文的最大延迟。视频这类实时业务我一般先给 0.05 秒试,但调小前要确认装置对单位时间拉取请求数有没有上限,很多装置默认限流,拉太频繁会直接拒掉。另一个细节是一次拉取可以批量返回多条缓存记录,比一条一条拉效率高,所以 on_poll 里把整个会话的缓存一次性给出去,内网侧再逐条 sendto。

3.4 正向链路和反向链路合流时的一个坑

正向 TCP 链路和反向 UDP 链路最终都会汇到同一个 gw_client 适配层。如果现场是独立的正向、反向两台装置并排部署,gw_client 就要维护两套独立的传输通道:正向帧走正向装置的 SDK 句柄,反向帧走反向装置的句柄,不能共用一个连接池。原因在于装置侧会按帧类型把两个逻辑链路混排,造成序号区间撞车、重组出现空洞。demo 里我通常直接初始化两个 gw 实例,一个绑正向配置、一个绑反向配置,连接池互不共享,代码里也会用配置项把 host 和 port 分开写,省得现场改错。

4. 穿透 demo 的三个必调参数:超时、块大小、心跳与重传

4.1 DATA_BLOCK_SIZE:块大小决定效率和 MTU 风险

隔离装置对单块报文长度有上限,常见在 1KB 到 16KB 之间。块设太小,帧头开销占比高,吞吐上不去;块设太大,超过装置限制或中间交换机 MTU,报文被丢弃或分片,重组失败率直线上升。我的经验是先查装置手册里的单块报文上限,取它的一半作为 DATA_BLOCK_SIZE。例如装置上限 4KB,就取 2KB,既留出帧头余量,又避免 IP 分片。

参数建议值调整依据
DATA_BLOCK_SIZE装置单块上限的一半避免 IP 分片,平衡头部开销
SESSION_TIMEOUT60 秒要大于业务最长静默期
HEARTBEAT_INTERVAL10 秒小于装置会话老化时间
POLL_INTERVAL0.1 秒UDP 业务时效与装置拉取限流的折中
RETRY_MAX3 次重传再多会阻塞装置队列

对 TCP 业务,2KB 块在千兆链路上效率已经够用;对 UDP 业务,业务报文本身超过 DATA_BLOCK_SIZE 时,穿透层要自己拆成多块,重组后再交给业务,不能把超长报文硬塞进一个块里,否则装置侧直接丢弃。

4.2 超时与重传:丢块重传往业务连接上靠

穿透层的重传不能和业务 TCP 的重传各打各的。如果内网业务数据发过来后被穿透层重传了 3 次才过装置,对端 TCP 栈会认为 RTT 变大,重新计算超时上限,应用层的真实超时判断就会被搅乱。我一般把穿透层重传次数限制在 2~3 次,重传间隔取 200ms 到 1s 之间,且小于业务 TCP 的初始 RTO(通常约 1 秒),保证业务侧还没触发重传,穿透层已经把块补过去了。对 UDP 业务则不做重传或最多重传 1 次,因为 UDP 应用通常有自己的应用层兜底,穿透层重传反而容易造成报文重复。

4.3 心跳与保活:让装置认为这条链路还活着

隔离装置有会话老化机制,一段时间收不到合法报文,就回收会话状态,后续帧会被拒收。所以穿透层必须有心跳帧,它不携带业务数据,只告诉装置"链路还在"。心跳帧走同一套帧格式和会话 ID,只是 type 标记为 0x00。心跳间隔取装置老化时间的 1/3 到 1/2,常见是 10 秒。还要注意装置两侧如果经过 NAT 网关,UDP 映射老化时间通常只有 30~60 秒,穿透层心跳间隔也要小于这个值,所以我在现场直接把心跳设成 5~10 秒,NAT 和装置的老化一起覆盖。

心跳这地方我吃过一次亏。最初把心跳间隔调成 30 秒,想着省流量,结果装置 20 秒就回收了会话,后面的业务帧全部被拒收,现象是链路上有去无回。后来在装置侧开启会话状态日志才定位到,从此再也不敢把心跳间隔调得比装置老化时间还大。这个调参原则要记住:心跳间隔 = 装置老化时间的一半,留一倍裕量,而不是凑整数。

5. 穿透 demo 部署避坑:现场踩过的 5 个问题

5.1 现象:TCP 穿透链路握手超时,业务侧报 connection timed out

原因:local_agent 在 TCP 三次握手完成之前就把数据切块送过装置了。TCP 业务端口收到 SYN 包时,socket 连接还没建立,recv 实际上拿不到业务数据,但 demo 代码如果不等 accept 就直接 recv,会把空数据或半包切块送走,对端 remote_agent 以为会话开始了,实际数据还是空的,业务握手自然超时。

解决:handle_conn 里必须先完成 accept,再进入 recv 循环;0 号会话声明帧必须在 accept 之后发,而不是 connect 时发。如果现场已经出现这个现象,把 local_agent 的 accept 返回值打日志,确认连接状态再放行业务数据处理。

5.2 现象:UDP 穿透丢包率远高于交换机直连

原因:一半是 MTU 问题。业务 UDP 报文超过 DATA_BLOCK_SIZE,穿透层又没有做拆包重组,超过了装置单块上限被静默丢弃;另一半是缓存溢出,remote_agent 的 UDP_CACHE 上限设得太小,业务突发一来,先到的缓存被新报文挤掉。

解决:先抓包看业务报文最大长度,把穿透层拆包逻辑补上,保证任何进入装置的帧都不超过 DATA_BLOCK_SIZE;再把 UDP_CACHE 上限放大到 5000 条以上,同时按会话单独计数,某个会话缓存超过阈值时单独告警,而不是整体溢出。UDP 穿透这里我自己的做法是宁可让接收方在重组时发现缺口主动丢包,也不要让缓存挤兑造成全部会话的连锁丢包。

5.3 现象:反向穿透时业务流量把装置堵死,拉取请求经常超时

原因:POLL_INTERVAL 调太小了,加上批量拉取的逻辑没做流控。内网侧每 0.05 秒就发一次拉取请求,外网侧一有业务就大量回包,装置的处理队列被打满,正常的拉取请求被排队挤掉。

解决:把 POLL_INTERVAL 调到装置规格书允许的上限以内,一般 0.1 秒起步;二次再调小。同时给拉取请求加一个携带最大报文数的参数,外网侧一次最多返回 N 条,内网侧等这批处理完再发起下一轮拉取。这种"请求-限量应答"的节奏,比一次全量搬更接近装置的处理模型。

5.4 现象:重启装置后穿透会话不恢复,业务一直收不到数据

原因:会话状态全存在内存里,装置重启后会话 ID 和序列号全部重置,两侧穿透层的 SESS_BUCKET 和 REASSEMBLY 还保留着旧状态,新帧序号从旧值继续递增,对端按新序号重组,旧状态和新序号对不上。

解决:穿透层要监听装置的重启事件,或者靠心跳连续失败来触发会话重置。收到心跳超时后,local_agent 主动断开所有会话,重新走一遍 0 号帧声明流程;remote_agent 清空 REASSEMBLY,等待新的 HELLO 帧。这套自愈逻辑在 demo 里可以简化成"连续 3 次心跳无响应就清空会话表",生产环境我会再加一个持久化文件,把会话 ID 和最后成功序号落盘,重启后从磁盘恢复,避免业务无感重连时序号断档。

5.5 现象:demo 进程崩溃后端口还占着,重启起不来

原因:socket 关闭时进入了 TIME_WAIT 状态,普通 bind 方式直接报 address already in use,进程看似退出了,端口没释放干净。

解决:local_agent 和 remote_agent 的监听 socket 都要设置 SO_REUSEADDR,UDP 侧还要加上 SO_REUSEADDR 保证多实例重启不冲突。另外进程需要做守护,崩溃后自动拉起,我在现场直接用 systemd 管理两个 agent,restart=always,而不是靠 nohup 裸跑。这算是一个小细节,但 demo 从开发机搬到现场环境时经常栽在这。

6. 验证穿透效果的一个土办法:用计数器替代抓包

6.1 发送方计数器:记录应用层发出与确认的会话

穿透链路验证不好用抓包,因为装置两侧已经是两台机器,网卡上加过滤也看不到应用层视角。我一般用计数器,穿透层每个会话维护三个值:发出的块总数、收到回执确认的块序号、重传的块次数。local_agent 跑一段时间后,把这三个值打到日志里,对比发出总数和最后确认序号,差值为零说明链路无丢块。

# 观察 local_agent 日志,确认发送与回执一致 grep "session 1003" /var/log/gw_agent.log | tail -20 # 期望看到 sent=1520 acked=1520 retrans=0

6.2 接收方计数器:记录重组块的缺口率

remote_agent 侧也有三组计数:收到的块总数、按序交付的块数、乱序到达的块数。若缺口率超过 0.1%,先查 DATA_BLOCK_SIZE 和 MTU;若乱序率超过 1%,就看看装置是否开了多队列并发,必要的时候在 remote 侧加大重组窗口。把这两组日志对上,穿透链路的质量判断就有依据了。

6.3 一套管用的验证流程与判断基线

我的验证顺序是:先用 500 字节小 TCP 包跑 1 万次,确认链路通;再放大到 1MB 连续流,跑 5 分钟,观察 sent 和 acked 是否收敛;然后切到 UDP,用 100 字节小报文和 1400 字节大报文各跑一轮,每轮 1 万条;中间有意让 POLL_INTERVAL 大一点,看业务是否可接受这个延迟。基线是 TCP 缺口率为 0、UDP 缺口率小于 0.1%,达不到就按第 5 章的排查路径去调块大小和缓存。

这个计数器方案是我从一次现场故障里学到的。那回业务说穿透链路不稳定,抓包抓了一晚上没结论,第二天用计数器一跑,发现重传数竟然高过发送数,罪魁是 MTU 分片后的重复 ACK。后来计数器就成了我每次穿透类方案交付前的固定动作。希望这个思路也能帮你在现场少熬一个通宵。

本文还有配套的精品资源,点击获取

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

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

立即咨询