Simple Ping in Swift Code

文中代码主要是参考:GitHub - Frizlab/SimpleSwiftPing: A Swift implementation of SimplePing from the Apple Developer Examples

前言

iOS 平台上想使用 ping 不像安卓可以直接使用 linux 的 ping 命令,我们需要自己来实现,早先苹果给出了一个 OC 版本的 demo,并参考了网上一些 Swift 版本的实现,虽然只是个小工具,但还是涉及到了不少知识点:ICMP 报文格式、Swift 中的指针操作、底层 C 方法等,稍作整理。 前三个部分可以戳: Size, Stride, Alignment · YUI 的严肃文 Simple Ping in Swift - 预备芝士 · YUI 的严肃文 Simple Ping in Swift - ICMP 报文和 IP 报文 · YUI 的严肃文

Swift 代码实现

ICMP 和 IP 结构体

初始化

// 需要 ping 的域名
public let hostName: String
// 需要在开始 ping 之前指名
public var addressStyle: AddressStyle
// 用来标识一个 ping,init 的时候会赋值一个随机数
public let identifier: UInt16

public init(hostName hn: String, addressStyle s: AddressStyle = .any) {
    hostName = hn
    addressStyle = s
    identifier = UInt16.random(in: .min ... .max)
}

初始化的时候需要调用者传递一些必要信息,并生成一个随机数作为标识符

DNS 解析

fileprivate var host: CFHost?
public private(set) var hostAddress: Data?

public func start() {
    assert(host == nil)
    assert(hostAddress == nil)
    
    // 在创建 context 的时候将 self 本身作为 info 参数传递进去,在回调函数中会使用到
    var context = CFHostClientContext(version: 0,
                                      info: unsafeBitCast(self, to: UnsafeMutableRawPointer.self),
                                      retain: nil,
                                      release: nil,
                                      copyDescription: nil)
    
    // 使用现有的 hostName(非 IP) 创建一个 CFHostRef 对象
    let h = CFHostCreateWithName(nil, hostName as CFString).autorelease().takeUnretainedValue()
    host = h
    // 提供一个上下文对象和回调函数
    CFHostSetClient(h, hostResolveCallback, &context)
    // 在 RunLoop 中执行具体的解析操作
    CFHostScheduleWithRunLoop(h, CFRunLoopGetCurrent(), RunLoopMode.defaultRunLoopMode.rawValue as CFString)
    
    var error = CFStreamError()
    // 开始解析,把它的第二个参数设置为 .addresses 表明你想要返回一个 IP 地址
    if !CFHostStartInfoResolution(h, CFHostInfoType.addresses, &error) {
        didFail(hostStreamError: error)
    } else {
        //set a timeout behavior
        setTimeoutBehaviorOfPing()
    }
}

// 回调方法
private func hostResolveCallback(theHost: CFHost,
                                 typeInfo: CFHostInfoType,
                                 error: UnsafePointer<CFStreamError>?,
                                 info: UnsafeMutableRawPointer?) {
    /* This C routine is called by CFHost when the host resolution is complete.
       * It just redirects the call to the appropriate Swift method. */
    // 装换为 Swift Object
    let obj = unsafeBitCast(info, to: SimplePing.self)
    // 如果正确查询到结果,那么 host 此时已经被赋值,因为在 CFHostSetClient 的时候已经将 .host 作为参数传递进去了
    assert(obj.host === theHost)
    assert(typeInfo == CFHostInfoType.addresses)
    
    if let error = error, error.pointee.domain != 0 {
        obj.didFail(hostStreamError: error.pointee)
    } else {
        obj.hostResolutionDone()
    }
}

