正反向隔离装置下TCP/UDP穿透Demo:握手、帧封装与转发实战

发布时间:2026/10/29 2:29:18
正反向隔离装置下TCP/UDP穿透Demo:握手、帧封装与转发实战

简介:这份资源围绕正反向隔离装置的TCP/UDP穿透展开,面向从事网络通信、安全隔离与协议调试的开发者及运维人员,帮助理解隔离环境下如何实现TCP与UDP的双向穿透。压缩包共11个文件,约1.68MB,包含cpp源码、ini配置、可执行测试程序及说明文档,并附有隔离服务整体方案演示文稿,便于从代码、配置到方案层面完整对照。资源在原有TCP穿透基础上补充了UDP穿透实现,配套tcp与udp测试程序及代理相关模块,可帮助读者梳理穿透链路的搭建思路、验证方法与常见问题排查。已有1253人学习下载,适合需要快速搭建实验环境、对照源码理解穿透机制并复用测试工具的中高级网络开发者参考。

1. 正反向隔离装置下的 TCP/UDP 穿透:一个能跑起来的 Demo 到底解决了什么

做电力、工控或者涉密网络集成的同行,大概率都遇到过这种局面:内网有一台设备要往外送数据,中间横着一台正反向隔离装置,TCP 连接建不起来,UDP 包发出去石沉大海。业务方催得紧,网络组说“隔离装置就是这样”,最后压力全落在写代码的人身上。这个 Demo 就是冲着这个场景来的——它把正反向隔离装置环境下 TCP 与 UDP 穿透的完整链路拆成可运行的最小工程,让你能在本地先把“包怎么出去、怎么回来”跑通,再往真实环境里搬。适合两类人:一是刚接手隔离装置对接、需要一份能照着调试的参考实现的新手;二是想看清穿透握手、会话保持、双向转发这几个关键环节怎么落地、方便二次改造的老手。tcpip 协议栈和网络安全的基础概念在这里不是背景板,而是每一行代码都要对上的约束条件。

2. 工程结构与技术选型:穿透 Demo 的目录怎么摆、依赖怎么选

2.1 整体分层:代理端、穿透端、业务端三段式

这个 Demo 的目录结构不复杂,但分层意图很明确,核心是把“谁发起连接、谁维持会话、谁转发数据”三件事拆开。常见做法是下面这种摆法:

tcp_udp_pierce_demo/ ├── agent/ # 部署在隔离装置外侧的代理端 │ ├── tcp_proxy.py # TCP 监听与转发 │ ├── udp_relay.py # UDP 收包与回包 │ └── session_table.py # 会话映射表 ├── pierce/ # 穿透核心逻辑 │ ├── handshake.py # 握手与身份校验 │ ├── frame.py # 自定义帧封装/解封装 │ └── heartbeat.py # 心跳保活 ├── biz/ # 模拟业务端,验证用 │ ├── tcp_client.py │ └── udp_sender.py ├── conf/ │ └── pierce.yaml # 端口、超时、重试参数 └── run_demo.sh # 一键拉起三段

agent负责对外暴露端口,pierce是真正跟隔离装置打交道的部分,biz只是拿来验证链路的假业务。这样分层的好处是:你换真实业务时只动biz,穿透逻辑一行不用改;隔离装置换了型号,重点排查pierce/frame.py的帧格式即可。我一般会把conf单独拎出来,端口和超时这类参数在调试期改得最频繁,硬编码在代码里是血泪教训。

2.2 技术栈选择:为什么用 Python 而不是 C

Demo 层面选 Python 是刻意的。穿透的核心难点不在性能,而在“帧格式对不对、握手时序对不对、超时重试合不合理”,这些用 Python 改起来最快,socket、selectors、struct三个标准库就够,不引入第三方依赖,拿到源码python3 run_demo.sh就能起。真实生产环境如果吞吐要求高,常见做法是把pierce/frame.py的封解包逻辑用 C 重写,Python 端只保留调度,接口保持不变。

关键依赖就三个标准库,版本要求 Python 3.8 以上:

模块用途说明
socketTCP/UDP 套接字设置 SO_REUSEADDR 避免端口占用
selectors多路复用单线程管多个会话,避免线程爆炸
struct二进制帧封解包定长头 + 变长体,网络字节序

提示:selectors在 Linux 下默认走 epoll,Windows 下走 select,Demo 跨平台能跑,但高并发场景只在 Linux 下有意义。

2.3 配置先行:pierce.yaml 里哪些参数决定成败

配置文件是整个 Demo 的“黑匣子”入口,调试时先看这里再看代码。典型内容如下:

