网络闪断 + 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...

相关推荐
Zhu7582 小时前
在k8s集群部署MySQL单实例,支持多个主流发行版
android·mysql·kubernetes
AFinalStone5 小时前
Android 7系统异常问题排查(四)Framework层(上)—System Server Watchdog
android·系统异常
宝杰X77 小时前
Android Room3 多平台数据库
android·数据库
huibin1478523699 小时前
热缓解学习记录
android·学习
小田的博客12 小时前
SAP MM 供应商银行主数据更新报错!message R1228!
android·java·服务器
一笑的小酒馆13 小时前
Android智能猫砂盆视频加载慢卡顿问题分析
android
March.s15 小时前
IP 存储核心 iSCSI:原理拆解 + 无报错实战指南
网络·网络协议·tcp/ip
只会cv的小前端15 小时前
七巧低代码服务端脚本使用方法
android·低代码·rxjava
15 小时前
Android 自定义 View 实战:从零打造工业级全向摇杆控件(OmniJoystickView)
android·kotlin·自定义view·摇杆控件
雨白15 小时前
深入理解 Kotlin 协程 (十一):以逸待劳,探秘 select 多路复用与并发安全策略
android·kotlin