// 获取到 IP 组以后我们需要解析出一个符合要求的可用的 IP
fileprivate func hostResolutionDone() {
    // DarwinBoolean is the Swift mapping of the "historic" C type Boolean
    var resolved = DarwinBoolean(false)
    guard let h = host else {
        return
    }
    // 函数来获取解析结果,这个函数返回一个数组,因为一个域名可能会对应多个 IP,我们只选取第一个可用的 IP 即可
    let addresses = CFHostGetAddressing(h, &resolved)?.retain().autorelease()
    if resolved.boolValue, let addresses = addresses?.takeUnretainedValue() as? [Data] {
        resolved = false
        for address in addresses {
            assert(hostAddress == nil)
            guard address.count >= MemoryLayout<sockaddr>.size else {
                continue
            }
            
            address.withUnsafeBytes {(addrPtr: UnsafePointer<sockaddr>) in
                // 根据初始化传入的 addressStyle 进行判断
                switch (addrPtr.pointee.sa_family, addressStyle) {
                case (sa_family_t(AF_INET), .any), (sa_family_t(AF_INET), .icmpV4):
                    hostAddress = address; resolved = true
                case (sa_family_t(AF_INET6), .any), (sa_family_t(AF_INET6), .icmpV6):
                    hostAddress = address; resolved = true
                default: ()
                }
            }
            if resolved.boolValue {
                break
            }
        }
    }
    
    stopHostResolution()
    
    if resolved.boolValue {
        assert(hostAddress != nil)
        startWithHostAddress()
    } else {
        didFail(error: NSError(domain: kCFErrorDomainCFNetwork as String,
                               code: Int(CFNetworkErrors.cfHostErrorHostNotFound.rawValue),
                               userInfo: nil))
    }
}

套接字创建

域名解析成功后,我们需要创建套接字,为后续发送包做准备

fileprivate var sock: CFSocket?

private func startWithHostAddress() {
    // Type for the platform-specific native socket handle.
    let fd: CFSocketNativeHandle
    let err: Int32
    switch hostAddressFamily {
    case sa_family_t(AF_INET):
        // SOCK_DGRAM 表示使用不可靠的数据报服务,不保障有序、可达
        fd = socket(AF_INET, SOCK_DGRAM, IPPROTO_ICMP)
        if fd < 0 {
            err = errno
        } else {
            err = 0
        }
        
    case sa_family_t(AF_INET6):
        fd = socket(AF_INET6, SOCK_DGRAM, IPPROTO_ICMPV6)
        if fd < 0 {
            err = errno
        } else {
            err = 0
        }
        
    default:
        fd = -1
        err = EPROTONOSUPPORT
    }
    
    guard err == 0 else {
        didFail(error: NSError(domain: NSPOSIXErrorDomain, code: Int(err), userInfo: nil))
        return
    }
    
    // 包装到 CFSocket 中,仍然将 self 本身作为 info 参数传递
    var context = CFSocketContext(version: 0,
                                  info: unsafeBitCast(self, to: UnsafeMutableRawPointer.self),
                                  retain: nil,
                                  release: nil,
                                  copyDescription: nil)
    // CFSocketCreateWithNative 会返回一个可复用的 socket
    // readCallBack: The callback is called when data is available to be read or a new connection is waiting to be accepted. The data is not automatically read; the callback must read the data itself.
    sock = CFSocketCreateWithNative(nil, fd, CFSocketCallBackType.readCallBack.rawValue, socketReadCallback, &context)
    assert(sock != nil)
    
    assert(CFSocketGetSocketFlags(sock) & kCFSocketCloseOnInvalidate != 0)
    // 为 CFSocket 创建一个 CFRunLoopSourceRef
    let rls = CFSocketCreateRunLoopSource(nil, sock, 0)
    assert(rls != nil)
    
    CFRunLoopAddSource(CFRunLoopGetCurrent(), rls, CFRunLoopMode.defaultMode)
    delegate?.simplePing(self, didStart: hostAddress ?? Data())
}

发送消息

因为 ping 默认使用 UDP,是无连接的,所以发送数据的时候需要致命对方的 IP 地址 构造 ICMP 包,ICMPHeader 的定义可以查看:

