简介:面向网络隔离场景的TCP/UDP穿透演示包,服务于网络安全开发者、协议调试人员及需要验证正反向隔离装置转发链路的工程实践者,重点解决隔离环境下TCP与UDP报文如何穿透、代理转发与收发验证的问题。压缩包共11个文件,约1.68MB,包含C++源码、ini配置、txt说明、pptx整体方案,以及TCP/UDP测试程序、代理端与输出文件等,便于从代码、配置和方案文档三个层面理解实现。已有1253人学习下载,适合作为隔离装置穿透实验的参考样例。读者可据此搭建TCP与UDP双通道测试环境,对照源码梳理代理监听、端口映射、数据收发与输出校验流程,并结合整体方案文档理解正反向隔离服务的部署思路;新增的UDP穿透部分尤其适合用来验证无连接报文的转发与接收行为,为排查链路连通性、配置差异和协议适配问题提供可复用的实验素材。
1. 正反向隔离装置下的 TCP/UDP 通道 demo:先把"两段连接"这件事讲透
联调现场最常见的一幕:telnet 打到对端端口是通的,握手三步走完,然后就没有然后了——没有回包,没有 RST,抓包只看到一堆重传。八成是链路中间横着一台正反向隔离装置,它把一条 TCP 连接硬生生劈成了两段。这个 demo 就是干这件事的:在隔离装置两侧各起一个代理进程,一端终结客户端连接,另一端重建到真实服务端的连接,中间靠私有摆渡帧把字节流搬运过去。它适合正在做工业现场或内网隔离环境对接的后端、运维、嵌入式网络开发者,尤其是被"TCP 能连但不通"折磨过一轮的人。tcpip 层面的东西,光看文档没用,得把会话表、帧头、超时这三样握在手里,网络安全边界才不会踩线。
2. 项目结构与技术选型:三段式角色怎么划分
拿到这份 demo,第一眼别急着跑,先看它把进程拆成了几个角色。隔离装置这种东西,本质是"物理上单点、协议上必断"的盒子,所以任何跨它的通信方案都只有一种可行结构:两侧各自维护一半连接,中间用设备允许的方式搬运数据。demo 的做法很朴素,没有花哨的中间件,纯 Python 3 标准库,asyncio 做并发,struct 做帧编解码,落盘目录模拟摆渡载体。选 asyncio 而不是多线程的原因很实际——一个现场动辄几百条并发会话,线程模型下每条会话两三个线程,上下文切换的开销比协议处理本身还大。
2.1 三个角色:客户端代理、摆渡载体、服务端代理
角色划分是这套东西的骨架,理解错了后面全是坑。demo 里分成三块:
第一块是客户端代理(forward agent),部署在发起侧网络,对外暴露监听端口,任何本地程序连它,它就当自己是目标服务。它负责接收字节流、切分会话、打上 session_id 后压成帧,丢进摆渡载体。
第二块是摆渡载体(carrier),这是隔离装置真正允许通过的通道。demo 里用一个双向目录模拟:spool_out/放待摆渡的帧,spool_in/放摆渡回来的帧。真实设备上这块可能是设备厂商提供的文件落地接口、私有 TCP 摆渡端口、或者 UDP 单向广播口。载体不关心内容,只管把一坨字节从 A 侧送到 B 侧,所以它是整条链路里唯一"被信任"的部分。
第三块是服务端代理(reverse agent),部署在目标网络侧,从spool_in/里捞帧,还原出 session_id 和原始字节,按 session_id 找到(或新建)到真实服务端的 TCP 连接,把数据写进去;服务端回包时反向走一遍,回程帧丢回spool_out/。
这种拆法的好处是故障域清晰。客户端代理挂了,业务方看到的是连接被拒;载体堵了,两侧代理日志里都是"写入等待超时";服务端代理挂了,客户端侧表现为连接建立但无响应。排查时一眼就能定位到是哪一段的问题,不用全链路瞎猜。
提示:载体这一段是设备厂商的边界,不要在里面做任何加密、压缩、协议改造。设备认什么格式,你就老老实实按什么格式喂,改造放在两侧代理里做。
2.2 目录结构与启动顺序
demo 的目录组织得比较规矩,按"协议层 / 传输层 / 工具"分:
isolation-tunnel-demo/ ├── relay/ │ ├── frame.py # 帧编解码:magic + ver + session_id + seq + len + crc │ ├── session.py # 会话表:分配、查找、老化回收 │ ├── forward.py # 客户端代理:监听 + TCP/UDP 接入 │ ├── reverse.py # 服务端代理:还原 + 回连真实服务 │ └── config.py # 参数加载,命令行覆盖 yaml ├── carrier/ │ ├── spool_out/ # 待摆渡帧(发起侧写、目标侧读) │ ├── spool_in/ # 回程帧(目标侧写、发起侧读) │ └── carrier_file.py # 落盘载体的读写封装 ├── tools/ │ ├── echo_tcp.py # 目标侧 TCP echo,用来验证 │ ├── echo_udp.py # 目标侧 UDP echo │ └── bench.py # 并发压测脚本 ├── config.yaml └── run.sh启动顺序有讲究,必须先起服务端代理,再起客户端代理。原因在于 carrier 目录的初始化:服务端代理启动时会清理残留的spool_in/半截帧并写入自己的启动标记,客户端代理会在这一步检查标记是否新鲜,标记过期就拒绝启动,直接报"对端未就绪"。这个设计看着多余,实际上避免了两个进程抢同一批帧的经典事故。
# 目标侧(隔离装置内侧)先起 python -m relay.reverse --config config.yaml --role reverse # 发起侧(隔离装置外侧)后起 python -m relay.forward --config config.yaml --role forward # 验证:本地 echo 服务 + 代理端口 python tools/echo_tcp.py --listen 127.0.0.1:9100 telnet 127.0.0.1 9000run.sh里其实就是这两条命令加日志重定向,没别的。参数上真正需要动的只有三个:forward.listen(客户端代理对外端口)、reverse.target(目标侧真实服务地址)、carrier.spool_dir(摆渡目录)。其余像frame.max_payload、session.idle_timeout保持默认即可,改之前先把默认值的含义搞清楚。
2.3 帧格式:摆渡载体里流动的"信封"
帧是整个 demo 的核心数据结构,两侧代理对它的理解必须完全一致,一字节都不能差。设计上参考了常见私有协议的思路:定长头 + 变长体 + 校验。
import struct, zlib MAGIC = b"ISL1" # 魔数,用于切分帧边界 HEADER = struct.Struct("!4sBIIHI") # 大端:magic, ver, sid, seq, len, crc MAX_PAYLOAD = 8192 # 单帧最大载荷,和载体 MTU 对齐 def pack_frame(sid: int, seq: int, payload: bytes, ver: int = 1) -> bytes: """把一个会话的数据切片封装成一帧""" if len(payload) > MAX_PAYLOAD: raise ValueError(f"payload {len(payload)} exceeds {MAX_PAYLOAD}") crc = zlib.crc32(payload) & 0xFFFFFFFF # 只校验载荷,头由魔数兜底 head = HEADER.pack(MAGIC, ver, sid, seq, len(payload), crc) return head + payload def unpack_frame(buf: bytes): """从缓冲区头部解一帧,返回 (sid, seq, payload, 消耗字节数)""" if len(buf) < HEADER.size: return None # 头都没收全,等下次 magic, ver, sid, seq, length, crc = HEADER.unpack_from(buf, 0) if magic != MAGIC: raise ValueError("bad magic, carrier corrupted or offset drift") end = HEADER.size + length if len(buf) < end: return None payload = buf[HEADER.size:end] if zlib.crc32(payload) & 0xFFFFFFFF != crc: raise ValueError(f"crc mismatch on sid={sid} seq={seq}") return sid, seq, payload, end逐项说清楚:MAGIC用固定 4 字节,解帧时一旦对不上就直接判定载体数据损坏或读偏移错位,这是最快的失败信号,比等 CRC 报错要早。ver留给协议升级,现在固定 1,将来加字段时靠它分流。sid是会话号,由客户端代理在接入时分配,全局唯一,反向回程帧靠它找到对应连接。seq是会话内序号,仅用于日志排查和丢帧检测,demo 没做重传——载体本身是可靠的文件落地,不需要应用层重传。len用 2 字节,配合 8192 的上限刚好够用,别贪心开大,载体单次落盘超过这个数在实际设备上经常被截断。crc只算载荷不算头,因为头里已有魔数和长度做结构校验,重复校验意义不大。
unpack_frame返回None表示"数据不够,继续等",这是流式解帧的标准姿势。注意它返回消耗字节数,调用方要自己把缓冲区切片切掉,不要在原地改 buffer,否则并发下很容易出现读偏移错乱。我在别的项目里见过直接buf = buf[end:]写在循环里,结果异常分支一出,缓冲区就永久错位了,后面所有帧全报废。
3. 正向通道:从客户端接入到隔离装置内落地
正向通道是把发起侧的请求搬到目标侧,链路最长、分支最多,也是调试时最花时间的部分。它的处理流程是"接入 → 分配会话 → 切帧 → 写入载体",四步走,每一步都有独立的失败模式。
3.1 TCP 接入与会话表:sid 怎么分配、怎么回收
客户端代理对外只暴露一个监听端口,来一个连接就分配一个 sid。会话表用字典存,键是 sid,值是会话状态对象。分配策略上 demo 用了自增 + 回绕,不是随机数,理由是现场排查时日志里sid=1001比sid=8f3a2c好认得多。
import asyncio, itertools, time class Session: __slots__ = ("sid", "peer", "target", "created", "last_active", "writer") def __init__(self, sid: int, peer: tuple, target: tuple): self.sid = sid self.peer = peer self.target = target self.created = time.time() self.last_active = time.time() self.writer = None # 接入侧 TCP writer,用于回程写回 class SessionTable: def __init__(self, idle_timeout: int = 300): self._sessions: dict[int, Session] = {} self._counter = itertools.cycle(range(1, 0xFFFFFFFF)) self.idle_timeout = idle_timeout def alloc(self, peer, target) -> Session: sid = next(self._counter) sess = Session(sid, peer, target) self._sessions[sid] = sess return sess def get(self, sid: int) -> Session | None: sess = self._sessions.get(sid) if sess: sess.last_active = time.time() return sess def recycle(self, sid: int): sess = self._sessions.pop(sid, None) if sess and sess.writer and not sess.writer.is_closing(): sess.writer.close() async def sweeper(self, interval: int = 30): """后台任务:定时清理超时会话,防止字典无限膨胀""" while True: await asyncio.sleep(interval) now = time.time() dead = [s for s in self._sessions.values() if now - s.last_active > self.idle_timeout] for s in dead: self.recycle(s.sid)__slots__是省内存的小手段,几百个会话看不出差别,上万会话时能省下可观的对象开销。itertools.cycle做自增回绕比取模运算更直观,也不会在 2^32 边界上出岔子。get()里顺手刷新last_active,这样只要会话上还有数据流动,就不会被 sweeper 误杀——这是个容易漏的细节,只靠建立时间判断空闲,长连接必被误回收。
sweeper必须是独立的后台任务,不能塞进数据循环里。我见过把清理逻辑写在while True: read()后面的实现,结果流量一停整个循环阻塞在读操作上,清理永远跑不到,内存一路涨到 OOM。用asyncio.create_task单独拉起来,开销可以忽略。
recycle里主动close()writer 这一步也不能省。TCP 连接不是垃圾回收的对象,引用计数归零不代表对端收到 FIN,客户端那边会卡在 CLOSE_WAIT 上直到超时。
3.2 帧封装与写入载体:切包、刷盘、边界对齐
接入侧拿到字节流之后,要按MAX_PAYLOAD切片,每片一帧,按seq递增写入载体。切片的正确姿势是"读多少封多少",不要试图攒满一帧再发——TCP 是流,你永远不知道下一次读到多少,攒包会把短连接直接卡死。
async def forward_tcp(reader: asyncio.StreamReader, writer: asyncio.StreamWriter, table: SessionTable, carrier, target_addr: tuple): peer = writer.get_extra_info("peername") sess = table.alloc(peer, target_addr) sess.writer = writer seq = 0 try: while True: chunk = await reader.read(MAX_PAYLOAD) # 最多读一帧的量 if not chunk: break # 对端关闭 frame = pack_frame(sess.sid, seq, chunk) await carrier.put(frame) # 落盘 + 触发摆渡 seq += 1 except (ConnectionResetError, asyncio.IncompleteReadError): pass # 客户端异常断开,正常收尾 finally: # 发一个零长度帧作为 FIN 标记,目标侧据此关连接 await carrier.put(pack_frame(sess.sid, seq, b"")) table.recycle(sess.sid)reader.read(MAX_PAYLOAD)而不是readexactly,这是关键差异:readexactly会一直等到凑够 8192 字节才返回,交互式协议下这就是死锁。用read有多少拿多少,帧长由len字段自己描述,解帧端天然支持变长。
循环结束时补一个零长度帧作为 FIN 标记,这是 demo 里比较巧的一手。很多同类实现直接靠超时让目标侧断开,结果是服务端连接要挂满 5 分钟才释放,端口占用严重。显式 FIN 帧让目标侧立刻关闭上游连接,行为可预期。
carrier.put()内部做的事是:写到spool_out/下一个临时文件名(带 pid + 时间戳),fsync之后再os.rename成正式名。这个"临时名 + 原子重命名"是落盘载体的标准套路,读端永远看不到写了一半的文件。省掉 fsync 的话,机器掉电时很容易读到全零文件,解帧直接报 bad magic。这个坑我在别的项目里踩过一次,之后凡是跨进程落盘交换数据,我都强制走一遍 fsync。
3.3 摆渡读取循环:目录扫描的节奏与幂等
目标侧的服务端代理要持续盯着spool_in/,出现新文件就捞出来解帧。实现上 demo 没用 inotify,用的是固定间隔扫描,原因很实在:隔离装置两侧的文件系统往往是网络挂载或者单向同步出来的,inotify 事件在这种环境里不可靠,经常丢事件。
import os, glob async def carrier_poller(spool_dir: str, handler, interval: float = 0.05): """固定间隔扫描摆渡目录,处理完即删,保证幂等""" while True: files = glob.glob(os.path.join(spool_dir, "*.frm")) files.sort(key=lambda p: os.path.getmtime(p)) # 按落盘时间排序 for path in files: try: with open(path, "rb") as f: data = f.read() if not data: continue # 空文件直接跳过 sid, seq, payload, _ = unpack_frame(data) await handler(sid, seq, payload) except (ValueError, OSError) as e: # 坏帧隔离到 bad/ 目录,不中断整体循环 os.makedirs(f"{spool_dir}/../bad", exist_ok=True) os.replace(path, f"{spool_dir}/../bad/{os.path.basename(path)}") continue finally: if os.path.exists(path): os.remove(path) # 处理完删除,防重复 await asyncio.sleep(interval)三个要点。第一,排序。按 mtime 排序保证同一个会话的帧按落盘顺序处理,乱序解帧在长流场景下会导致数据错位,而且错位后 CRC 一直报错,看起来像随机故障。第二,坏帧隔离。遇到解不出来的帧,os.replace挪到bad/,不要原地删,也不要抛出异常中断循环。现场最怕的就是一帧脏数据把整个代理打死,挪走之后还能事后分析。第三,finally 里删除。这样即使 handler 内部抛异常,文件也会被清掉,不会出现同一帧被反复处理的死循环。但注意顺序——os.replace之后path已经不存在,exists判断是必要的。
interval=0.05秒是折中值,扫太勤空转吃 CPU,扫太慢延迟上去了。真实设备环境中,如果摆渡本身就有几百毫秒延迟,这个值可以放大到 0.2 秒甚至更大。判断依据是实测的端到端延迟,而不是拍脑袋。
4. 反向通道:回程帧、UDP 伪连接与状态维持
反向通道是从目标侧把响应搬回发起侧,逻辑上跟正向对称,但多了两个麻烦:一是回程帧必须准确落到对应的会话上,找不到会话就只能丢弃;二是 UDP 天生无连接,你得靠一张伪连接表把它"伪装"成有状态的流。
4.1 回程帧与会话匹配:找不到 sid 时的处理策略
服务端代理从真实服务收到数据后,要封成帧丢回spool_out/,客户端代理的读取循环捞到之后,按 sid 查会话表,写回对应的客户端连接。
async def handle_backward(table: SessionTable, sid: int, seq: int, payload: bytes): sess = table.get(sid) if sess is None: # 会话已回收,说明客户端早断开,直接丢帧 log.debug("drop orphan frame sid=%s seq=%s len=%d", sid, seq, len(payload)) return if not payload: # 零长度 = 目标侧 FIN,主动关闭客户端连接 table.recycle(sid) return if sess.writer is None or sess.writer.is_closing(): table.recycle(sid) return try: sess.writer.write(payload) await sess.writer.drain() # 必须 drain,否则发送缓冲堆积 except ConnectionResetError: table.recycle(sid)drain()这一步是最容易被删掉的。很多人觉得write()就发出去了,实际上它只是把数据塞进传输层缓冲区,缓冲区满了之后write仍然是瞬时返回,数据在内核里越积越多,最后表现为"客户端早就收到响应了,服务端代理内存还在涨"。加上drain(),backpressure 才能传导到读循环,整条链路才有流量控制。
孤儿帧直接丢弃而不是缓存等待,这是刻意的选择。缓存看似"更可靠",实际上会造成严重问题:会话号是回绕分配,缓存久了可能被新会话复用,旧帧被投递到新连接上,那就是数据串话,比丢包严重得多。所以宁可丢,不可错。
4.2 UDP 伪连接:用四元组建表、用定时器老化
UDP 没有连接概念,隔离装置两侧要模拟出"有状态"的行为,只能靠伪连接表。建表的键是四元组,超时由独立定时器负责。
class UdpPseudoSession: __slots__ = ("sid", "peer", "target", "last_active", "transport") def __init__(self, sid, peer, target, transport): self.sid, self.peer, self.target = sid, peer, target self.transport = transport self.last_active = time.time() class UdpTable: def __init__(self, idle_timeout: int = 60): self._by_sid: dict[int, UdpPseudoSession] = {} self._by_quad: dict[tuple, int] = {} # (peer, target) -> sid self.idle_timeout = idle_timeout def lookup_or_create(self, peer, target, make_sid, transport): quad = (peer, target) sid = self._by_quad.get(quad) if sid and sid in self._by_sid: s = self._by_sid[sid] s.last_active = time.time() return s sid = make_sid() s = UdpPseudoSession(sid, peer, target, transport) self._by_sid[sid] = s self._by_quad[quad] = sid return s为什么用两个字典?_by_sid负责回程定向(拿到回程帧的 sid,找到发起侧地址),_by_quad负责接入侧去重(同一个源地址 + 目标地址反复发包,必须复用同一个 sid,否则一次请求会建出成千上万个伪连接)。两张表必须同步增删,删除时先删 quad 再删 sid,反过来会短暂出现"quad 指向不存在的 sid"的窗口,虽然概率极低,但并发压测下能撞到。
UDP 老化的超时比 TCP 短得多,demo 默认 60 秒。原因是 UDP 伪连接没有 FIN/RST,只能靠空闲时间判断,设太长会导致端口和 sid 被长期占用。真实环境里还要看业务心跳周期,心跳 10 秒一次的话,超时至少设 3 倍心跳周期。
4.3 半关闭、心跳与端到端超时的组合
TCP 半关闭是这个 demo 里最别扭的部分。客户端shutdown(SHUT_WR)之后连接并没有断,还能继续收数据,但代理侧的读循环会读到 EOF 直接退出,然后触发recycle,回程数据就再也没有接收方了。
demo 的处理方式是给每帧加个 3 位的type字段(占用ver高位的空余位),区分DATA、FIN、HALF_CLOSE。收到HALF_CLOSE时只关闭写入方向,会话保留,直到收到FIN或者idle_timeout到期才彻底回收。
async def on_half_close(table: SessionTable, sid: int): sess = table.get(sid) if sess and sess.writer and not sess.writer.is_closing(): if sess.writer.can_write_eof(): sess.writer.write_eof() # 只关写方向,读方向继续 sess.last_active = time.time() # 续命,等 FIN端到端超时的组合上,demo 用了三层:应用层idle_timeout(默认 300 秒,会话级)、载体层单帧等待上限(默认 30 秒,帧级)、TCP keepalive(内核参数,通常默认 2 小时)。这三层的语义完全不同,不能混用——idle_timeout管的是"这个会话没人说话了",30 秒上限管的是"这一帧摆渡太久,估计丢了",keepalive 管的是"这个 TCP 对端是死是活"。把三层混成一层,调参时会互相打架,最后怎么调都不对。
注意:
write_eof()在部分平台和部分 transport 上不被支持,调用前一定要判断can_write_eof()。不支持的环境下退化成"收到 HALF_CLOSE 后不立即回收,等 FIN 或超时",功能不受影响,只是半关闭语义变弱。
5. 避坑与排查:五个真实踩过的坑
5.1 现象:客户端 telnet 上去立刻断开,代理日志只有"connection reset"
原因通常是服务端代理压根没启动,或者spool_in/的启动标记过期。客户端代理在接入时做了对端就绪检查,检查失败就主动 reset。
解决办法:先ps看 reverse 进程在不在,再看spool_in/.ready的时间戳是不是当前会话写下的。还有一种隐蔽情况是两侧配置里的carrier.spool_dir指向不同目录,一个写了另一个读不到,日志上完全不报错,只有连不通。排查时用ls -l对一眼两侧的目录内容,比看日志快。
5.2 现象:大文件传输跑到一半卡住,两侧代理 CPU 都接近 0
典型的 backpressure 死锁。要么是writer.write()后面漏了drain(),要么是carrier.put()里同步落盘把事件循环堵死了。
解决办法:先确认每个 write 后面都有 await drain。再检查落盘是不是用了同步open().write()——在 asyncio 里同步落盘会把整个循环卡住,所有会话一起停摆。改成asyncio.to_thread或者loop.run_in_executor包一层。这个坑不表现为报错,表现为"越跑越慢最后不动",非常折磨。
5.3 现象:CRC 校验随机失败,重启后又好了
载体层的问题,不是协议层。常见原因有三种:一是写入方没做"临时名 + 原子重命名",读端读到了半截文件;二是网络挂载的文件系统缓存不一致,读端看到的是旧内容;三是单帧超过载体允许的最大落盘大小被设备截断。
解决办法:写入侧强制fsync+os.replace;读端遇到 CRC 失败先比对文件大小是否等于HEADER.size + len,不等就是截断;把MAX_PAYLOAD降到 4096 甚至 1024 试一轮,如果不再报错就说明是 MTU 或单次落盘上限的问题。
5.4 现象:UDP 场景下同一个客户端每次发包都新建伪连接,sid 一路涨到几万
_by_quad的键设计错了。有实现把端口也放进键里,或者用了(ip, port)但 port 取的是解析后的随机本地端口,导致同一台客户端每次发包都被认为是新源。
解决办法:打印伪连接表的键,确认四元组稳定。另外注意 NAT 环境,同一个内网客户端经过 NAT 后源端口可能每次都变,这种场景下不能靠四元组建表,得退化成按目标地址 + 会话 token 匹配,或者直接把空闲超时调短,让伪连接快速自然老化。
5.5 现象:会话表内存持续增长,sweeper 明明在跑
sweeper的while True里await asyncio.sleep(interval)写在了 try 外面但循环体内部有阻塞调用,或者 sweeper 任务本身被异常终止后没人发现。
解决办法:给create_task的返回值挂一个add_done_callback,任务异常退出时立刻打日志并重启。同时把清理逻辑改成"先收集再删除",不要在遍历字典的过程中修改字典,Python 会直接抛RuntimeError: dictionary changed size during iteration,任务死掉之后所有会话就永久残留了。这个异常如果不打日志,表现出来就是"内存一直涨、看不出来为什么"。
6. 二次开发:加协议、加校验、加压测
拿这份 demo 做二次开发,最值当的改造方向是把载体层抽象成接口。现在carrier_file.py是硬编码的目录落盘,真实设备上换成私有摆渡端口时得改一堆调用点。定义Carrier抽象基类,暴露async put(bytes)和async get() -> bytes,落盘、TCP 摆渡、UDP 单播各实现一个子类,配置里加carrier.type分流。改完之后换载体不动业务代码。
第二个方向是鉴权。demo 默认信任载体,任何能写spool_in/的进程都能注入帧。生产环境至少加两样:帧头里塞一个 8 字节的 token(由两侧共享配置读入),不匹配直接丢弃;会话分配时校验peer是否在允许的网段白名单内。这两项都不复杂,但没有的话,载体目录一旦被别的程序写进去,就是典型的注入风险。
第三个方向是压测。tools/bench.py现在只测 TCP 单连接吞吐,看不出问题。改成多进程并发建连 + 随机大小包 + 长短连接混合,重点观察三个指标:会话表峰值条目数、载体目录的写入积压量、端到端 P99 延迟。
| 改造项 | 改动文件 | 风险点 | 验证方式 |
|---|---|---|---|
| 载体抽象 | carrier/*.py, config.py | 切换时 spool 目录残留 | 双载体并行跑一轮,比对帧数 |
| 帧鉴权 | frame.py, forward.py, reverse.py | token 不一致导致全丢帧 | 故意配错 token,确认有明确日志 |
| 多进程压测 | tools/bench.py | 端口耗尽、文件句柄上限 | ulimit -n调大,观察积压曲线 |
改完必须验证的一步是坏帧注入:手动往spool_in/里丢一个随机字节文件,确认代理把文件挪进bad/并且继续正常处理后续帧,不中断也不崩。这一步我每次改完帧格式都强制走一遍,因为坏帧处理是整条链路里唯一没法靠压测覆盖的路径,只能手动造。
我自己的习惯是:任何改动只要碰了frame.py,先跑一遍"单会话 echo → 大文件 → 半关闭 → 坏帧注入"这个四步回归,四步全绿再谈性能。有一次偷懒跳过了坏帧那一步,上线第二天一帧脏数据把服务端代理打死,回滚花了两个小时。从那以后,帧格式一动,坏帧注入必跑。希望帮到你。
本文还有配套的精品资源,点击获取