还记得大概在一年前写下的 NodeJS 实现 http client proxy 请求转发,在博客结束时挖了一个坑说要在 IOS 中实现一个 HTTP Proxy 反代的,到现在还没填上呢。今年年初在做 懒猫微服 通过 wifi 局域网扫描设备功能的时候发现,在 IOS 中 network extension 中向网关发送 TCP 数据时好时坏的,于是同事建议在宿主进程中监听一个 127.0.0.1 回环地址上的端口做一个 TCP 转发,将数据通过端口转发到网关便于提高服务的稳定以及可靠性(其实是因为 Apple 尽搞抽象的网络表现导致有时候根本用不了才不得不用这种方式)。

实现 EchoServer

按照 国际惯例 首先我们当然是先尝试实现一个 EchoServer 来帮助我们理解一下 CocoaAsyncSocket 的 API。

import CocoaAsyncSocket

public class TCPEchoServer: NSObject, GCDAsyncSocketDelegate {
    let ECHO_MSG = 1
    var localListenSocket: GCDAsyncSocket!
    var clientSocket: GCDAsyncSocket!

    // 获取本地监听地址
    public var localAddress: String {
        "\(localListenSocket.localHost ?? "127.0.0.1"):\(localListenSocket.localPort)"
    }

    override public init() {
        super.init()
        localListenSocket = GCDAsyncSocket(delegate: self, delegateQueue: .main)
    }

    deinit {
        localListenSocket.disconnect() // 关闭本地端口监听
    }

    public func start(to localHost: String, localPort: UInt16) throws {
        if localHost == "0.0.0.0" {
            try localListenSocket.accept(onPort: localPort)
        } else {
            try localListenSocket.accept(onInterface: localHost, port: localPort)
        }
    }

    public func socket(_: GCDAsyncSocket, didRead data: Data, withTag _: Int) {
        print("socket didRead \(String(data: data, encoding: .utf8) ?? "nil")")
        clientSocket.write("[echo] ".data(using: .ascii), withTimeout: -1, tag: 0)
        clientSocket.write(data, withTimeout: -1, tag: ECHO_MSG)
    }

    public func socket(_: GCDAsyncSocket, didWriteDataWithTag tag: Int) {
        // print("socket didWriteDataWithTag \(tag)")
        if tag == ECHO_MSG {
            clientSocket.readData(withTimeout: -1, tag: tag)
        }
    }

    public func socket(_: GCDAsyncSocket, didAcceptNewSocket: GCDAsyncSocket) {
        didAcceptNewSocket.setDelegate(self, delegateQueue: .global())
        clientSocket = didAcceptNewSocket
        clientSocket.readData(withTimeout: -1, tag: 0)
        print("socket didAcceptNewSocket")
    }
}

在上列代码中,我们定义了一个 TCPEchoServer 类并继承于 GCDAsyncSocketDelegate 委托,构造函数中初始化了一个用于本地监听的 GCDAsyncSocket 对象实例,在 TCPEchoServer 类中定义了一个 start 函数,start 函数需要接收一个 host 和 port 参数,通过传入的地址和端口使本地 GCDAsyncSocket 接受以及监听来自指定端口的数据。

在 didAcceptNewSocket 委托回调函数中,获取客户端 GCDAsyncSocket 实例设置 Delegate 并保存至 clientSocket 变量中, 当客户端连接成功后调用 clientSocket 的 readData 去读取数据,当收到客户端发送的数据后将会在委托回调函数 didRead 中获取到 Data 数据,随后输出并调用 clientSocket.write 将数据发送回复给客户端中,如此则实现了一个简单的 EchoServer 服务。

接下来我们利用 swift-argument-parser 去读取终端参数并启动一个命令行应用程序来运行我们的 EchoServer 服务试试吧。

import ArgumentParser
import CocoaAsyncSocket
import Foundation
import Network

public class TCPEchoServer: NSObject, GCDAsyncSocketDelegate {
    ……
}

