简介:面向电力或工控网络中的正反向隔离装置场景,这份Demo演示了如何在隔离网关两侧实现TCP与UDP的双向穿透。资源在原有TCP穿透能力基础上新增了UDP透传逻辑,适合从事网络隔离、安全网关开发的工程师以及需要验证穿透行为的测试人员参考。压缩包共11个文件、约1.68MB,核心包括2个CPP源码文件(分别对应代理与测试端)、2个INI配置文件、可执行的代理程序与TCP/UDP测试程序,另附txt说明文档与pptx整体方案文档,便于快速理解模块职责和启动流程。内容预览中可见glzz、xb_gl_proxy、xb_gl_proxy_out等目录,源码、配置、可执行文件分层放置,结构清晰。已有1250人学习下载,对研究隔离装置数据通道或进行二次开发具有直接借鉴价值。
1. 正反向隔离装置的tcp/udp穿透_demo:先搞懂它解决什么问题
在做内外网数据交换时,最头痛的场景不是防火墙,而是物理隔离装置。两台设备之间没有逻辑通道,TCP握手包根本过不去,业务却要求内网客户端能访问外网服务。这个demo做的就是一件事:把TCP和UDP业务流量翻译成隔离装置认得的单向报文,请求走正向通道,响应走反向通道,模拟出一条应用层透明隧道。对要接隔离装置做集成的开发,它是一份可以直接参考的穿透实现;对研究tcpip网络安全的人,它清楚展示了隔离装置“拦得住连接、拦不住封装”的本质。整个项目不依赖特殊硬件,自带模拟层,普通电脑上就能跑通。
2. 架构设计:两条单工通道如何拼成一条全双工链路
2.1 为什么TCP直连永远过不去
隔离装置的核心逻辑是单向摆渡。正向隔离只允许内网侧的数据单向流向外网侧,反向隔离允许外网侧数据单向流向内网侧,两条通道物理上互不相通。TCP协议本身是双向的,握手要发SYN、收SYN+ACK,数据段要逐包确认,这些回程流量在隔离装置里没有返回路径,连接自然建立不起来。
既然TCP过不去,常见的做法是改业务协议,把请求和响应拆成两个独立报文分别走正向和反向装置。这个demo的思路更进一步,它保留业务的TCP/UDP语义,在两端各放一个代理,代理负责把双向TCP流量拆成正向和反向两股单向流,对业务进程来说,连接的仍然是本机端口,完全无感知。
2.2 整体链路拆解
数据流到内网代理后,代理按最大包长把TCP字节流切成数据块,每块加上自定义包头,塞进UDP报文,发给正向隔离装置的内网侧接口。正向隔离装置只认这台内网侧主机发给外网侧主机的UDP包,会原样转发过去。外网侧代理收到UDP包后,按包头里的序号还原TCP字节流,再与真正的外网目标服务建立TCP连接。这样一来,内网到外网方向就走通了。
响应的路径完全对称,只是换了装置。外网目标返回的TCP数据在外网侧代理被切成块、封装成UDP包,通过反向隔离装置发给内网侧代理,内网代理把包还原成TCP字节流,写回内网客户端的连接。两个方向的单向通道在应用层拼接,对业务呈现的效果和一个普通TCP代理没有区别。
2.3 目录结构与模块职责
isolation-tcp-udp-penetration-demo/ ├── config.yaml # 全局配置:装置地址、监听端口、目标地址 ├── run.py # 一键启动入口,负责拉起内/外网代理 ├── core/ │ ├── packet.py # 隔离报文的封装与解析 │ ├── forward_channel.py # 正向通道:TCP流切片封装并发往对端 │ ├── reverse_channel.py # 反向通道:内网侧轮询拉取数据 │ └── udp_tunnel.py # UDP业务的透传处理 ├── gateway/ │ ├── inner_side.py # 内网侧代理主程序 │ └── outer_side.py # 外网侧代理主程序 ├── sim/ │ └── simulate_gap.py # 模拟隔离装置:只管单向转发,丢弃反向包 └── test/ ├── tcp_echo_server.py # 外网侧回显服务 ├── tcp_client.py # 内网侧客户端 ├── udp_sender.py └── udp_receiver.pycore目录是穿透的核心逻辑,gateway目录是两端入口,sim目录提供隔离装置模拟器,test目录是验证用的对端程序。这个分层的好处是:换真实隔离装置时只改sim层和配置,core和gateway不需要动。
2.4 隔离报文的包头设计
自定义包头是整个穿透协议的关键。包头的设计遵循两个原则:足够短以避免浪费UDP有效载荷,字段足够全以保证对端能还原数据。下面是core/packet.py里实际使用的字段布局:
| 字段 | 长度 | 说明 |
|---|---|---|
| 魔数 | 2字节 | 固定0xA5A5,用来过滤隔离装置串入的脏包 |
| 通道号 | 1字节 | 1表示正向,2表示反向,隔离两条链路 |
| 序号 | 2字节 | 大端序,接收端按它排序还原TCP流 |
| 分段索引 | 2字节 | 一个TCP数据块被切成多个UDP包时的序号 |
| 分段总数 | 2字节 | 收到全部段后开始组装 |
| 数据长度 | 2字节 | payload实际字节数 |
| CRC32 | 4字节 | 对包头加payload计算,防止中间设备改包 |
总包头加CRC固定为15字节,单个UDP报文最大长度控制在1415字节以内,比常见UDP的1472字节上限小,经过隔离装置时基本不会被IP分片。
3. 核心模块实现:从包封装到双向穿透的代码走读
3.1 包的封装与解析:core/packet.py
封装函数负责把一段业务数据切成多个隔离报文,每个报文带独立的包头和CRC。注意分段索引从1开始,因为0留给后续扩展做握手控制包。
import struct import zlib MAGIC = 0xA5A5 CHANNEL_FORWARD = 0x01 CHANNEL_REVERSE = 0x02 MAX_PAYLOAD = 1400 def build_packet(channel, seq, data): packets = [] total = (len(data) + MAX_PAYLOAD - 1) // MAX_PAYLOAD for idx in range(total): offset = idx * MAX_PAYLOAD chunk = data[offset:offset + MAX_PAYLOAD] header = struct.pack('!HBHHHH', MAGIC, channel, seq, idx + 1, total, len(chunk)) crc = zlib.crc32(header + chunk) & 0xFFFFFFFF packets.append(header + struct.pack('!I', crc) + chunk) return packets这里的!HBHHHH是按大端序打包6个字段,对应魔数、通道号、序号、分段索引、分段总数、数据长度。之所以强制大端序,是因为隔离装置两端可能跑在不同架构的机器上,小端序的序号在跨平台传输后会被倒过来读,排序直接乱掉。CRC32的计算范围覆盖包头和payload,接收端只要发现一个包校验不过就丢弃,防止脏包污染还原后的字节流。
解析函数是封装的逆过程,先校验长度、再校验魔数、最终校验CRC,全部通过才返回解析结果。任何一步失败都返回None,由上层调用方记录日志并丢弃。
def parse_packet(buf): if len(buf) < 15: return None magic, channel, seq, seg_idx, seg_total, data_len = struct.unpack('!HBHHHH', buf[:11]) if magic != MAGIC: return None crc_recv = struct.unpack('!I', buf[11:15])[0] crc_calc = zlib.crc32(buf[:11] + buf[15:15 + data_len]) & 0xFFFFFFFF if crc_recv != crc_calc: return None return { 'channel': channel, 'seq': seq, 'seg_index': seg_idx, 'seg_total': seg_total, 'data': buf[15:15 + data_len] }处理脏包时有个细节:如果魔数不对就直接丢弃,不再做CRC计算,因为CRC本身耗CPU,在高速转发场景下能省一点是一点。而验证CRC会覆盖包头的通道号,通道号不对的包会被拒绝,这保证正向通道的包不会串到反向链路里去。
3.2 正向通道:TCP数据如何打进UDP包
正向通道跑在内网侧代理上。它的任务很简单:监听本机TCP端口,接到客户端连接后,把连接上的字节流切成隔离报文发到隔离装置的内网侧接口。接收端在外网侧反向操作,把隔离报文还原成TCP字节流。
def forward_tcp_to_gap(local_listen_port, gap_inner_addr): server = socket.socket(socket.AF_INET, socket.SOCK_STREAM) server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) server.bind(('0.0.0.0', local_listen_port)) server.listen(16) while True: conn, _ = server.accept() threading.Thread(target=_handle_forward_conn, args=(conn, gap_inner_addr), daemon=True).start() def _handle_forward_conn(conn, gap_inner_addr): udp = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) seq = 0 while True: data = conn.recv(4096) if not data: break for pkt in build_packet(CHANNEL_FORWARD, seq, data): udp.sendto(pkt, gap_inner_addr) seq = (seq + 1) & 0xFFFF udp.close() conn.close()每次读取最多4096字节的TCP数据,能凑满3个1400字节的分段包。这里把读取和发送放在一个线程里顺序执行,避免多线程并发写入同一个UDP socket时出现包顺序乱掉的情况。序号在0到65535之间循环,超过就回绕,接收端通过CRC和分段总数可以识别乱序和丢包。
外网侧代理收到这些UDP包后,需要把同一序号下的所有分段拼成一个完整TCP数据块,再按序写入与目标服务建立的TCP连接。这个组装逻辑的核心是拿seq做分组键,seg_index做排序键,攒够seg_total个分段后合并。
def _handle_gap_packet(pkt, tcp_conn, buffer): key = (pkt['channel'], pkt['seq']) if key not in buffer: buffer[key] = {'segments': {}, 'total': pkt['seg_total']} buffer[key]['segments'][pkt['seg_index']] = pkt['data'] if len(buffer[key]['segments']) == pkt['seg_total']: ordered = [buffer[key]['segments'][i] for i in range(1, pkt['seg_total'] + 1)] tcp_conn.sendall(b''.join(ordered)) del buffer[key]buffer是每个TCP连接独立维护的字典,键是通道号和序号的组合。因为UDP包可能先发后到,这里通过等待分段凑齐再写入TCP,天然解决了乱序问题。如果某个序号的分段长时间凑不齐,说明网络丢包了,上层会有一个定时器清理超时的buffer条目,同时断开对应的TCP连接,让业务层走重连逻辑。
3.3 反向通道:内网侧主动轮询拉取数据
反向隔离的复杂性在于方向。外网侧的响应数据不能主动推到内网,只能由内网侧定时发拉取请求,隔离装置把排队的数据包送回来。这个demo采用固定间隔轮询,内网代理每50毫秒向反向装置的内网侧接口发送一个UDP拉取包,外网侧代理收到拉取包后,从自己的待发队列里弹出一个包发回。
def reverse_poll(poll_interval_ms, gap_inner_addr): udp = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) while True: udp.sendto(b'POLL', gap_inner_addr) udp.settimeout(poll_interval_ms / 1000.0 + 0.01) try: data, _ = udp.recvfrom(2048) except socket.timeout: time.sleep(poll_interval_ms / 1000.0) continue pkt = parse_packet(data) if pkt and pkt['channel'] == CHANNEL_REVERSE: dispatch_reverse_packet(pkt)轮询间隔是一个需要权衡的参数。50毫秒在局域网环境能拿到约20RPS的单请求吞吐,对验证demo完全够用。如果隔离装置对UDP报文的流量有限速策略,比如每秒只允许20个报文,这个间隔就要调到200毫秒以上,否则拉取响应会被装置静默丢弃,客户端表现为长时间收不到响应。外网侧代理只需要把每次TCP写入产生的隔离报文追加到队列尾部,拉取包来了就发队列头部的包,这样响应的顺序和TCP原始数据的顺序保持一致。
3.4 UDP穿透:无连接业务的直接透传
UDP本身没有连接状态,不需要像TCP那样切片和流控。内网侧UDP代理收到一个完整UDP数据报后,把它封装成一个隔离报文发给正向装置,对端解析后直接发给目标UDP服务。反向路径同理,但每个数据报要被当做一个独立消息处理,不能拆成多段。
def udp_passthrough(local_udp_port, gap_inner_addr): ingress = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) ingress.bind(('0.0.0.0', local_udp_port)) while True: data, client_addr = ingress.recvfrom(2048) seq = gen_udp_seq(client_addr) for pkt in build_packet(CHANNEL_FORWARD, seq, data): ingress.sendto(pkt, gap_inner_addr)UDP穿透需要维护一张地址映射表,记录每个内网客户端对应的外网目标地址。因为一个客户端的UDP数据报可能来自不同源端口,但目标服务和端口是固定的。这里seq由客户端地址哈希生成,便于对端代理按客户端身份反向识别响应该发给谁。UDP业务不要求可靠传输,所以丢包直接丢掉,超时清理映射表条目的任务周期执行。
4. 运行起来:隔离穿透demo的完整启动流程
4.1 准备环境与网络拓扑
demo最少需要三台机器或虚拟机,模拟三个网络角色。内网侧运行内网代理和客户端,外网侧运行外网代理和目标服务,中间的模拟机运行sim/simulate_gap.py,扮演正反向隔离装置。如果只有一台电脑,也可以用三个容器或用loopback接口加不同端口号代替,但模拟机必须单独部署,否则隔离装置的单向特性无法体现。
部署顺序按这个拓扑规划:
内网客户端 --TCP--> 内网代理 --UDP(正向)--> 模拟隔离机 --UDP--> 外网代理 --TCP--> 目标服务 内网客户端 <--TCP-- 内网代理 <--UDP(反向)-- 模拟隔离机 <--UDP-- 外网代理 <--TCP-- 目标服务模拟隔离机的行为最关键,它只转发内网侧接口发来的UDP包到外网侧,反向同理,凡是方向不对的包一律丢弃。真实隔离装置还带物理单向特性,模拟机用iptables也能做到类似效果,但demo里用纯Python转发就够了。
4.2 修改配置文件
config.yaml是唯一需要改的配置文件,所有网络地址都集中在这里。
forward: gap_inner_addr: "192.168.1.2:7101" gap_outer_addr: "192.168.2.2:7102" inner_listen_port: 9000 outer_target_addr: "192.168.2.10" outer_target_port: 8080 reverse: gap_inner_addr: "192.168.1.3:7103" gap_outer_addr: "192.168.2.3:7104" poll_interval_ms: 50 udp: listen_port: 9001 target_addr: "192.168.2.10" target_port: 9002gap_inner_addr和gap_outer_addr分别是隔离装置内网侧和外网侧的接口地址,必须和模拟机绑定网卡时用的一致。内网客户端连接的是inner_listen_port,9000端口是内网侧代理的TCP监听端口;outer_target_addr是外网侧真正要访问的服务地址,可以指向外网代理所在机器的回环地址。
4.3 启动模拟机与两端代理
先在外网侧机器上启动目标服务和外网代理,因为内网客户端的数据随时可能到达,外网代理需要在收到设备数据的瞬间能够建立到目标的TCP连接。
# 外网侧终端1:启动回显服务 python test/tcp_echo_server.py --port 8080 # 外网侧终端2:启动外网侧代理 python gateway/outer_side.py --config config.yaml # 模拟隔离机终端:启动装置模拟 python sim/simulate_gap.py --config config.yaml # 内网侧终端:启动内网侧代理 python gateway/inner_side.py --config config.yaml启动顺序有讲究。隔离模拟机必须早于两端代理启动,否则代理第一次sendto时没有对端接收,UDP包会直接丢在网络上,而且不会报错。内网代理可以最晚启动,因为它只是监听端口,不会主动发包,直到有客户端连上来。这个顺序不是死规定,但按这个顺序启动时配置错误暴露得最快。
4.4 端到端验证
内网客户端连接9000端口发一段字符串,回显服务会把相同内容返回,客户端能在屏幕上看到完整往返。这是最简单的连通性验证。
python test/tcp_client.py --server 127.0.0.1 --port 9000 --payload "hello gap"如果看到返回的hello gap,说明正向通道和反向通道都工作正常。再抓包验证协议头是否正确,在外网侧代理机器上执行tcpdump,抓UDP 7102端口的报文,能看到头部前两个字节是a5 a5,这就是自定义包头里的魔数。如果魔数不对,说明中间环节有人改包,排查方向可以直接锁定在隔离装置的数据整形策略上。
UDP透传验证同样简单,内网侧跑udp_sender发消息,外网侧跑udp_receiver监听9002端口,能收到即通过。
5. 避坑指南:隔离穿透最常见的五个问题与排查手段
5.1 大包被隔离装置静默丢弃
现象:客户端发小数据一切正常,发超过2KB的报文时服务端完全收不到,抓包能看到UDP包发出去了,但目标机器上没有出现。
原因:很多隔离装置的实际处理逻辑不是按UDP长度上限工作,而是按装置内部缓冲区大小限制,常见值是1400字节左右。超过这个值的包会被装置内部协议栈直接判定为非法报文,不进入转发队列。TCP数据一次recv可能读到几千字节,build_packet虽然按1400切段了,但个别段恰好超过装置上限。
解决:把MAX_PAYLOAD从1400调到1200,留出余量。这个坑的隐蔽之处在于,小数据时不会触发,只有跑大文件或高带宽业务时流量瞬间中断,我在这上面翻过车,最终是逐段缩小包长二分定位出来的。
5.2 反向轮询频率太高触发限流
现象:运行几分钟后客户端偶发超时,恢复后又能正常工作,且超时时间点没有规律。看内网代理日志,发现recvfrom频繁超时。
原因:反向隔离装置对UDP报文的处理速率有限制,有些型号对单来源IP限到每秒30个包。默认50毫秒轮询就是每秒20个拉取包,表面没超,但加上业务反向报文后整体超过阈值,装置开始随机丢弃拉取响应,丢几次就触发客户端超时。
解决:把poll_interval_ms调大到200,优先保证业务响应包的顺畅;同时给急停场景加一个手动触发按钮,需要快速拉取时临时降到100毫秒以内,用完立即恢复。限速参数每个型号不一样,上线前务必做一次满速测试确认实际阈值。
5.3 跨平台部署时魔数校验失效
现象:两端代理分别部署在ARM和x86机器上,小数组件功能正常,但CRC校验频繁失败,日志里全是parse_packet返回None的记录。
原因:Python的struct.pack在不同架构上默认对齐规则不同,如果某处代码用了非!前缀的格式串,或者直接用了int.from_bytes没有指字字节序,读出来的魔数就会被字节反转,0xA5A5变成0xA5A5不变,但多字节序号会被倒序。更常见的坑是CRC计算时header里混入了本地字节序打包的字段。
解决:统一所有pack和unpack的格式串都带!前缀,CRC计算前把header用一个bytes对象缓存,避免多次打包产生不一致。我在一次跨平台联调时排查了一整天,最后发现是zlib.crc32传入的bytes在拼接顺序上出了问题。
5.4 隧道空闲后被装置回收连接
现象:长时间没有业务流量,再发起新请求时第一次必然失败,第二次重试就成功。
原因:隔离装置对空闲会话有老化机制,两分钟内没有转发流量的UDP流会被删掉。内网侧代理的UDP socket一直没关,但装置侧的路由状态已经没了,第一个包会触发装置重新建表,而这个建表动作来不及影响当前包,于是第一包被丢弃。
解决:加一个应用层心跳,每30秒从内网代理发一个空数据包,心跳包只带包头没有payload,解析端识别到data_len为0就丢弃不写TCP。这样一来装置的空闲会话永远不会超时,也避免了为了保活而频繁重建连接。
5.5 模拟环境正常、真实装置环境下丢包率极高
现象:本地模拟器跑通后,接入真实隔离装置调试,同样的代码丢包率超过30%,且集中在固定时间窗口。
原因:模拟器的转发是纯内存拷贝,真实装置带磁盘缓存和硬件加密,处理速率受限。大量小UDP包并发到达时,装置来不及处理就丢包。代码层面看,是内网代理sendto后没有做任何背压控制,一个循环里瞬间把几十个包扔给装置。
解决:在内网代理的发送线程里加入令牌桶限速,每发送一个包后sleep一个极小的时间片,让装置来得及处理。这个限速值从10微秒起步逐步调大,直到丢包率归零。接入生产环境前,这种背压机制是必须加上的,否则应用一压测就雪崩。
6. 二次开发:把穿透demo改造成可用的生产通道
6.1 协议升级:从单连接到多路复用
现在的包头结构里,通道号只区分正向和反向,多个TCP连接的数据包会共享同一个序号空间,接收端会拼错。改造思路是增加一个连接ID字段,占用2字节,放在通道号之后。内网代理为每个客户端TCP连接分配一个唯一ID,连接关闭后回收复用。对端代理用(conn_id, seq)作为组装键,这样一个UDP通道就能承载多路TCP业务,吞吐量能跟着提升一个量级。
6.2 可靠性升级:补重传与ACK
生产环境的隔离链路丢包率普遍在万分之一到百分之一之间,TCP流被封装成UDP后丢包就全丢了,上层TCP重传机制根本感知不到。最直接的做法是把包头里的分段索引扩展成滑动窗口,发送端缓存已经发送但未确认的包,接收端每攒齐一个序号就回一个ACK,发送端在超时时间后重发未确认包。考虑到隔离装置延迟通常超过20毫秒,超时时间建议设成200毫秒,太短会导致重传风暴。
6.3 上线前的验证方法
每次改造完,我都会强制做一轮故障演练:先拔掉模拟机的网线再插回,看两端代理能否在恢复后自动重连;再把目标服务杀掉重启,确认TCP连接重建不影响后续包序号;最后用超大文件压测跑30分钟,观察buffer字典有没有堆积。这些场景在demo里都能模拟,不需要真装置就能提前暴露大多数问题。
做一个能上线的穿透通道,难点从来不在协议设计,而在边界场景的覆盖。从那以后我每次改完隔离穿透代码,都会强制走一遍断线重连、对端重启、持续压测这三板斧,宁可多花一小时,也不让现场的故障灯亮起来才后悔。希望帮到你。
本文还有配套的精品资源,点击获取