func pingPacket(type: UInt8, payload: Data, requiresChecksum: Bool) -> Data {
        let header = ICMPHeader(
            type: type, code: 0, checksum: 0,
            identifier: identifier, sequenceNumber: nextSequenceNumber
        )
        
        var packet = header.headerBytes + payload
        if requiresChecksum {
            /* The IP checksum routine returns a 16-bit number that's already in
               * correct byte order (due to wacky 1's complement maths), so we just
               * put it into the packet as a 16-bit unit. */
            // IP 只针对头部进行校验,ICMP 是基于整个包来生成校验和
            let checksumBig = SimplePing.packetChecksum(packetData: packet)
            packet[ICMPHeader.checksumDelta...].withUnsafeMutableBytes {(bytes: UnsafeMutablePointer<UInt16>) in bytes.pointee = checksumBig }
        }
        
        return packet
    }

检验和的生成:

static private func packetChecksum(packetData: Data) -> UInt16 {
        var sum: Int32 = 0
        var packetData = packetData
        
        /* Mop up an odd byte, if necessary */
        if packetData.count % 2 == 1 {
            packetData += Data([0])
        }
        
        /* Our algorithm is simple, using a 32 bit accumulator (sum), we
           * add sequential 16 bit words to it, and at the end, fold back all the
           * carry bits from the top 16 bits into the lower 16 bits. */
        packetData.withUnsafeBytes {(bytes: UnsafePointer<UInt16>) in
            var curPos = bytes
            assert(packetData.count % 2 == 0)
            for i in 0 ..< packetData.count / 2 {
                //跳过校验和区域,因为我们正在计算校验和
                if i != ICMPHeader.checksumDelta / 2 {
                    // 从低位到高位,按照 16bit 为单位相加
                    sum &+= Int32(curPos.pointee)
                }
                curPos = curPos.advanced(by: 1)
            }
        }
        
        /* Add back carry outs from top 16 bits to low 16 bits */
        sum = (sum >> 16) &+ (sum & 0xffff)            /* add high 16 to low 16 */
        sum &+= (sum >> 16)                              /* add carry */
        return UInt16(truncating: NSNumber(value: ~sum)) /* truncate to 16 bits */
    }

发送包

构造好包,调用 sendto 方法来发送

public func sendPing(data: Data?) {
    // 必须要等到 IP 地址解析完成才能开始 ping
    guard let hostAddress = hostAddress else {
        fatalError("Gotta wait for -simplePing:didStartWithAddress: before sending a ping")
    }
    
    /* Our dummy payload is sized so that the resulting ICMP packet, including
       * the ICMPHeader, is 64-bytes, which makes it easier to recognise our
       * packets on the wire. */
    let payload = data ?? String(format: "%28zd bottles of beer on the wall", 99 - (nextSequenceNumber % 100)).data(using: .ascii)!
    assert(data != nil || payload.count == 56)// 56 为 64 扣掉首部的长度
    
    // 构造 ping packet
    let packet: Data
    switch hostAddressFamily {
    case sa_family_t(AF_INET):
        packet = pingPacket(type: ICMPv4TypeEcho.request.rawValue, payload: payload, requiresChecksum: true)
    case sa_family_t(AF_INET6):
        packet = pingPacket(type: ICMPv6TypeEcho.request.rawValue, payload: payload, requiresChecksum: true)
    default:
        fatalError()
    }
    
    // 调用 sendto 方法发送 package
    let err: Int32
    let bytesSent: Int
    if let socket = sock {
        bytesSent = packet.withUnsafeBytes {(packetBytes: UnsafePointer<UInt8>) -> Int in
            return hostAddress.withUnsafeBytes {(hostAddressBytes: UnsafePointer<sockaddr>) -> Int in
                return sendto(
                    CFSocketGetNative(socket),
                    UnsafeRawPointer(packetBytes),
                    packet.count,
                    0, /* flags */
                    hostAddressBytes, //因为是基于 UDP 发送,所以是无连接的,需要致命目的地址
                    socklen_t(hostAddress.count)
                )
            }
        }
        // 如果发送成功,sendto 方法会返回实际传送出去的字符长度,失败返回 -1
        if bytesSent >= 0 {
            err = 0
        } else {
            err = errno
        }
    } else {
        bytesSent = -1
        err = EBADF
    }
    
    if bytesSent > 0 && bytesSent == packet.count {
        delegate?.simplePing(self, didSendPacket: packet, sequenceNumber: nextSequenceNumber)
    } else {
        let error = NSError(domain: NSPOSIXErrorDomain, code: Int(err != 0 ? err : ENOBUFS), userInfo: nil)
        delegate?.simplePing(self, didFailToSendPacket: packet, sequenceNumber: nextSequenceNumber, error: error)
    }
    
    nextSequenceNumber &+= 1
    if nextSequenceNumber == 0 {
        nextSequenceNumberHasWrapped = true
    }
}

