Android 用户态实现 TCP 代理:从 SYN 到 FIN 的完整生命周期

在 Android 用户态实现 TCP 代理:从 SYN 到 FIN 的完整生命周期

WeakNet 技术博客系列 | 第 4 篇


这是整个系列最复杂的一篇,也是 WeakNet 最核心的部分。

前面几篇我们讲了 VpnService 怎么把流量导入用户态、怎么从原始字节解析出 IP/TCP/UDP 头部。这些都只是准备工作,真正的难题是:拿到 App 发出的 TCP 数据包之后,怎么办?

TCP 是一个有状态的、可靠的、字节流协议。它有连接建立(三次握手)、连接关闭(四次挥手)、序列号管理、超时重传、流量控制、拥塞控制等一整套复杂的机制。正常情况下,这些由操作系统内核的 TCP/IP 协议栈完成。而 WeakNet 要做的事情,是在用户态重新实现一个 TCP 端点------代理既是 App 的"服务器",又是真实服务器的"客户端"。你需要自己管理 seq/ack、自己处理 SYN 和 FIN、自己决定什么时候发 ACK、什么时候该让包"丢掉"。

说白了,这不是简单的代理转发------你在用户态实现了一个完整的 TCP 端点。

代理模型:WeakNet 是透明代理

理解 WeakNet 的 TCP 代理,最关键的一点是:代理进程维护了两条独立的 TCP 连接。

text 复制代码
App <-- TCP --> 代理 (虚拟端点) <-- TCP --> 真实服务器
    App 认为在跟服务器通信      代理建立真实连接
    实际上是跟代理通信         转发 App 的数据到服务器

App 发出的 TCP 包经过 TUN 接口到达 PacketProcessorPacketProcessor 解析 TCP 头部,根据四元组(srcIp:srcPort -> dstIp:dstPort)查找或创建一个 TcpSession。对于 App 来说,代理就是"服务器";对于真实服务器来说,代理就是"客户端"。两边各自维护独立的 seq/ack 序列号,互不感知。

这个模型意味着:

  • App 发 SYN -> 代理创建 TcpSession -> 代理模拟 SYN-ACK 给 App -> 代理在后台连接真实服务器
  • App 认为连接已经建立,开始发数据(比如 TLS ClientHello)
  • 真实连接可能还没建好,代理先把 App 的数据缓存起来
  • 真实连接建立后,代理把缓存的数据一次性发给服务器
  • 之后进入双向转发:App -> 代理 -> 服务器,服务器 -> 代理 -> App

这就是整个 TCP 代理的核心思路。

SYN 处理:连接建立的魔法

收到 App 发来的 SYN 包时,代理需要做三件事:创建会话、模拟 SYN-ACK、连接真实服务器。其中模拟 SYN-ACK 是关键------它让 App 以为连接已经建立,可以开始发数据了。

kotlin 复制代码
// 收到 SYN 包
if (tcp.isSyn && !tcp.isAck) {
    // 同一四元组已有未关闭会话,丢弃重复 SYN(避免状态机混乱)
    if (existing != null && !existing.isClosed) return

    val session = tcpSessionManager.createSession(
        ip.sourceAddress, tcp.sourcePort,
        ip.destinationAddress, tcp.destinationPort,
        tcp.sequenceNumber)

    // 随机初始 seq,模拟真实服务器的初始序列号
    session.serverSeq = Random.nextInt(100000, Int.MAX_VALUE)

    // 必须在后台线程:先给 app 发 SYN-ACK,再阻塞等真实连接
    connectExecutor.submit {
        // 1. SYN-ACK 也要经过延迟,模拟真实网络的握手 RTT
        val jitter = pipeline.calculateJitter(pipeline.jitterMs)
        val synDelay = (pipeline.delayMs + jitter).coerceAtLeast(0).toLong()
        if (synDelay > 0) Thread.sleep(synDelay)

        sendSynAck(session, ip, tcp)   // 内部已调用 addAndGetServerSeq(1),SYN 占 1 个 seq 号(RFC 793)

        // 2. 建立真实 TCP 连接(阻塞,5 秒超时)
        if (session.connectBlocking()) {
            // 3. 把握手期间 app 缓存的数据发给真实服务器
            val pendingWriteData = pendingData.remove(session.id)
            if (pendingWriteData != null) {
                synchronized(session.writeLock) {
                    val wbuf = ByteBuffer.wrap(pendingWriteData)
                    while (wbuf.hasRemaining()) {
                        session.channel?.write(wbuf)
                    }
                }
            }
            // 4. 进入 relay 循环(阻塞读取上游响应)
            relayUpstream(session)
        } else {
            // 连接失败:发 RST 让 app 端快速失败
            sendRstToSession(session)
            tcpSessionManager.removeSession(session)
        }
    }
    return
}