@main
struct cli: ParsableCommand {
    @Option var input: String = "Hello bin, Welcome to use TCPForwarder"
    @Option var local: String
    // @Option var remote: String

    mutating func run() throws {
        // 判断传入地址是否正确
        let _localAddr = try IPv4SocketAddress(local)

        let serve = TCPEchoServer()

        try serve.start(to: _localAddr.host, localPort: _localAddr.port)

        // IPv4Address.any.debugDescription
        print("[CLI] \(input), server listen in \(serve.localAddress)")
        RunLoop.main.run()
    }
}

struct IPv4SocketAddress {
    var host: String
    var port: UInt16

    init(_ address: String) throws {
        let _subs = address.components(separatedBy: ":")
        if _subs.count < 2 {
            throw NSError(domain: "\(address) address is wrong", code: -1)
        }
        guard let _port = UInt16(argument: _subs[1]), _port > 0, _port < 65535 else {
            throw NSError(domain: "\(_subs[1]) port is wrong", code: -1)
        }
        host = _subs[0]
        port = _port
    }
}

完整代码我已经提交到 PBK-B/ios-sample-project/echo-server-cli ,现在我们可以在终端启动我们的 EchoServer 服务并尝试使用 netcat 工具测试我们的服务器是否能够连接并得到我们期待的效果了。

# 启动服务
$ swift run EchoServer --local=127.0.0.1:2230

# 使用 netcat 连接 127.0.0.1:2230
$ nc 127.0.0.1 2230

看起来 EchoServer 目前已经按照我们所期待的结果运行起来了。

Swift 使用 CocoaAsyncSocket 实现 TCP 转发-天真的小窝

实现 TCPForwarder

首先分析一下我们的需求,用户在启动应用程序时会传递给我们需要转发的远程目的地址,以及一个本地监听地址,并且可能会有多个客户端会连接到我们的转发服务中与远程进行通讯。

根据需求,我们可以先定义一个 connectSockets 数组来存储每“一对”连接,每个连接会有一个远程地址和一个客户端地址,所以我们首先定义一个 TCPForwarderSocket 结构体用来记录每个连接。

import CocoaAsyncSocket
import Foundation

public struct TCPForwarderSocket {
    var remoteSocket: GCDAsyncSocket
    var clientSocket: GCDAsyncSocket

    init(client: GCDAsyncSocket, remote: GCDAsyncSocket) {
        clientSocket = client
        remoteSocket = remote
    }

    init(client: GCDAsyncSocket, remoteDelegate: GCDAsyncSocketDelegate) {
        let remote = GCDAsyncSocket(delegate: remoteDelegate, delegateQueue: .global())
        self.init(client: client, remote: remote)
    }

    // 获取客户端地址
    let clientAddress: String {
        "\(clientSocket.connectedHost ?? ""):\(clientSocket.connectedPort)"
    }

    // 断开链接
    func disconnect() {
        if remoteSocket.isConnected {
            remoteSocket.disconnect()
        }
        if clientSocket.isConnected {
            clientSocket.disconnect()
        }
    }
}

在上面代码中能够看到,我创建了一个简单的结构体 TCPForwarderSocket 其中有 remoteSocket 和 clientSocket 变量分别用于存储客户端和远程的 GCDAsyncSocket 实例对象,除此之外还定义了一个 clientAddress 变量用于获取客户端的地址,和一个 disconnect 函数用来统一断开此次(客户端和服务端的)连接。

import CocoaAsyncSocket
import Foundation

public struct TCPForwarderSocket {
    ……
}

public class TCPForwarder: NSObject, GCDAsyncSocketDelegate {
    var localListenSocket: GCDAsyncSocket!
    var sourceHost: String?, sourcePort: UInt16?
    var connectSockets: [TCPForwarderSocket] = []

    // 获取本地监听地址
    public var localAddress: String {
        "\(localListenSocket.localHost ?? "127.0.0.1"):\(localListenSocket.localPort)"
    }