接受消息

服务器返回消息后, Socket 调用 read 程序组件,将接受到响应消息存放在接受缓冲区中。 上面我们在创建套接字的时候,传入了一个 socketReadCallback 方法,并且限定了当返回类型是 CFSocketCallBackType.readCallBack 方法时回调我们的 callback 方法

Callback 方法中的逻辑很简单,进行一些判断后,调用 readData 方法:

private func socketReadCallback(s: CFSocket?,
                                type: CFSocketCallBackType,
                                address: CFData?,
                                data: UnsafeRawPointer?,
                                info: UnsafeMutableRawPointer?) {
    /* This C routine is called by CFSocket when there's data waiting on our ICMP
       * socket. It just redirects the call to Swift code. */
    // 转为 swift object
    let obj = unsafeBitCast(info, to: SimplePing.self)
    assert(obj.sock === s)
    
    assert(type == CFSocketCallBackType.readCallBack)
    assert(address == nil)
    assert(data == nil)
    
    obj.readData()
}

Readata 中的逻辑就是读取 IP 包,然后摘出其中的 ICMP,剖析 ICMP 返回包,检验其正确性之后将结果返回(下一段具体讲解析 ICMP 返回包的逻辑),如果成功,将 response 的内容和序列回调给代理,如果失败,抛错误给代理; 至此,就是一次完整的 Ping Pong

fileprivate func readData() {
    // 65535 is the maximum IP packet size
    let bufferSize = 65535
    // alloc 一片内存空间
    let buffer = UnsafeMutableRawPointer.allocate(byteCount: bufferSize, alignment: 0 /* We don’t need a specific alignment AFAICT */)
    defer {
        buffer.deallocate()
    }
    
    // 通过 recvfrom 方法读取数据
    let err: Int32
    // sockaddr_storage 既可以表示 IPv4 地址,也可以表示 IPv6 地址,是足以存储 IPv6 地址的结构体
    var addr = sockaddr_storage()
    var addrLen = socklen_t(MemoryLayout<sockaddr_storage>.size)
    let bytesRead = withUnsafeMutablePointer(to: &addr, { (addrStoragePtr: UnsafeMutablePointer<sockaddr_storage>) -> Int in
        let addrPtr = UnsafeMutablePointer<sockaddr>(OpaquePointer(addrStoragePtr))
        // 从指定套接字 sock 的 addrPtr 指针开始,读取长度为 addrLen 的数据到指定的 buffer 中
        return recvfrom(CFSocketGetNative(sock), buffer, bufferSize, 0, addrPtr, &addrLen)
    })
    // 失败则返回-1,错误原因存于errno中
    if bytesRead >= 0 {
        err = 0
    } else {
        err = errno
    }
    
    if bytesRead > 0 {
        var sequenceNumber = UInt16(0)
        var packet = Data(bytes: buffer, count: bytesRead)
        // 将 buffer 转给指向 IPv4Header 类型的指针,.pointee 实际上在取 buffer 中具体的值
        let ttl = buffer.assumingMemoryBound(to: IPv4Header.self).pointee.timeToLive
        // 将 packet 和 sequenceNumber 的引用传入,如果包是合法的,返回时这两个参数就已经被赋值
        if validatePingResponsePacket(&packet, sequenceNumber: &sequenceNumber) {
            delegate?.simplePing(self, didReceivePingResponsePacket: packet, sequenceNumber: sequenceNumber, ttl: ttl)
        } else {
            delegate?.simplePing(self, didReceiveUnexpectedPacket: packet)
        }
    } else {
        didFail(error: NSError(domain: NSPOSIXErrorDomain, code: Int(err != 0 ? err : EPIPE), userInfo: nil))
    }
}

