网络闪断 + DNS 故障:弱网模拟中最容易被忽视的两个场景
WeakNet 技术博客系列 | 第 7 篇
大多数弱网工具把精力花在延迟、丢包、限速这三件事上。这三个参数调好了,2G/3G/4G 弱信号的场景基本都能覆盖。但现实中有两类场景,仅靠延迟和丢包是模拟不出来的:周期性的网络闪断(进地铁隧道、进电梯、高铁经过偏远地区)和 DNS 故障(DNS 服务器不可达、返回错误、被公共 WiFi 重定向)。
你的 App 在进地铁隧道时崩过吗?在公共 WiFi 下 DNS 被重定向过吗?这两个场景在测试环境里几乎不会被覆盖,但在用户侧的发生频率远超想象。WeakNet 用两个独立的模块来处理它们------DisconnectScheduler 和 DnsResponder。这篇就来拆解它们的设计与实现。
网络闪断:DisconnectScheduler 的取模周期调度
设计思路
闪断的本质是周期性行为:每 N 秒断开一次,持续 M 秒。直觉上可以用 Timer 或 Handler 来做------设一个定时器,到点了切换状态。但 Timer 需要管理 TimerTask 的取消,Handler 需要绑定 Looper,生命周期管理很繁琐。对于这种简单的周期性开关行为,有更优雅的方案。
WeakNet 选择了取模调度。思路极其简单:记录一个起始时间戳,每次被外部驱动时计算当前经过的时间对周期取模,根据模值判断应该处于连接还是断开状态。没有 Timer,没有 Handler,没有任何回调注册。调用方以固定频率调用 tick(),调度器自己决定状态切换。
取模调度原理
以一个 30 秒周期、每次断开 2 秒为例:
text
时间轴(30s 周期示例):
|-- 28s 连接正常 --|-- 2s 闪断 --|
0 phase=28000 30000
^ ^
断开开始 周期重置,恢复连接
每个 tick() 调用中,计算 elapsed % intervalMs 得到当前在周期内的位置 phase。当 phase >= intervalMs - durationMs 时,说明进入了周期末尾的断开窗口。状态切换由 isDisconnected 标记控制,只有当这个标记从断开变为连接时才返回 JUST_RECONNECTED------这是一个"事件",通知调用方需要做 RST 清理。从连接变为断开时只返回 DISCONNECTED(普通状态),因为断开不需要特殊处理,五道关卡直接过滤流量就行。
完整实现不到 40 行:
kotlin
class DisconnectScheduler(
private val intervalMs: Long, // 周期,如 30000ms
private val durationMs: Long, // 每次断开时长,如 2000ms
) {
enum class State { CONNECTED, DISCONNECTED, JUST_RECONNECTED }
@Volatile
var isDisconnected: Boolean = false
private set
private val startNanos: Long = System.nanoTime()
fun tick(): State {
val elapsed = (System.nanoTime() - startNanos) / 1_000_000L
val phase = elapsed % intervalMs
val shouldDisconnect = phase >= intervalMs - durationMs
if (shouldDisconnect == isDisconnected) {
return if (isDisconnected) State.DISCONNECTED else State.CONNECTED
}
isDisconnected = shouldDisconnect
return if (shouldDisconnect) State.DISCONNECTED else State.JUST_RECONNECTED
}
}
注意 @Volatile 修饰了 isDisconnected------这个字段会被多个线程读取(TUN 读取线程、TCP 中继线程、UDP 线程),必须保证可见性。而写入只发生在 tick() 内部,由单线程驱动,不存在竞争。
驱动方是 VpnThread 中的一个协程,以 50ms 精度 驱动 tick:
kotlin
val disconnectJob = coroutineScope.launch {
while (isActive && !stopped) {
packetProcessor.handleDisconnectTick()
delay(50)
}
}
50ms 的 tick 精度意味着闪断的时间误差在正负 50ms 以内。对于最短 2 秒的断开时长来说,这个精度完全足够。
闪断守卫------五道关卡
断开状态下,所有流量必须被拦住。WeakNet 在 PacketProcessor 的五个关键路径上检查 isDisconnected:
- processPacket() :所有新数据包的入口,断开时解析后直接
return,不处理 - relayUpstream() :TCP 上游中继循环,断开时
continue跳过读到的数据,但读循环本身不停 - handleUdp():出向 UDP,断开时丢弃
- handleUdpResponse():入向 UDP 响应,断开时丢弃
- handleIcmp():ICMP ping,断开时丢弃
这里有一个微妙的设计:relayUpstream() 里断开时用 continue 而不是 break。continue 跳过了写入 TUN 和 serverSeq 递增,所以断开期间从上游读到的数据被静默丢弃------数据已经从内核 socket 缓冲区中取出,无法恢复。这是预期行为:真实断网时,应用层的数据同样会丢失。断开结束后,read() 返回的是服务器新发的数据,而非断开期间被丢弃的数据。用 break 的话 relay 循环就彻底退出了,连接直接永久断掉。
恢复连接时的 RST 清理
这是整个闪断机制里最关键的细节。网络恢复时,App 端的 TCP 连接其实已经废了------服务器端可能已经因为超时关闭了连接,但 App 还不知道(断开期间代理丢弃了所有出方向数据包且没有发 ACK,App 的 TCP 栈会不断重传,但 App 层面可能还没意识到连接已断)。不做处理的话,App 会一直等到自己的 TCP 超时(可能是几分钟)才发现连接断了。
解决方案是在恢复连接时,给 App 的所有活跃 TCP 会话发 RST 包,强制立即清理:
kotlin
fun handleDisconnectTick() {
val scheduler = disconnectScheduler ?: return
when (scheduler.tick()) {
DisconnectScheduler.State.JUST_RECONNECTED -> {
// 先捕获每个 session 的 seq 快照,再 close
val sessions = tcpSessionManager.allSessions()
val seqSnapshots = sessions.associateWith { it.serverSeq }
for (session in sessions) {
if (!session.isClosed) {
session.close()
}
}
for (session in sessions) {
sendRstToSession(session, seqSnapshots[session] ?: 0)
tcpSessionManager.removeSession(session)
}
}
DisconnectScheduler.State.CONNECTED,
DisconnectScheduler.State.DISCONNECTED -> { /* no action */ }
}
}
为什么必须先快照 seq 再 close?这是并发安全的要求。serverSeq 由 relayUpstream 线程维护,而 close() 会停止 relayUpstream 线程。如果在 close() 之后再去读 serverSeq,可能读到被并发修改的值。更关键的是,RFC 793 要求 RST 的 seq 号必须在接收方的窗口范围内,否则接收方会静默丢弃 RST。快照保证了 RST 的 seq 是 close 之前最后一次有效的值,App 的 TCP 栈一定会接受这个 RST。
那为什么必须发 RST?不发 RST 的后果很严重。断开期间,代理的 relayUpstream 线程还在运行(只是 continue 跳过了数据),App 以为连接正常。网络恢复后,服务器端的连接可能已经因为超时被销毁,App 再发数据过去只会收到服务器的新 RST,但 App 的 TCP 栈可能已经处于混乱状态。RST 让 App 立刻知道"连接已死",触发应用层的重连逻辑------设计良好的 App,重连通常几百毫秒就能搞定。
DNS 故障模拟:DnsResponder 的协议构造
为什么 DNS 故障值得专门搞一个模块
DNS 是移动网络中最脆弱的一环。原因很简单:DNS 是 UDP 协议,没有重传机制;DNS 查询通常只发一次,丢了就等超时;DNS 服务器的可用性直接影响所有 HTTP 请求。真实场景中 DNS 故障主要有三种表现:
- DNS 服务器不可达:网络本身没问题,但 DNS 服务器宕了。App 发出查询后等 2~30 秒才超时
- DNS 服务器返回错误:服务器收到查询了,但内部出错,返回 SERVFAIL。App 立刻知道解析失败
- DNS 被重定向:公共 WiFi 场景下,DNS 查询被截获,返回一个广告页或钓鱼站的 IP。App 以为解析成功,实际连到了错误的服务器
拦截点
DNS 查询本质上就是目标端口为 53 的 UDP 包,所以拦截点很好找。WeakNet 在 handleUdp() 中截获:
kotlin
// 在 handleUdp() 中
if (udp.destinationPort == 53 && dnsFaultType != DnsFaultType.NONE) {
if (handleDnsFault(packet.payload, session)) return
}
handleDnsFault 根据故障类型决定行为:
kotlin
private fun handleDnsFault(
query: ByteArray,
session: UdpSessionManager.UdpSession,
): Boolean {
val dnsResponse = when (dnsFaultType) {
DnsFaultType.TIMEOUT -> return true // 丢弃查询,让 app 等待超时
DnsFaultType.FAILURE -> DnsResponder.buildErrorResponse(query, DnsResponder.RCODE_SERVFAIL)
DnsFaultType.HIJACK -> DnsResponder.buildHijackResponse(query, dnsHijackIp)
DnsFaultType.NONE -> return false
}
if (dnsResponse != null) {
val responsePacket = buildUdpResponsePacket(session, dnsResponse, dnsResponse.size)
udpExecutor.submit {
writeToTunFragmented(responsePacket.rawBytes)
}
}
return true
}
三种模式的对比:
| 模式 | 行为 | 真实场景 | App 感知 |
|---|---|---|---|
| TIMEOUT | 静默丢弃 DNS 查询,什么都不返回 | DNS 服务器不可达 | 等 2~30 秒超时后报错 |
| FAILURE | 构造 SERVFAIL 响应写回 TUN | DNS 服务器故障 | 立刻收到解析失败 |
| HIJACK | 构造伪造 A 记录响应写回 TUN | 公共 WiFi DNS 重定向 | 以为解析成功,连到错误 IP |
TIMEOUT 模式最简单也最狠------什么都不做,直接 return true。App 的 DNS 解析器会按照自己的超时策略等待(通常是 2 秒、4 秒、8 秒的指数退避),最终抛出 UnknownHostException。测 App 的 DNS 缓存策略和超时重试逻辑时特别有用。
FAILURE 和 HIJACK 模式则需要构造真实的 DNS 响应包。这就需要理解 DNS 报文格式。
DnsResponder -- 手工构造 DNS 响应
DNS 报文由 Header(12 字节)、Question Section、Answer Section 组成。DnsResponder 需要做三件事:从查询中提取 Transaction ID 和 Question Section,构造响应 Header,组装完整的响应包。
FAILURE 模式------构造 SERVFAIL 响应:
kotlin
fun buildErrorResponse(queryPayload: ByteArray, rcode: Int): ByteArray? {
val querySectionEnd = parseQuerySectionEnd(queryPayload) ?: return null
val querySection = queryPayload.copyOfRange(DNS_HEADER_SIZE, querySectionEnd)
val response = ByteArray(DNS_HEADER_SIZE + querySection.size)
copyTransactionId(queryPayload, response)
response[2] = (FLAG_QR or 0x01).toByte() // QR=1(response), RD=1
response[3] = (0x80 or (rcode and 0x0F)).toByte() // RA=1, RCODE=SERVFAIL
response[4] = queryPayload[4] // QDCOUNT
response[5] = queryPayload[5]
System.arraycopy(querySection, 0, response, DNS_HEADER_SIZE, querySection.size)
return response
}
几个关键细节:Transaction ID(前 2 字节)必须从查询中复制,App 用它来匹配查询和响应;QR 位设为 1 表示这是响应包;RD 位(Recursion Desired)和 RA 位(Recursion Available)都设为 1,模拟一个正常的递归 DNS 服务器;RCODE 设为 2 即 SERVFAIL。Question Section 原样复制回响应,这是 DNS 协议的要求。
HIJACK 模式------构造伪造的 A 记录响应:
kotlin
fun buildHijackResponse(queryPayload: ByteArray, hijackIp: String): ByteArray? {
// ... 解析查询部分 ...
// AAAA 查询不重定向:返回空 answer(无记录),避免 IPv6 泄漏
if (qtype == DNS_TYPE_AAAA) {
return buildErrorResponse(queryPayload, 0)
}
// Answer section: name pointer + TYPE A + CLASS IN + TTL + RDLENGTH + IP
val answerSize = 16 // 2+2+2+4+2+4 = 16
val response = ByteArray(DNS_HEADER_SIZE + querySection.size + answerSize)
// ... header ...
response[6] = 0; response[7] = 1 // ANCOUNT=1,一条 Answer
// Answer 部分
val a = DNS_HEADER_SIZE + querySection.size
response[a] = 0xC0.toByte(); response[a + 1] = DNS_HEADER_SIZE.toByte() // name 指针压缩
response[a + 2] = 0; response[a + 3] = 1 // TYPE A
response[a + 4] = 0; response[a + 5] = 1 // CLASS IN
response[a + 6] = 0; response[a + 7] = 0
response[a + 8] = 0; response[a + 9] = 60 // TTL=60s
response[a + 10] = 0; response[a + 11] = 4 // RDLENGTH=4
System.arraycopy(ipBytes, 0, response, a + 12, 4)
return response
}
这里有个容易忽略的细节:AAAA 查询(IPv6)的处理。如果 App 发出的是 AAAA 查询(请求 IPv6 地址),我们不构造伪造的 AAAA 记录------因为 hijackIp 是一个 IPv4 地址,格式不匹配。正确的做法是返回一个空 answer(RCODE=0,ANCOUNT=0),让 App 只拿到 A 记录的结果。强行把 IPv4 地址填进 AAAA 记录的话,App 的网络栈会因为地址格式错误而产生难以预料的行为。
另一个细节是 DNS 名称指针压缩(0xC0 + offset)。DNS 协议允许 Answer 部分的域名用指针指回 Question 部分的域名,避免重复编码。0xC0, 0x0C 表示"从第 12 字节开始的位置读取域名"------正好是 Question Section 的起始位置。这不是可选的优化,很多 DNS 解析器就期望这种行为。
parseQuerySectionEnd -- 解析 DNS 名称编码
构造响应的前提是定位 Question Section 的结尾,才能知道从哪里开始写 Answer。DNS 的名称编码不是常见的字符串格式,而是 label-length prefixed:每个 label 前面有一个字节表示长度,以 0 字节结束。还可能遇到指针压缩(0xC0 开头的 2 字节指针)。
kotlin
private fun parseQuerySectionEnd(query: ByteArray): Int? {
if (query.size < DNS_HEADER_SIZE) return null
if ((query[2].toInt() and FLAG_QR) != 0) return null // 不是查询包
val qdcount = ((query[4].toInt() and 0xFF) shl 8) or (query[5].toInt() and 0xFF)
var offset = DNS_HEADER_SIZE
repeat(qdcount) {
while (offset < query.size) {
val b = query[offset].toInt() and 0xFF
offset++
if (b == 0) break // 名称结束
if ((b and 0xC0) == 0xC0) { // 指针压缩
offset++
break
}
if (offset + b > query.size) return null // 越界,畸形包
offset += b
}
if (offset + 4 > query.size) return null // QTYPE + QCLASS
offset += 4
}
return if (offset <= query.size) offset else null
}
这个解析器虽然不长,但涵盖了 DNS 名称编码的所有情况:普通 label、零终止符、指针压缩、越界防御。遇到畸形查询包直接返回 null,上层跳过处理,不会崩溃。
预设场景分析
三个使用闪断的预设,参数对比:
| 预设 | 周期 | 断开时长 | 延迟 | 丢包 | 设计依据 |
|---|---|---|---|---|---|
| 地铁 | 30s | 2s | 200ms | 8% | 地铁每 30s 左右进一次隧道,每次约 2s 完全无信号 |
| 电梯 | 20s | 3s | 500ms | 15% | 金属屏蔽效应更强,断网更频繁、更持久 |
| 高铁 | 60s | 4s | 150ms | 5% | 经过偏远地区时断网,间隔长但每次断开也更久 |
注意电梯场景的参数是最极端的:周期最短(20s)、断开最长(3s)、丢包最高(15%)。电梯的金属箱体对射频信号的屏蔽效应非常强,远超地铁隧道的混凝土结构。
DNS 故障只有一个预设(DNS 超时),但三种故障类型都可以在自定义 Profile 中配置。实际测试中我建议先用 TIMEOUT 模式测 App 的超时处理,再用 FAILURE 测错误回调,最后用 HIJACK 测 HTTPS 证书校验------如果 App 在 HIJACK 模式下没有弹出证书错误,那大概率没做证书校验,这可是安全隐患。
小结
网络闪断和 DNS 故障是两个经常被忽视但杀伤力极大的场景。DisconnectScheduler 用取模调度实现了极简的周期性闪断,配合五道守卫关卡和 RST 清理机制,确保断开期间流量被完全阻止、恢复时连接被干净地重置。DnsResponder 从零构造 DNS 响应包,覆盖了超时、失败、重定向三种故障模式,每个细节都考虑了 DNS 协议规范(Transaction ID 匹配、名称指针压缩、AAAA 查询特殊处理)。
两个模块加起来不到 200 行代码,但覆盖了弱网测试中最容易被遗漏的两个死角。
下一篇是系列的最后一篇,聊 WeakNet 的线程模型和踩坑总结------5 个线程池的设计、6 种同步机制、以及开发过程中踩过的 8 个坑。
下一篇预告:《线程模型与避坑总结 -- 5 个线程池、6 种同步机制、8 个踩坑实录》