    public override init() {
        super.init()
        localListenSocket = GCDAsyncSocket(delegate: self, delegateQueue: .global())
    }

    deinit {
        localListenSocket.disconnect() // 关闭本地端口监听

        for item in connectSockets {
            item.disconnect() // 将全部连接客户端断开连接
        }
        connectSockets.removeAll() // 移除全部连接
    }

    public func start(from sourceHost: String, sourcePort: UInt16, to localHost: String, localPort: UInt16) throws {
        self.sourceHost = sourceHost
        self.sourcePort = sourcePort

        if localHost == "0.0.0.0" {
            try localListenSocket.accept(onPort: localPort)
        } else {
            try localListenSocket.accept(onInterface: localHost, port: localPort)
        }
    }
}

紧接着我们来定义一下 TCPForwarder 类并继承于 GCDAsyncSocketDelegate 委托,能够看到我们上面的代码在 TCPForwarder 类中声明了一个 localListenSocket 变量用于存储代理监听的 GCDAsyncSocket 实例,sourceHost 和 sourcePort 分别用于记录当前需要转发的远程源主机和端口,connectSockets 变量则是用于记录每次连接的 TCPForwarderSocket 结构体实例。

其次就是在构造函数中实例化本地监听 GCDAsyncSocket 对象,以及定义了 start 函数用于启动服务。

接下来,我们应该需要在 didAcceptNewSocket 函数中实现一下当有新的连接进来时,创建一个远程连接并构造一个 TCPForwarderSocket 将客户端连接与远程连接绑定。

import CocoaAsyncSocket
import Foundation

……

public class TCPForwarder: NSObject, GCDAsyncSocketDelegate {
    var localListenSocket: GCDAsyncSocket!
    var sourceHost: String?, sourcePort: UInt16?
    var connectSockets: [TCPForwarderSocket] = []

    ……

    public func socket(_: GCDAsyncSocket, didAcceptNewSocket newSocket: GCDAsyncSocket) {
        // 连接远程TCP服务器
        var proxyItem: TCPForwarderSocket?
        proxyItem = connectSockets.filter { $0.clientSocket == newSocket }.first

        if proxyItem == nil {
            // 构建 ForwarderSocket
            let remoteSocket = GCDAsyncSocket(delegate: self, delegateQueue: .global())
            proxyItem = TCPForwarderSocket(client: newSocket, remote: remoteSocket)
            connectSockets.append(proxyItem!)
        }

        print("TCPForwarder didAcceptNewSocket \(proxyItem?.clientAddress ?? "nil")")

        do {
            guard let proxyItem else {
                return
            }

            if proxyItem.remoteSocket.isConnected {
                // 如果判断远程已经连接的话先断开连接
                proxyItem.remoteSocket.disconnect()
            }

            // 连接远程地址
            try proxyItem.remoteSocket.connect(toHost: sourceHost ?? "", onPort: sourcePort ?? 0)

            // 关联本地监听Socket和远程Socket
            proxyItem.clientSocket.readData(withTimeout: -1, tag: 0)
            proxyItem.remoteSocket.readData(withTimeout: -1, tag: 0)
            print("TCPForwarder remoteSocket Connecting to remote TCP server")
        } catch {
            // 连接远程失败后,需要移除连接列表
            if let proxyItem, let index = connectSockets.firstIndex(where: { $0.clientSocket == proxyItem.clientSocket }) {
                proxyItem.disconnect()
                if index >= 0 && connectSockets.count > index {
                    connectSockets.remove(at: index)
                }
            }
            print("TCPForwarder remoteSocket Error connecting to remote TCP server: \(error.localizedDescription)")
            return
        }
    }