这里有几个关键设计决策值得展开聊。

第一个:为什么要模拟 SYN-ACK?如果等真实服务器返回 SYN-ACK 再转发给 App,握手延迟就是两次 RTT(App->代理->服务器->代理->App)。模拟 SYN-ACK 让 App 立刻认为连接已建立,可以更快开始发数据。真实服务器连接在后台并行建立。

第二个:SYN-ACK 为什么要延迟?在弱网模拟场景下,握手延迟本身就是要模拟的指标。如果 SYN-ACK 瞬间返回,App 的体验就是零延迟连接,失去了模拟意义。延迟时间 = delayMs + jitter,和弱网配置一致。

第三个:connectBlocking 为什么是 5 秒超时?如果目标服务器不可达(比如网络故障、服务器宕机),TCP connect 会阻塞很久。5 秒是一个合理的上限:超过这个时间 App 的用户体验已经很糟糕了,不如直接 RST 掉让应用层快速失败。

第四个:SYN 为什么占用 1 个 seq 号?RFC 793 规定,SYN 和 FIN 各占用一个序列号。这不是一个约定俗成的小事------如果你忘了给 SYN 加 1,后续所有数据的 seq 号都会偏移,App 的 TCP 栈会因为 seq 不在期望窗口内而拒绝接收所有数据包,连接直接废掉。

SYN-ACK 的构造也有细节。它不是一个简单的 20 字节 TCP 头------还带 TCP 选项:

kotlin 复制代码
private fun sendSynAck(session: TcpSession, ip: IpHeader, tcp: TcpHeader) {
    // TCP 头 = 20 基础 + 12 选项 = 32 字节
    val tcpHeaderLen = 32
    val pkt = ByteArray(20 + tcpHeaderLen)  // IP(20) + TCP(32)
    // ... IP 头 ...

    // TCP Options:
    // MSS=1460(告诉 app 最大分段大小)
    // NOP + Window Scale=4(窗口扩大因子,65535 << 4 ≈ 1MB)
    // Window=65535(告诉 app 可以发大量数据,不要等待)

    session.addAndGetServerSeq(1)  // SYN 占 1 个 seq
    updateTcpChecksum(pkt, off, tcpHeaderLen)
    writeToTun(pkt)
}

Window=65535 加上 Window Scale=4,告诉 App "我的接收窗口大约 1MB,你使劲发"。这是因为代理要尽可能快地接收 App 数据,避免 App 因为窗口限制而等待。真实服务器的窗口由操作系统的 TCP 栈自行管理。

TcpSession 状态机:CAS 原子保护

TcpSession 的状态机只有三个状态,但状态转换的并发安全是整个代理最微妙的环节之一,稍不留神就会出 bug。

text 复制代码
SYN_RECEIVED --(connectBlocking 成功, CAS)--> ESTABLISHED --(FIN/RST/错误, CAS)--> CLOSED

状态转换使用 AtomicReference + CAS(Compare-And-Set)保证原子性:

kotlin 复制代码
enum class TcpState {
    SYN_RECEIVED,
    ESTABLISHED,
    CLOSED,
}

class TcpSession(...) {
    private val _state = AtomicReference(TcpState.SYN_RECEIVED)

    fun connectBlocking(): Boolean {
        val ch = SocketChannel.open()
        ch.configureBlocking(true)

        // protect() 必须 succeed------失败会导致路由循环
        if (!vpnService.protect(ch.socket())) {
            ch.close()
            return false
        }

        ch.socket().connect(InetSocketAddress(dstAddr, destPort), 5000)

        // 先设 channel,再 CAS 状态------确保 close() 能看到并关闭此 channel
        _channel.set(ch)

        // CAS 防止 close() 已经关闭会话后复活
        if (!_state.compareAndSet(TcpState.SYN_RECEIVED, TcpState.ESTABLISHED)) {
            // close() 可能在 _channel.set(ch) 之前运行而漏关,显式关闭防 fd 泄漏
            try { ch.close() } catch (_: Exception) {}
            return false
        }
        return true
    }

