简介面向网络隔离环境下的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_idferry摆渡编解码封帧、分片、校验、重传队列每会话的发送序号与未确认队列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 直接回 RSTUDP 记录告警并丢弃。听起来粗暴但比默默排队直到全线超时要好得多至少运维在监控上能看到明确的拐点。背压要分层做。摆渡通道的发送队列有长度上限队列满了就停止读 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 B1024-16分片边界是否多切一片边界 11009 B第二帧是否只剩 1 字节常见 MTU1460 BMSS 裁剪是否生效大块1 MB / 64 MB发送队列与背压异常路径我是这么测的在传输中途直接kill -9掉 egress观察 ingress 多久回收会话、客户端收到的是 RST 还是超时再把摆渡通道人为断开 30 秒后恢复看能不能自愈、有没有重复投递。这两条如果不过关生产环境一次设备重启就能让会话表永久泄漏。长跑至少挂 12 小时重点看三个指标会话数与时间的关系是否线性上升正常应该是锯齿状、摆渡层重传率、每会话缓冲的高水位。重传率超过 1% 就该去查通道质量而不是加大重传次数硬扛。从那以后我每次接这类隔离装置的活都强制走一遍固定种子净荷 五个边界尺寸 kill 一侧 12 小时长跑这套组合哪怕甲方催得再急也不跳步——跳过的那次最后都是在验收现场用两倍时间补回来的。希望帮到你。本文还有配套的精品资源点击获取
阅读完成 · 觉得有帮助?