agent: tcp_listen: 0.0.0.0:9001 # 对外 TCP 监听地址 udp_listen: 0.0.0.0:9002 # 对外 UDP 监听地址 max_sessions: 256 # 最大并发会话数 pierce: peer_host: 10.0.0.2 # 隔离装置对侧地址 peer_port: 9100 handshake_timeout: 3 # 握手超时,秒 heartbeat_interval: 10 # 心跳间隔,秒 retry_times: 3 # 失败重试次数 frame: magic: 0x5A5A # 帧头魔数,两端必须一致 max_payload: 1400 # 单帧最大载荷,避开 MTU 分片

max_payload设 1400 是有讲究的:隔离装置往往对分片包处理不友好,UDP 尤其容易在分片重组时丢包,把单帧压在 MTU 以内能少踩很多坑。handshake_timeout别设太大,隔离装置侧拒绝连接时通常很快返回,设 3 秒足够,设 30 秒只会让你调试时干等。

3. 核心链路拆解:握手、帧封装、TCP/UDP 转发逐段讲

3.1 握手阶段:先证明“我是谁”再谈穿透

隔离装置环境下的穿透,第一步不是发数据,而是让对侧确认这个连接是合法的。Demo 的握手逻辑在pierce/handshake.py:

import struct, socket, time MAGIC = 0x5A5A HANDSHAKE_REQ = 0x01 HANDSHAKE_ACK = 0x02 def build_handshake(token: bytes) -> bytes: # 帧头: magic(2) + type(1) + len(2),网络字节序 header = struct.pack("!HBH", MAGIC, HANDSHAKE_REQ, len(token)) return header + token def do_handshake(sock: socket.socket, token: bytes, timeout: float = 3.0): sock.settimeout(timeout) sock.sendall(build_handshake(token)) # 读回 5 字节定长头 head = recv_exact(sock, 5) magic, ftype, length = struct.unpack("!HBH", head) if magic != MAGIC or ftype != HANDSHAKE_ACK: raise ConnectionError("handshake rejected by peer") body = recv_exact(sock, length) return body # 对侧回传的会话标识 def recv_exact(sock, n): buf = b"" while len(buf) < n: chunk = sock.recv(n - len(buf)) if not chunk: raise ConnectionError("peer closed during handshake") buf += chunk return buf

这段代码有三个点值得说。第一,帧头用!HBH定长 5 字节,!表示网络字节序,两端必须一致,否则解出来的magic永远是错的,这是最常见的翻车点。第二,recv_exact必须循环读,TCP 是流式协议,一次recv不保证拿满 5 字节,直接sock.recv(5)在跨网段时经常只收到 2、3 字节。第三,握手返回的body是会话标识,后续所有数据帧都要带上它,session_table.py靠这个标识做映射。参数上token是预共享的校验串,真实环境里换成设备序列号或证书摘要都行,Demo 里用固定字符串方便验证。

3.2 帧封装:定长头 + 变长体,UDP 和 TCP 共用一套

穿透链路里 TCP 和 UDP 最终都走同一种帧格式,这样隔离装置侧只需要认一种协议。pierce/frame.py的核心:

import struct HEADER_FMT = "!HBHI" # magic, type, length, session_id HEADER_LEN = struct.calcsize(HEADER_FMT) # 9 字节 TYPE_DATA = 0x10 TYPE_HEARTBEAT = 0x11 TYPE_CLOSE = 0x12 def pack_frame(session_id: int, ftype: int, payload: bytes) -> bytes: if len(payload) > 1400: raise ValueError("payload exceeds max_payload") header = struct.pack(HEADER_FMT, 0x5A5A, ftype, len(payload), session_id) return header + payload def unpack_frame(buf: bytes): if len(buf) < HEADER_LEN: return None, buf # 数据不够,等下一批 magic, ftype, length, sid = struct.unpack(HEADER_FMT, buf[:HEADER_LEN]) if magic != 0x5A5A: raise ValueError("bad magic, frame desync") total = HEADER_LEN + length if len(buf) < total: return None, buf # 半包,继续等 return (sid, ftype, buf[HEADER_LEN:total]), buf[total:]

unpack_frame返回(frame, remaining)的设计是为了处理粘包和半包——TCP 流里一次recv可能拿到一个半帧,也可能拿到两个完整帧,调用方循环调用直到返回None为止。session_id放在帧头里而不是靠连接区分,是因为 UDP 无连接,只能靠帧内标识找会话。max_payload的校验放在pack_frame里,超长直接抛异常,比让隔离装置静默丢包好排查得多。