    fun close() {
        // CAS 保证幂等:只有第一次调用执行关闭
        if (_state.getAndSet(TcpState.CLOSED) == TcpState.CLOSED) return
        try { _channel.getAndSet(null)?.close() } catch (_: Exception) {}
    }
}

为什么要 CAS 而不是 synchronized?因为 close() 可能从多个线程被调用:connectExecutor 的连接超时、outgoingExecutor 的写异常、relayUpstream 的读异常、cleanupExpired 的定时清理。CAS 保证只有第一个调用者执行实际的关闭操作,其余调用者直接返回。getAndSet(CLOSED) 是原子操作,天然幂等。

CAS 失败为什么要关闭 channel?这是一个 fd 泄漏的经典场景。考虑这个时序:

text 复制代码
1. connectExecutor: ch = SocketChannel.open()     // 打开 channel
2. cleanupExpired:  close()                        // CAS -> CLOSED, _channel 还是 null, 无 channel 可关
3. connectExecutor: _channel.set(ch)               // 设了 channel,但状态已经是 CLOSED
4. connectExecutor: CAS(SYN_RECEIVED, ESTABLISHED) // 失败!
5. 此时 ch 已设入 _channel 但没人关闭它 ------ fd 泄漏

解决方法:CAS 失败后显式关闭 ch。这是 connectBlocking 里最不起眼但最关键的一行代码。

出方向数据处理:pre-ACK 丢包设计

这是整个 TCP 代理中最精巧的设计,也是最容易做错的地方。

当 App 发来一个 TCP 数据包时,代理需要做两件事:给 App 回一个 ACK("我收到了"),然后把数据转发给真实服务器。问题来了:如果弱网配置要求丢包 10%,这个包该不该丢?

传统做法:先 ACK,再让管线决定丢不丢。问题是被"丢"的数据已经 ACK 过了------App 认为对方已经收到,不会重传。数据永久丢失。

WeakNet 的做法:在 ACK 之前就决定丢不丢。被"丢"的包不发 ACK,App 的 TCP 栈会认为包在网络中丢失了,自动重传。这才是真实网络中丢包的行为。

kotlin 复制代码
// TCP 丢包模拟:在 ACK 之前决定
val tcpLost = pipeline.shouldDropTcpOutgoing()

if (!tcpLost) {
    // 没被"丢"------正常 ACK,告诉 app "我收到了"
    session.clientNextSeq = newSeqEnd
    sendAck(session, newSeqEnd, ip, tcp)
}

outgoingExecutor.submit {
    if (tcpLost || session.isClosed) return@submit

    val manipulated = pipeline.processOutgoing(packet)
    // 写到上游 SocketChannel...
}

这个设计的核心洞察是:ACK 是对 App TCP 栈的承诺。发了 ACK 就意味着"数据已收到",不发 ACK 就意味着"数据丢了,请重传"。利用这个语义,我们不需要自己实现重传------App 的 TCP 栈会帮我们搞定。

另一个关键细节是重传检测。App 的 TCP 栈重传时,seq 号和之前一样。我们需要识别这些重传包,避免重复写入上游:

kotlin 复制代码
// 重传检测:使用无符号 32 位比较,防止 seq 回绕时误判
val newSeqEnd = tcp.sequenceNumber + payloadSize
if ((newSeqEnd.toLong() and 0xFFFFFFFFL) <=
    (session.clientNextSeq.toLong() and 0xFFFFFFFFL)) return

Kotlin 的 Int 是有符号的,TCP 的 seq 号是无符号 32 位整数。当 seq 号超过 Int.MAX_VALUE 时会回绕到负数。如果用有符号比较,回绕后的 seq 会比之前的 seq "小",导致正常数据包被误判为重传。转为 Long 再做 and 0xFFFFFFFFL 就是无符号比较。

ACK 必须在管线处理之前发送。管线可能有延迟(限速、乱序),如果等管线处理完再发 ACK,App 的 TCP 栈会一直等到超时才收到确认,导致不必要的重传和性能下降。

TCP 数据不做逐包延迟。UDP 和 ICMP 的延迟实现是每个包独立 sleep。但 TCP 是字节流,如果每个包都加延迟,延迟会堆叠------10 个包就是 10 倍延迟。TCP 的延迟只施加在 SYN-ACK(模拟握手 RTT)上,数据流不加。