    public func socket(_ sock: GCDAsyncSocket, didConnectToHost _: String, port _: UInt16) {
        // 连接成功
        if let _ = connectSockets.filter({ $0.remoteSocket == sock || $0.clientSocket == sock }).first {
            // 远端或者客户端连接成功,读取数据
            print("TCPForwarder remoteSocket didConnect")
            sock.readData(withTimeout: -1, tag: 0)
        } else if sock == localListenSocket {
            print("TCPForwarder localListenSocket didConnect")
        }
    }
}

在 didAcceptNewSocket 函数中,首先判断 connectSockets 中是否存在一个相同的连接(这里通过客户端的地址来判断是否为同一个连接),当连接存在时需要先将旧的连接断开并且建立一个新的远程连接,当 connectSockets 中不存在连接时,创建一个新的远程连接并与当前的客户端连接关联。如果远程连接失败,则从 connectSockets 中移除本次连接并且同时断开客户端连接。

除了之前在 EchoServer 中用到的函数之外,这里出现了一个新的函数 didConnectToHost,这个函数主要用于与远程服务连接成功后触发读取远程连接上的数据。

import CocoaAsyncSocket
import Foundation

……

public class TCPForwarder: NSObject, GCDAsyncSocketDelegate {
    var localListenSocket: GCDAsyncSocket!
    var sourceHost: String?, sourcePort: UInt16?
    var connectSockets: [TCPForwarderSocket] = []

    ……
    
    public func socket(_ sock: GCDAsyncSocket, didRead data: Data, withTag _: Int) {
        if let item = connectSockets.filter({ $0.remoteSocket == sock }).first {
            // 如果是来自远程的数据,则转发到本地监听Socket
            item.clientSocket.write(data, withTimeout: -1, tag: 0)
        } else if let item = connectSockets.filter({ $0.clientSocket == sock }).first {
            // 如果是来自本地的数据,则转发到远程Socket
            item.remoteSocket.write(data, withTimeout: -1, tag: 0)
            print("TCPForwarder didRead localListenSocket \(data.count)")
        }
        // 继续监听数据
        sock.readData(withTimeout: -1, tag: 0)
    }

    public func socketDidDisconnect(_ sock: GCDAsyncSocket, withError err: (any Error)?) {
        if let index = connectSockets.firstIndex(where: { $0.clientSocket == sock }), index >= 0 {
            // 客户端断开连接
            print("TCPForwarder clientSocket socketDidDisconnect \(err?.localizedDescription ?? "nil") \(connectSockets.count):\(index)")
            if connectSockets.count > index {
                let item = connectSockets[index]
                item.disconnect()
                connectSockets.remove(at: index)
            }
        } else if let item = connectSockets.filter({ $0.remoteSocket == sock }).first {
            // 远程断开连接,同时断开客户端连接
            item.disconnect()
        } else if sock == localListenSocket {
            print("TCPForwarder localListenSocket socketDidDisconnect \(err?.localizedDescription ?? "nil")")
        }
    }
}

当然最后就是在 didRead 实现将客户端的数据转发到远程以及相反将远程的数据发送到客户端的逻辑了,发送完成后继续读取数据,其次就是在 socketDidDisconnect 中处理远程和客户端的连接断开已经从 connectSockets 数组中移除连接项的相关逻辑了。

到这里我们就基本实现了一个简单的 TCP 转发工具,相关完整代码我也已经提交到代码仓库 PBK-B/ios-sample-project/swift-tcp-forwarder 了。

最后稍微修改一下 cil 的代码将远程地址传递进去调用 TCPForwarder().start(from: _remoteAddr.host, sourcePort: _remoteAddr.port, to: _localAddr.host, localPort: _localAddr.port) 然后启动应用程序,利用 netcat 测试一下结果如下图,两边数据转发没有问题,完结撒花 🎉

Swift 使用 CocoaAsyncSocket 实现 TCP 转发-天真的小窝

好了,今天的博客就先写到这里,后面我应该会逐步多去尝试了解并写一些网络小工具来提高对计算机网络的了解,网络和协议还是得多写。