解析 ICMP 返回包

分情况验证包的正确性,注意以下几次方法的参数都是 inout 的

func validatePingResponsePacket(_ packet: inout Data, sequenceNumber: inout UInt16) -> Bool {
    switch hostAddressFamily {
    case sa_family_t(AF_INET):
        return validatePing4ResponsePacket(&packet, sequenceNumber: &sequenceNumber)
    case sa_family_t(AF_INET6):
        return validatePing6ResponsePacket(&packet, sequenceNumber: &sequenceNumber)
    default:
        fatalError()
    }
}

下面只贴 IPv4 的验证逻辑,思路是一样的,只是 type 类型不一样

private func validatePing4ResponsePacket(_ packet: inout Data, sequenceNumber: inout UInt16) -> Bool {
    guard let icmpHeaderOffset = SimplePing.icmpHeaderOffset(in: packet) else {
        return false
    }
    
    let icmpPacket = Data(packet[icmpHeaderOffset...])
    // 传入 data 生成 ICMPHeader 结构体
    let icmpHeader = ICMPHeader(data: icmpPacket)
    
    let receivedChecksum = icmpHeader.checksum
    // 验证校验和
    let calculatedChecksum = UInt16(bigEndian: SimplePing.packetChecksum(packetData: icmpPacket))
    /* The checksum method returns a big-endian UInt16 */
    // 检验和不对,不合法
    guard receivedChecksum == calculatedChecksum else {
        return false
    }
    // 类型不是 0,代码不为 0,不合法,IPv4 的 ping 请求的 reply 对应的类型字段为 0, 代码字段为 0,下面实际贴了一张 ICMP 的 RFC 中的图
    guard icmpHeader.type == ICMPv4TypeEcho.reply.rawValue && icmpHeader.code == 0 else {
        return false
    }
    // 之前我们生成的 id,用于匹配包,不匹配则不合法
    guard icmpHeader.identifier == identifier else {
        return false
    }
    
    guard validateSequenceNumber(icmpHeader.sequenceNumber) else {
        return false
    }
    //
    packet = icmpPacket
    sequenceNumber = icmpHeader.sequenceNumber
    
    return true
}

IPv4 的 ping 请求的 reply 对应的类型字段为 0, 代码字段为 0: -w250

// ICMP 包的偏移位置 
static private func icmpHeaderOffset(in ipv4Packet: Data) -> Int? {
    guard ipv4Packet.count >= IPv4Header.size + ICMPHeader.size else {
        return nil
    }
    
    // 通过 data 生成 IPv4 结构体
    let ipv4Header = IPv4Header(data: ipv4Packet)
    if ipv4Header.versionAndHeaderLength & 0xF0 == 0x40 /* IPv4 */ && Int32(ipv4Header.protocol) == IPPROTO_ICMP {
        // 取出第 1 个字节,与 0x0F 是为了取其中的后 4 位,因为首部长度字段只有 4 位
        let ipHeaderLength = Int(ipv4Header.versionAndHeaderLength & 0x0F) * MemoryLayout<UInt32>.size
        if ipv4Packet.count >= (ipHeaderLength + ICMPHeader.size) {
            return ipHeaderLength
        }
    }
    return nil
}