还有 pendingData 缓存。SYN-ACK 发出后,App 会立刻开始发数据(比如 TLS ClientHello)。但此时真实连接可能还没建好。这些数据需要缓存起来,等 connectBlocking() 成功后一次性写入:

kotlin 复制代码
// channel 未就绪时,缓冲等待连接建立
pendingData.compute(session.id) { _, existing ->
    val totalSize = (existing?.size ?: 0) + dataToSend.size
    if (totalSize > MAX_PENDING_PER_SESSION) {
        return@compute existing  // 超过 1MB 限制,丢弃新数据
    }
    if (existing != null) {
        val combined = ByteArray(totalSize)
        System.arraycopy(existing, 0, combined, 0, existing.size)
        System.arraycopy(dataToSend, 0, combined, existing.size, dataToSend.size)
        combined
    } else dataToSend.copyOf()
}

ConcurrentHashMap.compute() 是原子操作,保证多线程追加数据时不会丢失。缓存上限 1MB------如果真实连接建了 5 秒还没好,App 发了超过 1MB 数据,说明连接大概率建不起来了,丢弃新数据等 RST 就好。

入方向数据中继:服务器到 App

relayUpstream() 是连接线程的阻塞循环。它从真实服务器的 SocketChannel 读取数据,构造 TCP 数据包,经过入方向管线处理后写入 TUN 接口送达 App。

kotlin 复制代码
private fun relayUpstream(session: TcpSession) {
    // 1460 = MTU(1500) - IP头(20) - TCP头(20):每次读取不超过一个 MTU
    val buf = ByteBuffer.allocate(VpnConfig.MTU - 40)
    val ch = session.channel ?: return

    while (!session.isClosed && ch.isOpen) {
        buf.clear()
        val bytesRead = ch.read(buf)   // 阻塞读上游
        if (bytesRead < 0) break       // 连接关闭
        if (bytesRead == 0) {
            Thread.sleep(1)
            continue
        }

        buf.flip()
        val data = ByteArray(bytesRead)
        buf.get(data)

        val responsePacket = buildTcpDataPacket(session, data, bytesRead)
        val manipulated = pipeline.processIncoming(responsePacket)

        for (p in manipulated) {
            if (session.isClosed) break
            writeToTun(p.rawBytes)     // 写入 TUN -> 送达 app
        }

        // 按实际字节数递增:TCP seq 是字节流偏移量,不是包序号
        session.addAndGetServerSeq(bytesRead)
    }

    // relay 结束 -> 发 FIN 通知 app
    if (!session.isClosed) {
        sendFinToApp(session)
        tcpSessionManager.removeSession(session)
    }
}

为什么读取粒度是 MTU-40?每次读取的数据要构造成一个 IP 包写入 TUN。如果一次读太多,构造出的 IP 包超过 MTU(1500 字节),需要分片。限制每次读取 MTU-40=1460 字节,构造出的 IP 包刚好 20+20+1460=1500 字节,不需要分片。

入方向数据不会被丢弃。这和出方向的 pre-ACK 丢包设计不同。入方向数据已经从真实服务器的 socket 读取出来了------如果丢弃,数据就永久丢失了(服务器不会重传,因为它认为你已经收到了)。入方向管线可以对数据做延迟、重复、篡改,但不能丢包。

serverSeq 按字节数递增,不是按包递增。这是最常见的错误。TCP 的 seq 号是字节流的偏移量,不是包的序号。读了 1460 字节,seq 就加 1460,不是加 1。如果你写成 serverSeq++,后续所有包的 seq 号都会错乱,App 的 TCP 栈会认为数据损坏。

FIN/RST 处理:连接关闭

RST 处理最简单:直接清理会话,不走操控管线。RST 是"异常断开",应该尽快生效,被延迟反而会导致问题。

kotlin 复制代码
if (tcp.isRst) {
    tcpSessionManager.removeSession(session)
    return
}

FIN 处理要复杂一些。FIN 可能携带最后一批数据(比如 HTTP 响应的最后一截),而且要保证 FIN 在所有排队数据之后处理。所以 FIN 整个提交到 outgoingExecutor

