限速/丢包/乱序/重复/篡改:弱网模拟的 5 把利刃
WeakNet 技术博客系列 | 第 5 篇
数据包到了,然后呢?
前面两篇我们完成了 IP 数据包的解析(第 3 篇)和用户态 TCP 代理(第 4 篇)。现在数据包能从 App 正确到达真实网络,也能把响应送回来。但这一切还太完美了------没有延迟,没有丢包,没有乱序。这和真实网络相去甚远。
这篇要讲的是 WeakNet 最核心的部分------数据包操控管线。五个操纵器(限速 Throttle、乱序 Reorder、重复 Duplicate、丢包 Loss、篡改 Tamper)各司其职又相互配合,通过一条管线串联执行,任意组合、动态调节。每个操纵器只需要关心自己的逻辑,管线负责把它们粘在一起。
先看接口设计
所有操纵器实现同一个接口:
kotlin
interface PacketManipulator {
fun manipulateOutgoing(packet: IpPacket): List<IpPacket>
fun manipulateIncoming(packet: IpPacket): List<IpPacket>
}
返回 List<IpPacket> 而不是单个 IpPacket,是整个管线设计的核心。返回值的数量就是语义:0 个表示丢包,1 个表示正常通过(可能被修改),2 个及以上表示重复。这种设计让每个操纵器都能用统一的签名表达"过滤"、"变换"和"增殖"三种语义。
管线用 flatMap 串联起来,简洁到几乎不需要解释:
kotlin
fun processOutgoing(packet: IpPacket): List<IpPacket> {
var packets = listOf(packet)
for (m in manipulators) {
packets = packets.flatMap { m.manipulateOutgoing(it) }
}
return packets
}
一个包进去,经过第一个操纵器可能变成两个(重复),两个包经过第二个可能变成一个(丢了一个),再经过第三个可能全没了。flatMap 天然支持这种 1-to-N 的变换链,用起来很舒服。
管线顺序:为什么是 Throttle -> Reorder -> Duplicate -> Loss -> Tamper
管线的执行顺序不是随便排的,每个位置都有讲究:
text
Throttle -> Reorder -> Duplicate -> Loss -> Tamper
Throttle 在最前面,因为它控制的是实际发送速率。限速说白了就是"每字节等多久",通过 Thread.sleep 暂停当前线程来控制节奏。如果限速不在最前面,后续操纵器产生的重复包会导致额外的时间消耗,速率控制就不准了。
Reorder 紧跟 Throttle。乱序需要缓冲数据包然后打乱顺序,缓冲就意味着延迟。把它放在限速之后,可以保持限速的时间语义------先限速确定发送时机,再通过缓冲和交换制造乱序。
Loss 放在 Duplicate 后面。原因很直觉:被重复出来的包也应该有机会被丢弃。如果 Loss 在 Duplicate 前面,一个包可能先被丢弃,Duplicate 操纵器根本看不到它,就没法产生"重复了一半、丢了一半"的效果。
Tamper 放最后。篡改是"最后一公里"的损坏,只有经过前面所有筛选后幸存下来的包才值得花力气去改。如果篡改在前面,一个被篡改的包可能随后又被丢弃,白费了计算开销。
ThrottleManipulator -- 限速
限速的思路是每会话独立的速率控制器。不同 TCP 连接、不同 UDP 会话之间互不影响,避免多连接共享同一令牌桶导致延迟线性叠加。
kotlin
class ThrottleManipulator(
uploadSpeedKbps: Int = 0,
downloadSpeedKbps: Int = 0,
) : PacketManipulator {
private val nanosPerByteUpload: Double = if (uploadSpeedKbps > 0)
1_000_000_000.0 / (uploadSpeedKbps.toLong() * 1000L / 8L) else 0.0
private val nanosPerByteDownload: Double = if (downloadSpeedKbps > 0)
1_000_000_000.0 / (downloadSpeedKbps.toLong() * 1000L / 8L) else 0.0
private val uploadControllers = ConcurrentHashMap<String, RateController>()
private val downloadControllers = ConcurrentHashMap<String, RateController>()
// ...
}
核心是 RateController,使用了累计 nextSendTime 调度法:
kotlin
private class RateController(private val nanosPerByte: Double) {
private var nextSendTimeNanos: Long = System.nanoTime()
fun acquire(bytes: Int) {
val waitNs: Long = synchronized(this) {
val now = System.nanoTime()
val sendAt = maxOf(nextSendTimeNanos, now)
nextSendTimeNanos = sendAt + (bytes * nanosPerByte).toLong()
sendAt - now
}
if (waitNs > 0) Thread.sleep(waitNs / 1_000_000, (waitNs % 1_000_000).toInt())
}
}
这里有一个关键的并发设计:sleep 放在 synchronized 块外面。锁只保护 nextSendTimeNanos 的读写和计算,纳秒级的计算几乎不会阻塞。真正耗时的 Thread.sleep 放在锁外面,不会阻塞同一会话中后续包的速率计算,更不会阻塞其他会话。
nextSendTimeNanos 采用累计调度------每次发送后,下一次允许发送的时间等于本次发送时间加上本次字节数对应的等待时间。如果中间有一段空闲期,maxOf(nextSendTimeNanos, now) 会自动追平到当前时间,不会出现"空转后集中发送"的问题。
上传和下载方向使用独立的 ConcurrentHashMap,速率参数也分开配置。这意味着你可以只限上传不限下载,或者反过来。
ReorderManipulator -- 乱序
乱序操纵器用的是"跟前一个包交换"策略,而不是缓冲区洗牌。每个会话维护一个单包缓冲槽,新包到达时与前一个包按概率交换顺序:
kotlin
class ReorderManipulator(bufferSize: Int = 0) : PacketManipulator {
private val swapChance = if (bufferSize > 0)
(bufferSize.toFloat() / 20f).coerceAtMost(1.0f) else 0f
private val outgoingBuffers = ConcurrentHashMap<String, IpPacket>()
private val incomingBuffers = ConcurrentHashMap<String, IpPacket>()
override fun manipulateOutgoing(packet: IpPacket): List<IpPacket> {
if (swapChance <= 0) return listOf(packet)
if (packet.ipHeader.isTcp) return listOf(packet)
val key = sessionKey(packet)
val last = outgoingBuffers.put(key, packet)
if (last != null) {
return if (Random.nextFloat() < swapChance)
listOf(packet, last) else listOf(last, packet)
}
if (outgoingBuffers.size > MAX_BUFFER_ENTRIES) {
outgoingBuffers.remove(key)
return listOf(packet)
}
return emptyList()
}
// ...
}
交换概率由 bufferSize 参数推导:swapChance = bufferSize / 20,上限为 1.0。bufferSize 越大,乱序概率越高。当 bufferSize 达到 20 时,每次都会交换。
TCP 双方向都跳过乱序。出方向:代理已经代替 App 向服务器 ACK 了数据,如果此时再把包乱序写入上游 Socket,TCP 字节流的顺序就被打乱了,不可恢复。入方向:数据已经从上游 Socket 读取出来,如果缓冲等待乱序,这些数据就离开了 TCP 的重传保护范围,VPN 进程一旦异常退出,数据就永久丢失。
缓冲区有溢出保护:超过 1024 个条目时,新包直接发出,不再缓冲。这防止了极端情况下内存无限增长。
管线还提供了两级 flush 机制:flushForSession(key) 在 TCP FIN 时释放特定会话的残余包,flush() 在代理关闭时释放所有缓冲包。被 flush 出来的包会继续走管线后续的操纵器处理(Duplicate、Loss、Tamper),不会跳过任何一步。
DuplicateManipulator -- 重复
重复操纵器是五个里面最简单的,按概率深拷贝就行:
kotlin
class DuplicateManipulator(
private val duplicatePercent: Int = 0,
) : PacketManipulator {
override fun manipulateOutgoing(packet: IpPacket): List<IpPacket> {
if (duplicatePercent <= 0) return listOf(packet)
return if (Random.nextInt(100) < duplicatePercent) {
listOf(packet, packet.copy())
} else {
listOf(packet)
}
}
// manipulateIncoming 逻辑完全一致
}
packet.copy() 是深拷贝,生成一个完全独立的 IpPacket 实例,包括底层 ByteArray 的复制。两个包在管线后续阶段会被独立处理------丢包操纵器可能只丢其中一个,篡改操纵器可能只改其中一个。
TCP 的重复处理有点特殊,不在管线里而在 PacketProcessor 中。管线返回多个包后,PacketProcessor 的出方向处理逻辑只把第一个包的 payload 写入上游 Socket,后续重复包的 payload 被跳过:
kotlin
var wroteData = false
for (p in manipulated) {
val dataToSend = p.payload
if (dataToSend.isEmpty()) continue
val ch = session.channel
// TCP 是流协议:重复包的数据不能重复写入上游
// ...
}
这是因为 TCP 是字节流协议。如果把同样的数据写两次到 SocketChannel,服务器会收到两份重复字节,导致应用层解析出错。入方向同理:重复的 TCP 数据包都会被写入 TUN 接口,但 TCP 协议栈本身会通过序列号去重,所以入方向不需要特殊处理。
PacketLossManipulator -- 丢包
丢包操纵器是五个里最复杂的,支持两种数学模型,对 TCP 还有特殊的处理路径。
RANDOM 模式(伯努利模型)
每个包独立地以 lossPercent 的概率被丢弃:
kotlin
LossModel.RANDOM -> Random.nextInt(100) < lossPercent
简单直接,适合模拟均匀的随机丢包。
BURST 模式(Gilbert 两状态马尔可夫链)
真实网络的丢包往往不是独立的,而是成串出现------好一会儿,然后突然丢一大片。Gilbert 模型用两个状态(GOOD 和 BAD)之间的切换来模拟这种行为:
kotlin
// 状态转移参数
r = 1.0 / 3.0 // BAD -> GOOD 的概率
p = r * lossPercent / (100.0 - lossPercent) // GOOD -> BAD 的概率
// 状态判定
synchronized(lock) {
val drop = inBadState // 坏状态丢包
if (inBadState) {
if (Random.nextDouble() < r) inBadState = false // 坏状态有机会转好
} else {
if (Random.nextDouble() < p) inBadState = true // 好状态有机会转坏
}
drop
}
r 固定为 1/3,意思是处于 BAD 状态时,每个包有 33% 的概率恢复到 GOOD。p 根据目标丢包率反推:丢包率越高,GOOD 到 BAD 的概率越大,进入突发丢包的频率越高。在 BAD 状态中,每个包都被丢弃(val drop = inBadState),形成丢包"风暴"。两状态切换自然产生"好一段、坏一段"的突发效果。
p 的推导公式保证长期丢包率等于 lossPercent。当 lossPercent = 10% 时,p = (1/3) * 10 / 90 = 0.037,即每个包有 3.7% 的概率从 GOOD 切换到 BAD,而一旦进入 BAD 就有 33% 的概率退出。稳态下大约 10% 的时间处于 BAD 状态,与目标丢包率一致。
TCP 丢包的特殊处理
TCP 丢包不能简单地走管线,这可能是整个操纵系统里最精巧的设计。
管线中,TCP 数据包在两个方向都被跳过(if (packet.ipHeader.isTcp) return listOf(packet))。TCP 出方向的丢包判定在 PacketProcessor 中完成,关键是在发送 ACK 之前:
kotlin
val tcpLost = pipeline.shouldDropTcpOutgoing()
if (!tcpLost) {
session.clientNextSeq = newSeqEnd
sendAck(session, newSeqEnd, ip, tcp)
}
outgoingExecutor.submit {
if (tcpLost || session.isClosed) return@submit
// ... 正常发送
}
shouldDropTcpOutgoing() 委托给 PacketLossManipulator.shouldDropForTcp(),内部使用和管线一样的丢包判定逻辑(RANDOM 或 BURST)。关键区别在于时机:如果判定丢包,就不发 ACK。App 的 TCP 协议栈收不到 ACK,会自然触发重传机制。这样丢掉的包不是真的消失了,而是让 TCP 自己重传一遍,模拟出"网络丢包导致重传"的真实效果。
TCP 入方向完全不丢包。数据已经从上游 Socket 读取出来,TCP 层的重传保护已经结束。如果在这里丢弃,数据就永久丢失了,App 永远收不到这段数据,连接会卡死直到超时。
TamperManipulator -- 篡改
篡改操纵器模拟网络传输中的数据损坏。做法是在 payload 区域随机选一个字节,用 XOR 操作翻转若干比特:
kotlin
private fun tamperPacket(packet: IpPacket): IpPacket {
val bytes = packet.rawBytes.copyOf()
val payloadStart = ipHeaderLen + transportHeaderLen
val payloadEnd = packet.ipHeader.totalLength
if (payloadEnd <= payloadStart) return packet
val tamperOffset = payloadStart + Random.nextInt(payloadEnd - payloadStart)
bytes[tamperOffset] = (bytes[tamperOffset].toInt() xor Random.nextInt(1, 256)).toByte()
updateChecksums(bytes, packet)
return packet.withRawBytes(bytes)
}
Random.nextInt(1, 256) 生成 1-255 的掩码,确保至少翻转一个比特。bytes.copyOf() 创建独立副本,不影响原始数据包。
篡改后必须重算校验和,否则接收端会直接丢弃。需要重算两层:
- IP 头校验和:覆盖 IP 头部 20 字节,用
ByteUtils.updateChecksum重算 - 传输层校验和:TCP/UDP 的校验和覆盖传输层头部 + payload + 伪首部(源 IP、目标 IP、协议号、长度)。必须先清零旧的校验和字段,再构造伪首部拼接传输层数据,最后计算整体校验和
TCP 双方向都跳过篡改,原因和乱序一样:代理已经 ACK 了出方向数据,篡改等于不可逆的损坏;入方向数据已经离开了上游 Socket 的保护,篡改也是永久性的。
TCP 处理总结
TCP 在 VPN 代理模型下的操控受到严格限制。一句话概括:代理模型中,TCP 数据在到达管线时已被确认,任何导致数据永久丢失或损坏的操控都必须跳过。
| 操控器 | TCP 出方向 | TCP 入方向 | UDP |
|---|---|---|---|
| Throttle | 正常节流 | 正常节流 | 正常节流 |
| Reorder | 跳过 | 跳过 | 正常乱序 |
| Duplicate | 生成副本 | 正常重复 | 正常重复 |
| Loss | pre-ACK 判定 | 跳过 | 正常丢包 |
| Tamper | 跳过 | 跳过 | 正常篡改 |
Throttle 对 TCP 不受限,因为限速只是控制发送节奏(sleep),不丢数据。Duplicate 对 TCP 也不受限,出方向管线会生成副本,但 PacketProcessor 只写第一个包的 payload 到上游;入方向重复包直接写入 TUN,由 TCP 协议栈通过序列号去重。Loss 的出方向走 pre-ACK 判定路径,入方向完全跳过。Reorder 和 Tamper 在 TCP 双方向都跳过。
这个表格背后的逻辑可以归纳为一条规则:操控能否被 TCP 的重传机制弥补?如果能(比如出方向丢包,TCP 会重传),就可以施加;如果不能(比如入方向丢包、双方向乱序和篡改),就必须跳过。
为什么延迟不在管线里
读到这里你可能注意到了,五个操纵器里没有"延迟"。管线里的每个操纵器都是同步处理------数据包进来,经过变换,立即返回。ThrottleManipulator 虽然调用了 Thread.sleep,但那是为了限速,不是为了模拟网络延迟。
延迟需要"先存起来、稍后再发"的异步模型,和管线的同步 flatMap 架构天然冲突。如果把延迟塞进管线,一个包需要等待,后续所有包的处理链都会被阻塞,多连接场景下延迟会互相叠加。
所以 WeakNet 把延迟放在了 PacketProcessor 的特定位置:SYN-ACK 延迟(模拟连接建立慢)、UDP 出方向延迟、UDP 入方向延迟、ICMP 延迟。TCP 数据流不做逐包延迟------每个包都延迟 200ms 意味着吞吐量会线性塌陷,TCP 窗口永远打不开。延迟模型(均匀分布、高斯分布、对数正态分布)的具体数学实现,下一篇展开讲。
管线给了我们 5 种操控能力,但抖动值从哪个分布中采样?丢包的概率怎么随时间变化?下一篇深入延迟和丢包背后的数学模型:Box-Muller 变换、Gilbert 两状态马尔可夫链、对数正态分布------从公式到代码,一个不落。