网络闪断 + DNS 故障:Android弱网模拟中最容易被忽视的两个场景

网络闪断 + DNS 故障:弱网模拟中最容易被忽视的两个场景

WeakNet 技术博客系列 | 第 7 篇


大多数弱网工具把精力花在延迟、丢包、限速这三件事上。这三个参数调好了,2G/3G/4G 弱信号的场景基本都能覆盖。但现实中有两类场景,仅靠延迟和丢包是模拟不出来的:周期性的网络闪断(进地铁隧道、进电梯、高铁经过偏远地区)和 DNS 故障(DNS 服务器不可达、返回错误、被公共 WiFi 重定向)。

你的 App 在进地铁隧道时崩过吗?在公共 WiFi 下 DNS 被重定向过吗?这两个场景在测试环境里几乎不会被覆盖,但在用户侧的发生频率远超想象。WeakNet 用两个独立的模块来处理它们------DisconnectSchedulerDnsResponder。这篇就来拆解它们的设计与实现。

网络闪断:DisconnectScheduler 的取模周期调度

设计思路

闪断的本质是周期性行为:每 N 秒断开一次,持续 M 秒。直觉上可以用 TimerHandler 来做------设一个定时器,到点了切换状态。但 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

  1. processPacket() :所有新数据包的入口,断开时解析后直接 return,不处理
  2. relayUpstream() :TCP 上游中继循环,断开时 continue 跳过读到的数据,但读循环本身不停
  3. handleUdp():出向 UDP,断开时丢弃
  4. handleUdpResponse():入向 UDP 响应,断开时丢弃
  5. handleIcmp():ICMP ping,断开时丢弃

这里有一个微妙的设计:relayUpstream() 里断开时用 continue 而不是 breakcontinue 跳过了写入 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?这是并发安全的要求。serverSeqrelayUpstream 线程维护,而 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 个踩坑实录》


项目地址github.com/baithinking...

相关推荐
Flynt1 小时前
Android 17内存限制:我是怎么发现App被系统悄悄干掉的
android·性能优化
Irissgwe1 小时前
8-1\IP 分片和组装的具体过程
linux·网络·tcp/ip·网络层·分片·组装
.小小陈.2 小时前
从零构建可用 TCP 服务:从基础 Socket 到自定义协议与序列化
服务器·网络·tcp/ip
消失的旧时光-19432 小时前
Kotlin 协程设计思想(七):为什么 Kotlin 要设计 SupervisorJob 和 supervisorScope?
android·开发语言·kotlin
故渊at2 小时前
第一板块:Android 系统基石与运行原理 | 第五篇:Context 上下文与资源配置体系
android·人工智能·opencv·context·上下文·资源配置体系
RXXW_Dor2 小时前
ModbusTcp通信C#WPF开发测试(基于Nmodbus4库应用)
服务器·网络·tcp/ip
故渊at3 小时前
第一板块:Android 系统基石与运行原理 | 第四篇:进程孵化(Zygote)与 Low Memory Killer 机制
android·虚拟机·zygote·系统启动·low memory·进程孵化
JohnnyDeng943 小时前
【Android】RecyclerView性能优化与缓存机制:从卡顿到丝滑的完整指南
android·性能优化·kotlin·mvvm
zfoo-framework3 小时前
kotlin中体会到一些比较好用的点
android·开发语言·kotlin