kotlin 复制代码
if (tcp.isFin) {
    outgoingExecutor.submit {
        // 1. 写 FIN payload(如果有)
        // 2. clientNextSeq 递增(FIN 也占 1 个 seq 号)
        session.clientNextSeq = tcp.sequenceNumber + finPayloadSize + 1
        // 3. 释放管线中此 session 的残余包
        flushPipelineForSession(sessionKey)
        // 4. 先 close 停止 relayUpstream 线程
        session.close()
        // 5. 发 FIN-ACK 给 app
        sendFinAck(session, ip, tcp)
        tcpSessionManager.removeSession(session)
    }
    return
}

关闭顺序很重要:先 close() 停止 relayUpstream 线程(避免 serverSeq 竞争),再发 FIN-ACK。如果反过来,relay 线程可能在 FIN-ACK 构造过程中修改 serverSeq,导致 FIN-ACK 的 seq 号不正确。

踩过的坑

这几个问题每一个都花了不少时间定位,有些甚至花了一两天。

seq 号是字节级不是包级。这是最容易犯的错误。serverSeq += bytesRead 不是 serverSeq++。TCP 的 seq 号是字节流的字节偏移量。每一个字节数据占用一个 seq 号,SYN 和 FIN 也各占用一个。搞错了整个连接的 seq 号全乱,App 的 TCP 栈会拒绝所有数据。

RST 必须符合 RFC 793。构造 RST 包时,seq 必须在接收方的窗口范围内,否则接收方会直接丢弃 RST(RFC 793 规定)。这意味着 RST 的 seq 应该等于对方期望的下一个 seq(即 serverSeq),ack 应该确认对方已发的数据(即 clientNextSeq)。此外,当 ackNumber 不为零时,RST 必须携带 ACK 标志------否则某些 TCP 实现(包括 Android)会丢弃这个 RST。

kotlin 复制代码
private fun sendRstToSession(session: TcpSession, serverSeqSnapshot: Int) {
    // seq 必须在 app 的接收窗口内
    ByteUtils.intToByteArray(serverSeqSnapshot).copyInto(pkt, off + 4)
    // ack 确认 app 已发的全部数据
    ByteUtils.intToByteArray(session.clientNextSeq).copyInto(pkt, off + 8)
    pkt[off + 13] = (TcpHeader.FLAG_RST or TcpHeader.FLAG_ACK).toByte()
}

CAS 失败要关闭 channel。前面分析过的 fd 泄漏场景。connectBlocking() 中 CAS 失败意味着 close() 已经先一步执行了,但 channel 可能是在 close() 之后才创建的。必须显式关闭,否则文件描述符泄漏,长期运行后进程耗尽 fd 崩溃。

TCP 不做逐包延迟。TCP 是流协议,包和包之间有先后关系。如果每个包都加 100ms 延迟,10 个包就是 1 秒------不是"网络延迟 100ms",而是"每个包都晚到 100ms,总共晚到 1 秒"。TCP 的延迟只施加在连接建立(SYN-ACK)上,数据流通过限速(Throttle)控制速率,而不是逐包 sleep。

下一步

TCP 代理搞定了------从 SYN 握手到数据双向转发再到 FIN 关闭,整个生命周期都在用户态完成。但数据包到目前为止只是被原样转发,还没有被真正"操控"。

下一篇:数据包操控管线。5 种弱网效果(限速、乱序、重复、丢包、篡改)怎么用一个 flatMap 式管线串联起来,每个操纵器怎么实现 1 -> N 的包变换。


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

相关推荐
Geek_Vison1 小时前
技术实践:保险健康APP引入第三方小程序实战,如何构建一个安全可控的沙箱环境~
android·安全·小程序·uni-app·mpaas
2501_915918412 小时前
Python如何抓取HTTPS请求包的完整教程与代码示例
android·ios·小程序·https·uni-app·iphone·webview
. . . . .2 小时前
android开发
android
程序员看世界2 小时前
Kotlin协程是如何实现优先级机制的
android·kotlin
Carson带你学Android2 小时前
Kotlin放大招!官方 Skills 直接喂出「专家级」代码
android·前端·kotlin
Coffeeee2 小时前
一个kotlin的Smart cast导致的编译问题
android·前端·kotlin
plainGeekDev2 小时前
XML 布局 → Compose 声明式 UI
android·java·kotlin
杊页2 小时前
系列一:架构思想进阶 | 第2篇 分层架构实战:四层拆分、单向依赖与架构防腐
android
weiggle3 小时前
第四篇:布局系统——从 Row、Column 到 Box 的声明式布局思维
android