3.3 TCP 转发:会话表 + 多路复用

TCP 侧的核心是agent/tcp_proxy.py,用selectors单线程管所有连接:

import selectors, socket from pierce.frame import pack_frame, unpack_frame, TYPE_DATA from agent.session_table import SessionTable sel = selectors.DefaultSelector() sessions = SessionTable() def on_accept(listen_sock): conn, addr = listen_sock.accept() conn.setblocking(False) sid = sessions.alloc(conn, addr) # 分配会话 ID sel.register(conn, selectors.EVENT_READ, ("client", sid)) def on_client_read(conn, sid): data = conn.recv(1400) if not data: sessions.close(sid) sel.unregister(conn) return frame = pack_frame(sid, TYPE_DATA, data) upstream = sessions.upstream_of(sid) # 对侧穿透连接 upstream.sendall(frame) # 简化写法,生产需处理写缓冲 def event_loop(): while True: for key, mask in sel.select(timeout=1.0): role, sid = key.data if role == "listen": on_accept(key.fileobj) elif role == "client": on_client_read(key.fileobj, sid)

SessionTable维护sid -> (client_conn, upstream_conn)的映射,alloc时分配自增 ID,close时两边一起关。这里有个简化:upstream.sendall在非阻塞套接字上可能只写出去一部分,生产代码要挂写事件把剩余数据补发,Demo 为了可读性先这么写,二次开发时这是第一个要补的点。selectors的timeout=1.0让循环每秒醒一次,方便做心跳和超时清理,设 0 会空转吃 CPU。

3.4 UDP 转发:无连接场景下的会话保持

UDP 没有连接概念,agent/udp_relay.py靠“源地址 + 端口”做伪会话:

import socket from pierce.frame import pack_frame, unpack_frame, TYPE_DATA udp_sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) udp_sock.bind(("0.0.0.0", 9002)) pseudo_sessions = {} # (ip, port) -> sid def relay_udp(): while True: data, addr = udp_sock.recvfrom(1400) sid = pseudo_sessions.get(addr) if sid is None: sid = alloc_sid() pseudo_sessions[addr] = sid frame = pack_frame(sid, TYPE_DATA, data) send_to_peer(frame) # 走穿透通道发往对侧 # 对侧回包后按 sid 反查 addr 再 sendto 回去

关键在pseudo_sessions的过期清理:UDP 会话没有关闭信号,只能靠“多久没收到包”判断失效,常见做法是 60 秒无流量就删映射,否则表会无限涨。recvfrom(1400)的缓冲区大小和max_payload对齐,避免截断。回包路径上按sid反查addr再sendto,这一步如果映射被提前清掉,回包就丢了,所以清理阈值要比业务心跳周期长。

4. 跑起来与联调:从本地回环到模拟隔离环境的验证步骤

4.1 本地回环先跑通,再谈隔离装置

拿到源码第一步别急着连真实设备,先在单机把三段拉起来。run_demo.sh做的事就是依次启动agent、pierce对侧模拟、biz:

#!/usr/bin/env bash set -e python3 -m agent.tcp_proxy --conf conf/pierce.yaml & python3 -m pierce.handshake --listen 9100 --conf conf/pierce.yaml & sleep 1 python3 -m biz.tcp_client --target 127.0.0.1:9001 --msg "hello-pierce" python3 -m biz.udp_sender --target 127.0.0.1:9002 --msg "udp-probe" wait

set -e保证任一段起不来就整体退出,不会出现“看起来在跑其实对侧没起”的假象。sleep 1是给监听端留绑定时间,真实环境里换成轮询端口是否可连更稳。跑通后biz端应该打印出回显内容,说明握手、帧封装、转发、回包整条链路是通的。

4.2 模拟隔离装置:用中间件制造“只允许单向发起”的约束

真实隔离装置的核心约束是“连接只能由某一侧发起”,本地验证时可以用一个简单的中间层模拟:只允许pierce侧主动连peer_host,反向连接一律拒绝。做法是在pierce/handshake.py的监听端加一个来源白名单,非白名单地址直接close。这样你能验证“发起方向错了会怎样”——现象是对侧一直收不到握手 ACK,handshake_timeout到点抛ConnectionError。把发起方向调对,链路立刻通,这个对比能帮你快速确认现场部署时哪一侧该配成主动方。

4.3 抓包看什么:三个必查点

联调阶段tcpdump是后悔药。必查三处:一是握手帧的magic是否为5a5a,字节序错了这里一眼能看出来;二是max_payload是否被突破,超过 1400 的帧在隔离装置侧容易被丢;三是 UDP 回包的源端口是否和请求一致,NAT 环境下端口被改写会导致pseudo_sessions反查失败。命令示例:

