简介:面向网络隔离环境下的TCP/UDP穿透验证需求,这份demo包围绕正反向隔离装置的代理转发与收发测试展开,适合从事网络安全、协议分析及隔离网关调试的开发者用作实验参考。压缩包共11个文件、约1.68MB,包含ini配置样例、cpp源码、pptx整体方案演示、txt说明以及代理端和TCP/UDP测试端程序,结构上便于分别查看配置、源码与运行验证。相比仅覆盖TCP的版本,其补充了UDP穿透路径,可用于对比两种协议在代理转发、数据收发和异常排查中的差异。已有1253人学习下载。读者可据此搭建小规模验证环境,理解正反向隔离场景下的转发链路、测试方法与配置要点,并借助方案材料梳理实现思路,对隔离装置代理转发机制形成直观认识,适合作为协议穿透实验和排错分析的参考素材。
1. 直连超时、ping 能通:这份「隔离装置 TCP/UDP 穿透」demo 到底给了什么
内侧一台采集程序要连外侧一台服务,ping能通,connect一直是超时——十有八九是中间那台正反向隔离装置把端到端连接掐断了。这个 tcp/udp 穿透 demo 要解决的就是这件事:业务程序一行不改,让 TCP 会话和 UDP 报文仍然能跨过隔离装置走完一轮完整的收发。它面向的是做 tcpip 网络安全落地的人:手里有隔离装置、有存量业务、又不想为了合规去改应用层协议的那批工程师。demo 把最容易翻车的几块单独拆了出来——握手怎么在两侧各建一次、私有报文头怎么设计、UDP 会话表怎么老化、单向设备上反向请求怎么配对。读完之后你应该能判断:自己的场景能不能用这条路走通,走不通的话卡在哪一环。适合有网络编程基础、被隔离装置卡过一次的人。
2. 拆开隔离装置这层黑匣子:TCP 直连为什么必然失败
先把失败原因说清楚,不然调参数全是瞎调。隔离装置不是防火墙,防火墙放通之后 TCP 仍然是端到端握手的;隔离装置的本质是把一条链路切成两段,中间塞进一个私有摆渡通道。两侧的主机从来没握过手,握手的对象是隔离装置自己的接入进程。你抓包会看到 SYN 出去了、SYN-ACK 回来了,看起来一切正常,但对端根本不是真正的服务器。
理解这一点之后,很多"玄学"现象就解释得通了:为什么三次握手能成但业务超时、为什么小包通大包不通、为什么netstat里一堆 ESTABLISHED 却对不上号。都是因为 TCP 的端到端语义在这里被劈成了两半,任何依赖"对端就是我想连的那台机器"的假设都失效。
2.1 三个物理约束决定了所有实现细节
第一个约束是没有 IP 可达路径。内外两侧可能同网段也可能不同网段,但中间那条路只认设备的私有帧格式,普通 IP 包扔进去就是被丢弃。这意味着你不能靠 IP 层转发解决问题,必须自己把字节流封装成设备能接受的帧。
第二个约束是摆渡通道有单帧上限。设备手册上一般会写单次摆渡的最大报文体,常见量级是 1KB 上下。这个数值直接决定了你的分片策略和 MSS 裁剪方式,是后面第三章里最容易被忽略、也最容易引发血泪经验的一个参数。
第三个约束是方向性。正向隔离只允许数据从内到外;反向隔离只允许从外到内;双向的也往往区分主通道和应答通道。TCP 是双向的,一个POST请求过去还得有响应回来,所以单向设备上必须靠"请求走主通道、应答走回传通道"来配对,这是第四章要展开的部分。
提示:先确认手里的设备是单向还是双向,再决定要不要看反向部分。单向设备上硬配反向穿透,日志会显示请求已发出但永远等不到应答,白耗一整天。
2.2 三种补偿方案,demo 选了最省改动的那条
绕开这个限制有三条路,改动量和适用面差异很大:
| 方案 | 做法 | 应用改动 | 适用场景 | 主要代价 |
|---|---|---|---|---|
| 应用层改造 | 业务改成文件投递或消息队列 | 大 | 新建系统 | 协议要重设计 |
| 透明转发 | 设备侧做四层转发,尽量保持原语义 | 中 | 同构协议、双向设备 | 受设备能力限制 |
| 会话重建 | 两侧各自终结 TCP,中间用私有报文摆渡 | 无 | 存量系统、协议不可改 | 自己实现可靠性 |
demo 走的是第三条。思路很直白:内侧接入端accept掉客户端的连接,把读到的字节切成帧丢进摆渡通道;外侧输出端收到帧之后,自己向真实服务器发起一条全新的 TCP 连接,把字节原样写进去。返回的数据走同一条路径反向回来。对客户端来说,它只是连上了本机某个端口,完全不知道中间隔了一层。
代价是你得自己在摆渡层实现序号、确认、重传——因为这条通道不保证有序,也不保证不丢。设备厂商通常只保证"尽力投递",剩下的要靠上层兜。
2.3 demo 的四个模块与状态归属
拆开看,demo 里就四个角色,职责划得很清:
| 模块 | 职责 | 持有的状态 |
|---|---|---|
| ingress(内侧接入) | 监听端口、接受客户端连接、按会话编号 | 客户端五元组 ↔ session_id |
| ferry(摆渡编解码) | 封帧、分片、校验、重传队列 | 每会话的发送序号与未确认队列 |
| egress(外侧输出) | 向真实服务建连、写数据、读回包 | session_id ↔ 上游连接 |
| session table | 会话生命周期与回收 | 会话状态机、最后活跃时间 |
关键在会话状态机的归属。session_id 由 ingress 分配,是全局唯一的;egress 只认 session_id,不关心原始五元组。这样做的好处是反向穿透时逻辑可以复用——反向只是发起方换了一侧,会话表的结构不变。实际部署里 ingress 和 egress 往往跑在物理隔离的两台机器上,session table 得靠摆渡报文同步,不能指望共享内存。
3. 正向穿透实现:把 TCP 字节流拆成可摆渡的私有报文
正向是最常见的一条:内侧客户端发起,外侧连真实服务。这一章把三个最容易写错的地方钉死——握手顺序、报文头设计、分片与窗口。写完这三块,一个能跑通的基础链路就有了。
3.1 握手重建:为什么必须"先应答、后建连"
客户端 SYN 到达 ingress,你有两个选择:一是先向外侧发 OPEN、等 egress 连上真实服务之后再给客户端回 SYN-ACK;二是立刻回 SYN-ACK,把 OPEN 异步发出去。
正确做法是第二种。TCP 客户端的 SYN 重传是秒级的,而摆渡通道加上 egress 建连的往返,很容易超过 1 秒。走第一种,客户端会在你还在等 egress 的时候就开始重传 SYN,你这边会话状态还没落库,直接乱套。走第二种,客户端立刻认为连接已建立,开始发数据,这些数据先缓在 ingress 的会话缓冲里,等 egress 的 OPEN_ACK 回来再放行。
代价是要处理"建连失败":egress 连不上真实服务时,必须回一个 RST 报文,让 ingress 主动关掉客户端连接。否则客户端会一直以为连接是好的,写进去的数据全部石沉大海,这种故障排查起来极其费劲。
会话状态机大致是:SYN_RECV → ESTABLISHED(本侧握手完成)→OPEN_SENT→OPEN_ACKED(对侧建连成功)→CLOSING → CLOSED。中间任何一步超时都要能落到CLOSED并释放资源,这是防会话泄漏的根本。
3.2 报文头设计:16 字节里塞进哪些字段
摆渡报文头是整个链路的地基,改一次要动两侧,所以一开始就要设计对:
import struct # 摆渡报文头:固定 16 字节,全部网络字节序 # magic(2) ver(1) flags(1) sid(4) seq(4) ack(4) HDR = struct.Struct("!HBBIII") MAGIC = 0x4A47 # 固定魔数,用来识别通道里混进来的杂数据 FLAG_OPEN = 0x01 # 请求对侧向真实服务建连 FLAG_DATA = 0x02 # 携带净荷 FLAG_ACK = 0x04 # 纯确认,无净荷 FLAG_FIN = 0x08 # 半关闭,只关一个方向 FLAG_RST = 0x10 # 立即销毁会话 def pack(flags: int, sid: int, seq: int, ack: int, payload: bytes = b"") -> bytes: """把一段净荷封成摆渡帧。seq/ack 是本层自己的字节序号,与 TCP 序号无关。""" return HDR.pack(MAGIC, 1, flags, sid, seq, ack) + payload def unpack(buf: bytes): """解帧。magic 不对直接抛异常,不能静默跳过——静默跳过会把脏数据当净荷写进上游。""" magic, ver, flags, sid, seq, ack = HDR.unpack_from(buf, 0) if magic != MAGIC: raise ValueError("bad magic: %#x" % magic) if ver != 1: raise ValueError("unsupported version: %d" % ver) return flags, sid, seq, ack, buf[HDR.size:]字段含义和取舍:
sid是会话编号,由发起侧分配,4 字节够用;如果部署规模到十万级会话,建议留 8 字节,改的时候两侧一起改。seq是本层的字节序号,不是 TCP 的序号。两侧的 TCP 窗口完全独立,直接把 TCP seq 搬过来必然错位——这是新手最常犯的错。ack表示"我已经连续收到对端第 ack 字节之前的全部数据"。摆渡通道可能乱序,靠这个字段做重排。- 头部固定 16 字节、不做变长,是为了让分片逻辑足够简单:单帧净荷上限减去 16 就是可用空间。
ver字段现在看着没用,等你要在不停机的情况下升级协议就知道它的价值了。老版本帧直接拒掉,比解析出一堆半对的字段安全得多。
3.3 分片、MSS 与缓冲区:大包不通的根源都在这
客户端在 SYN 里宣告自己的 MSS,常见是 1460。但摆渡通道单帧可能只吃 1024 字节,头部还要占 16 字节。如果你老老实实按 1460 切,每一帧都超限,设备直接丢,而 TCP 层看不到丢包,只看到"对端迟迟不确认",表现就是传小文件没事、传大文件卡死。
正确的切法是取三者最小值:
MAX_FRAME = 1024 # 摆渡通道单帧上限,按设备手册填,别拍脑袋 HDR_LEN = HDR.size # 16 def payload_limit(peer_mss: int, frame_limit: int = MAX_FRAME) -> int: """ 单帧最大净荷 = min(对端 MSS, 通道单帧上限 - 头部) 下限保护 64,避免极端配置下切出 0 长度帧把循环打死。 """ return max(64, min(peer_mss, frame_limit - HDR_LEN)) def split(payload: bytes, limit: int): """按 limit 切片,最后一片可能不足 limit。""" for i in range(0, len(payload), limit): yield payload[i:i + limit]参数怎么定:frame_limit一定要去查设备手册或直接做二分探测,别猜。探测方法是拿已知长度的报文逐级加大发过去,看对侧能否收到,这个操作在联调期做一次就够。
缓冲区这边,每个会话至少要有两条:一条是"收到但还没被上游确认"的发送队列,一条是"上游读出来但还没封帧发走"的接收缓冲。demo 里给的是 64KB,按经验这能覆盖大多数 BDP 不大的场景;如果链路往返延迟高,按带宽 × RTT × 2估算再往上加。缓冲满了不能丢数据,正确做法是停止从 socket 读,让 TCP 窗口自然收缩,把背压传回给发送方。丢数据再靠重传补,是把自己的可靠性实现写成了半成品。
3.4 半关闭与 RST:尾巴上最容易漏的两件事
FIN是单向的。客户端发完数据关掉写方向,读方向还开着,等着服务端把响应吐完。如果 ingress 收到 FIN 就把整个会话拆了,响应全部丢失,客户端看到的是"数据发出去了,回包是空的"。正确做法是把 FIN 映射成一个FLAG_FIN帧,egress 收到后对上游 socket 调shutdown(SHUT_WR),读方向继续跑,直到上游也返回 EOF 再发一个反方向的 FIN。
RST则完全相反,它代表"立刻结束,不要讲礼貌"。收到 RST 要立刻销毁会话、关掉两侧 socket、释放 sid。这里有个坑:如果 RST 和最后一个数据帧在摆渡通道里乱序,RST 先到了,最后那段数据就被丢掉,客户端会报connection reset by peer,但业务日志里看不到任何错误。解决办法是让 RST 走有序队列——排在所有已发数据之后,或者干脆在发 RST 之前先把发送队列 flush 干净。
4. 反向穿透与 UDP 会话:两种更难的场景怎么兜住
正向跑通之后,反向和 UDP 是绕不过去的两道坎。前者难在方向受限,后者难在没有连接这个概念。这两块的实现思路差别很大,但共同点是:资源上限必须显式管理,不能让会话表被慢慢撑爆。
4.1 反向发起:靠白名单和配对令牌把口子收紧
反向场景是外侧主动访问内侧服务,比如外侧的运维终端要连内侧某台机器的采集端口。在双向设备上这跟正向是对称的,把 ingress 和 egress 的角色换一下就行。但在单向设备上,反向请求的 OPEN 帧必须走回传通道,而回传通道通常只允许"应答"类报文通过。
常见的落地方式是配对令牌:内侧周期性向外侧发布一份"可被反向访问的目的清单",每条带一个随机令牌和有效期;外侧收到反向请求时,先拿令牌去匹配,匹配上了才允许发 OPEN。这样回传通道里跑的始终是"对已知请求的应答",不违反单向设备的规则。
白名单是硬性的。反向穿透如果不限制目的地址和端口,等于在隔离装置上开了一个任意可达的入口,这在实际项目里是绝对过不了评审的。demo 里的白名单是一张静态表,落地时建议至少加上有效期和单次授权。
4.2 UDP 伪会话表:五元组映射加老化
UDP 没有握手也没有关闭,摆渡层必须自己造一个"伪会话"。做法是拿五元组当 key 建表,首包到达时分配 sid、记录活跃时间,回包按 sid 反查五元组,从内侧用同一个源端口发回给客户端——本质上是做了一次端口映射。
老化时间是这张表的命门。设太长,表会被短连接撑满;设太短,长连接的语音流或心跳会被反复重建。分级是必须的:
import time import threading UDP_IDLE = 60.0 # 普通 UDP 流的老化秒数 UDP_IDLE_DNS = 10.0 # 53 端口短老化,避免解析请求把表占满 UDP_TABLE_CAP = 8192 # 表容量上限,按内存和并发量估 class UdpTable: def __init__(self, cap: int = UDP_TABLE_CAP): self.cap = cap self.map = {} # 五元组 -> {"sid", "ts", "idle"} self.by_sid = {} # sid -> 五元组,回包方向查表用 self.lock = threading.Lock() self._next_sid = 1 def _idle_of(self, dport: int) -> float: return UDP_IDLE_DNS if dport == 53 else UDP_IDLE def get_or_create(self, key): """key = (src_ip, src_port, dst_ip, dst_port)。 表满时抛异常而不是静默丢弃——静默丢弃会让上层完全查不到线索。""" with self.lock: s = self.map.get(key) if s is not None: s["ts"] = time.time() return s if len(self.map) >= self.cap: raise RuntimeError("udp session table full: %d" % self.cap) sid = self._next_sid self._next_sid += 1 s = {"sid": sid, "ts": time.time(), "idle": self._idle_of(key[3])} self.map[key] = s self.by_sid[sid] = key return s def sweep(self) -> int: """周期性调用,回收超时会话。返回本次回收数量,便于打点观察。""" now = time.time() with self.lock: dead = [k for k, s in self.map.items() if now - s["ts"] > s["idle"]] for k in dead: self.by_sid.pop(self.map[k]["sid"], None) del self.map[k] return len(dead)几个参数的确定方式:UDP_IDLE一般不低于业务心跳周期的 3 倍,否则心跳包刚好卡在老化边界上会出现"隔一次丢一次"的怪现象;UDP_TABLE_CAP按单会话约 200 字节估算内存;sweep的调用周期取最小老化时间的 1/5 左右,太频繁浪费 CPU,太稀疏会让表短暂超容。
_next_sid这里用的是自增,长期跑会回绕。要更严谨的话换成随机分配并检查冲突,代价是多一次查表。
4.3 并发上限与背压:表满时该拒绝而不是硬扛
正向 TCP 和反向 UDP 都会撞上同一个问题——并发数到顶了怎么办。demo 里的处理是明确拒绝:TCP 直接回 RST,UDP 记录告警并丢弃。听起来粗暴,但比"默默排队直到全线超时"要好得多,至少运维在监控上能看到明确的拐点。
背压要分层做。摆渡通道的发送队列有长度上限,队列满了就停止读 socket;会话表有容量上限,满了就拒新连接;单会话缓冲有上限,满了同样停止读。三层都堵住之后,压力会自然回传到客户端,由客户端自己退避。这套机制不写,链路在压测时一定会以"内存涨到 OOM 然后进程重启"收场。
5. 避坑:五条从联调现场捡回来的记录
这一章按「现象 → 原因 → 解决」写,都是这套结构里真实会撞上的问题,顺序按遇到概率排。
第一条:小文件正常,传大文件卡死。
现象:几 KB 的数据秒过,几百 MB 的文件传到一半彻底不动,抓包只看到重传,没有任何错误码。原因:MSS 没按摆渡通道的单帧上限裁剪,超限帧被设备静默丢弃,而 TCP 层看到的是"对端不确认",只能靠超时重传,越传越慢直到完全停滞。解决:按min(peer_mss, frame_limit - 16)切片,并且让摆渡层自己带序号和确认——把丢包暴露在自己这一层,而不是指望 TCP 去感知一个它根本看不见的通道。
第二条:跑几小时之后会话数只涨不降。
现象:netstat里的 ESTABLISHED 数量随时间线性增长,最终撞上连接数上限,新请求全部被拒。原因:FIN 或 RST 没有被映射成摆渡帧,对侧不知道该关连接;本侧只能靠老化兜底,而老化时间被设成了 2 小时。解决:FIN 和 RST 都做双向映射,收到就触发会话回收;再补一个定期核对任务,把两侧会话表对一遍,差异超过阈值就告警。这个核对任务在后期的价值远高于它的实现成本。
第三条:UDP 心跳类业务隔一次丢一次。
现象:客户端的 UDP 心跳有规律地丢,丢包率稳定在 50% 左右,重发一次就好。原因:心跳周期恰好等于老化时间的一半,会话在两次心跳之间被回收,下一包重建会话,再下一次又被回收。解决:把UDP_IDLE提到心跳周期的 3 倍以上,并且在会话命中时刷新ts——很多实现只在新包时更新,回包不更新,长连接的接收方向就先老化了。
第四条:反向穿透配置完成但对端永远收不到。
现象:日志显示 OPEN 帧已经发出,外侧看不到任何入向请求。原因:单向设备上反向 OPEN 走的是回传通道,设备的放通规则里没给这类帧开口子;有的设备还会校验帧格式,私有报文头直接被判为非法。解决:先确认设备是否支持反向请求这一类帧,再确认放通规则;如果设备只放通文件形态的摆渡,就退回"文件投递 + 轮询"的方案,别硬刚。
第五条:两侧同时关闭时客户端报 connection reset。
现象:正常业务跑着没问题,客户端主动断开时偶发connection reset by peer,且服务端日志里看不到异常。原因:RST 帧在摆渡通道里乱序,抢在最后一段数据前面到达,最后那段数据被直接丢弃。解决:RST 走有序队列,或者发 RST 之前先把发送队列 flush 干净并等待确认。这个问题的排查成本很高,因为它在低负载下几乎不复现,只在压测尾部集中出现。
注意:上面五条里有四条都和"丢包/关连接"这类边界事件有关。联调阶段就把断开、超时、半关闭这几条路径的日志打全,比事后加埋点省事得多。
6. 验证与压测:怎么证明这条链路没有偷偷改你的数据
链路跑通不等于能用。穿透层最容易出的问题不是连不上,而是悄悄改数据——少一个字节、多一个字节、乱序,业务侧可能几周后才炸。所以验证要按"字节级正确性 → 边界尺寸 → 异常路径 → 长跑"四层来做,一层都不能跳。
先上最小闭环:写一个把收到的字节原样回写的测试服务放在外侧,内侧发固定内容,比对 MD5。内容用固定种子生成,保证可复现,别用/dev/urandom直接生成完就完事——出了问题你没法重放。
# 1. 生成可复现的测试净荷(1MB),记下 md5 作为基准 python3 -c "import sys,random;random.seed(20240501);sys.stdout.buffer.write(bytes(random.getrandbits(8) for _ in range(1048576)))" > /tmp/payload.bin md5sum /tmp/payload.bin # 2. 两侧分别起接入端和输出端,上游指向回写测试服务 python3 demo/ingress.py --listen 0.0.0.0:9000 --peer 10.0.0.2:9100 & python3 demo/egress.py --listen 0.0.0.0:9100 --upstream 127.0.0.1:9200 & # 3. 走一遍往返,比对结果 cat /tmp/payload.bin | nc 10.0.0.1 9000 > /tmp/echo.bin md5sum /tmp/echo.bin命令里的三个参数要留意:--peer填的是对侧摆渡地址,不是真实服务地址,这个搞混了会出现"连上了但数据全丢";--upstream才是真实服务;--listen的端口要和设备放通规则一致。比对时如果长度一致但 MD5 不同,基本可以断定是分片边界上的错位,回头查payload_limit的返回值。
边界尺寸一定要单独测,这是最容易漏的:
| 用例 | 净荷大小 | 关注点 |
|---|---|---|
| 零长度 | 0 B | 是否产生空帧、循环是否卡死 |
| 刚好一帧 | 1008 B(1024-16) | 分片边界是否多切一片 |
| 边界 +1 | 1009 B | 第二帧是否只剩 1 字节 |
| 常见 MTU | 1460 B | MSS 裁剪是否生效 |
| 大块 | 1 MB / 64 MB | 发送队列与背压 |
异常路径我是这么测的:在传输中途直接kill -9掉 egress,观察 ingress 多久回收会话、客户端收到的是 RST 还是超时;再把摆渡通道人为断开 30 秒后恢复,看能不能自愈、有没有重复投递。这两条如果不过关,生产环境一次设备重启就能让会话表永久泄漏。
长跑至少挂 12 小时,重点看三个指标:会话数与时间的关系是否线性上升(正常应该是锯齿状)、摆渡层重传率、每会话缓冲的高水位。重传率超过 1% 就该去查通道质量,而不是加大重传次数硬扛。
从那以后我每次接这类隔离装置的活,都强制走一遍"固定种子净荷 + 五个边界尺寸 + kill 一侧 + 12 小时长跑"这套组合,哪怕甲方催得再急也不跳步——跳过的那次,最后都是在验收现场用两倍时间补回来的。希望帮到你。
本文还有配套的精品资源,点击获取