tcpdump -i any -nn -X 'port 9001 or port 9002 or port 9100' -c 50

-X打十六进制内容,-c 50抓 50 个包就停,避免刷屏。看的时候重点对帧头 9 字节,5a5a后面跟的 type 和 length 是否和代码里pack_frame的入参对得上。

5. 避坑与排查:穿透 Demo 落地时最容易翻车的五件事

5.1 现象:握手一直超时,日志里没有任何 ACK

原因多半是发起方向配反了。隔离装置只允许固定方向建连,pierce.yaml里peer_host配成了对侧主动连的地址,本端却在监听等连接。解决:确认现场哪一侧是“允许发起方”,把do_handshake放在那一侧,另一侧只做accept。改完先用 4.2 的模拟中间件验证方向,再上真实设备。

5.2 现象:TCP 小数据能通,传大文件卡死或截断

原因是upstream.sendall在非阻塞套接字上只写出去一部分,剩余数据没补发。Demo 里为了可读性简化了写缓冲,真实数据量一大就暴露。解决:给 upstream 注册EVENT_WRITE,维护每会话的待发队列,写事件触发时继续 flush,队列空了再注销写事件。这是二次开发第一个要补的坑。

5.3 现象:UDP 偶发丢回包,重发也没用

原因是pseudo_sessions的过期清理阈值比业务心跳短,映射被提前删掉,回包按sid反查不到addr。解决:清理阈值设为业务心跳周期的 2 到 3 倍,并在删除前打一条日志记录sid和最后活跃时间,下次丢包直接对日志。另外recvfrom缓冲区要和max_payload对齐,缓冲区小了会静默截断。

5.4 现象:帧解析报bad magic, frame desync

原因是两端magic或字节序不一致,或者中间有设备改写了载荷。先核对frame.py和pierce.yaml里的magic是否都是0x5A5A,再确认struct格式串都带!。如果两端代码一致还报错,抓包看实际字节,多半是隔离装置对特定字节做了转义,这时候要调整帧头魔数避开被转义的取值。

5.5 现象:并发一上来 CPU 飙高、连接被拒

原因是selectors.select(timeout=0)空转,或者max_sessions设太小导致alloc直接抛异常。解决:timeout保持 1.0 秒级别,别设 0;max_sessions按实际并发留 20% 余量;SessionTable.alloc在超限时返回明确错误码而不是静默失败,方便上层做限流。

6. 二次开发:把 Demo 改成能上生产的穿透组件

Demo 到生产之间隔着几件事,按优先级排:第一是写缓冲,5.2 说的待发队列必须补,否则大流量必挂;第二是心跳与断线重连,pierce/heartbeat.py目前只发不收,生产要加 ACK 超时判定,连续 3 次没 ACK 就重建会话;第三是会话表的并发安全,Demo 单线程没问题,一旦引入多线程处理业务就要给SessionTable加锁,或者干脆保持单线程事件循环、把耗时操作丢到线程池。

改造时建议保留一个“最小验证回路”:每次改完frame.py或handshake.py,先跑run_demo.sh的本地回环,再跑 4.2 的模拟中间件,两步都过再上真实环境。我一般会在conf里加一个debug: true开关,打开后每个帧的sid、type、length都打日志,关掉后只打错误。这个习惯帮我省过好几次“改了帧格式忘了同步对侧”的返工。

验证方法上,除了功能通,还要看两个指标:单会话吞吐和会话建立耗时。吞吐用biz/tcp_client.py循环发 1MB 数据计时,建立耗时看握手从sendall到收到 ACK 的毫秒数。这两个数在改造前后各测一次,能直观看出写缓冲和心跳改动有没有引入回退。

改造项改动文件验证方式
写缓冲队列agent/tcp_proxy.py传 10MB 文件不截断
心跳 ACK 判定pierce/heartbeat.py拔网线 30 秒内触发重连
会话表加锁agent/session_table.py并发 200 连接无映射错乱
帧日志开关conf/pierce.yamldebug 开关切换日志量

最后说个教训:早期我图省事,把max_payload直接设成 65535,觉得“一次多传点省事”,结果 UDP 在隔离装置侧分片重组失败,丢包率三成,查了两天才定位到是 MTU 的问题。从那以后我每次配穿透参数,max_payload都强制压在 1400 以内,并且抓包确认没有分片再往下走。希望这个 Demo 和这些踩坑记录能帮到你,少走点我走过的弯路。

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