Skip to content

Commit

Permalink
Allow ICMP and HTTP Ping Client to bind to a specific device (#12)
Browse files Browse the repository at this point in the history
  • Loading branch information
johnnzhou authored Oct 1, 2024
1 parent e202465 commit 28f3728
Show file tree
Hide file tree
Showing 7 changed files with 175 additions and 19 deletions.
2 changes: 1 addition & 1 deletion Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ let package = Package(
],
dependencies: [
// Dependencies declare other packages that this package depends on.
.package(url: "https://github.com/apple/swift-nio.git", from: "2.72.0"),
.package(url: "https://github.com/apple/swift-nio.git", from: "2.73.0"),
.package(url: "https://github.com/apple/swift-log.git", from: "1.5.3"),
.package(url: "https://github.com/apple/swift-nio-ssl.git", from: "2.25.0"),
.package(url: "https://github.com/apple/swift-collections.git", from: "1.1.1")
Expand Down
29 changes: 25 additions & 4 deletions Sources/LCLPing/HTTP/HTTPPingClient.swift
Original file line number Diff line number Diff line change
Expand Up @@ -178,6 +178,9 @@ extension HTTPPingClient {

/// The DNS-resolved address according to the URL endpoint
public let resolvedAddress: SocketAddress

/// The outgoing device associated with the given interface name
public var device: NIONetworkDevice?

/// Initialize a HTTP Ping Client `Configuration`.
///
Expand All @@ -190,6 +193,7 @@ extension HTTPPingClient {
/// - useServerTimimg: Indicate whether the HTTP Ping Client should take `ServerTiming` attribute
/// from the reponse header.
/// - useURLSession: Indicate whether the HTTP Ping Client should use native URLSession implementation.
/// - deviceName: the interface name for which the outbound data will be sent to.
///
/// - Throws:
/// - httpMissingHost: if URL does not include any host information.
Expand All @@ -201,7 +205,8 @@ extension HTTPPingClient {
connectionTimeout: TimeAmount = .seconds(5),
headers: [String: String] = Configuration.defaultHeaders,
useServerTiming: Bool = false,
useURLSession: Bool = false) throws {
useURLSession: Bool = false,
deviceName: String? = nil) throws {
self.url = url
self.count = count
self.readTimeout = readTimeout
Expand Down Expand Up @@ -244,6 +249,20 @@ extension HTTPPingClient {
// NOTE: URLSession is not fully supported in swift-corelibs-foundation
self.useURLSession = false
#endif

for device in try System.enumerateDevices() {
if device.name == deviceName, let address = device.address {
switch (address.protocol, self.resolvedAddress.protocol) {
case (.inet, .inet), (.inet6, .inet6):
self.device = device
default:
continue
}
}
if self.device != nil {
break
}
}
}

/// Initialize a HTTP Ping Client `Configuration`.
Expand All @@ -257,6 +276,7 @@ extension HTTPPingClient {
/// - useServerTimimg: Indicate whether the HTTP Ping Client should take `ServerTiming` attribute
/// from the reponse header.
/// - useURLSession: Indicate whether the HTTP Ping Client should use native URLSession implementation.
/// - deviceName: the interface name for which the outbound data will be sent to
///
/// - Throws:
/// - httpMissingHost: if URL does not include any host information.
Expand All @@ -268,7 +288,8 @@ extension HTTPPingClient {
connectionTimeout: TimeAmount = .seconds(5),
headers: [String: String] = Configuration.defaultHeaders,
useServerTiming: Bool = false,
useURLSession: Bool = false) throws {
useURLSession: Bool = false,
deviceName: String? = nil) throws {
guard let urlObj = URL(string: url) else {
throw PingError.invalidURL(url)
}
Expand All @@ -279,8 +300,8 @@ extension HTTPPingClient {
connectionTimeout: connectionTimeout,
headers: headers,
useServerTiming: useServerTiming,
useURLSession: useURLSession
)
useURLSession: useURLSession,
deviceName: deviceName)
}

/// Initialize a HTTP Ping Client `Configuration`.
Expand Down
20 changes: 20 additions & 0 deletions Sources/LCLPing/HTTP/NIOHTTPClient.swift
Original file line number Diff line number Diff line change
Expand Up @@ -198,6 +198,26 @@ final class NIOHTTPClient: Pingable {
} catch {
return channel.eventLoop.makeFailedFuture(error)
}

if let device = self.configuration.device {
#if canImport(Darwin)
switch device.address {
case .v4:
return channel.setOption(.ipOption(.ip_bound_if), value: CInt(device.interfaceIndex))
case .v6:
return channel.setOption(.ipv6Option(.ipv6_bound_if), value: CInt(device.interfaceIndex))
case .unixDomainSocket:
self.stateLock.withLock {
self.state = .error
}
return channel.eventLoop.makeFailedFuture(PingError.icmpBindToUnixDomainSocket)
default:
()
}
#elseif canImport(Glibc)
return (channel as! SocketOptionProvider).setBindToDevice(device.name)
#endif
}

return channel.eventLoop.makeSucceededVoidFuture()
}
Expand Down
77 changes: 63 additions & 14 deletions Sources/LCLPing/ICMP/ICMPPingClient.swift
Original file line number Diff line number Diff line change
Expand Up @@ -78,23 +78,31 @@ public final class ICMPPingClient: Pingable {
}
}
}

for cnt in 0..<self.configuration.count {

let sendPromise = channel.eventLoop.makePromise(of: Void.self)
sendPromise.futureResult.cascadeFailure(to: self.promise)

func send(_ cnt: Int) {
if cnt == self.configuration.count {
sendPromise.succeed()
return
}
let el = self.eventLoopGroup.next()
let p = el.makePromise(of: Void.self)
logger.debug("Scheduled #\(cnt) request")
channel.eventLoop.scheduleTask(in: cnt * self.configuration.interval) {
channel.write(
ICMPPingClient.Request(
sequenceNum: UInt16(cnt),
identifier: self.identifier
),
promise: nil
)
}
channel.writeAndFlush(ICMPPingClient.Request(sequenceNum: UInt16(cnt), identifier: self.identifier), promise: p)
}.futureResult.hop(to: el).cascadeFailure(to: sendPromise)

p.futureResult.cascadeFailure(to: sendPromise)
send(cnt + 1)
}

return self.promise.futureResult.flatMap { pingResponse in

send(0)

return sendPromise.futureResult.and(self.promise.futureResult).flatMap { (_, pingResponse) in
let summary = pingResponse.summarize(host: self.configuration.resolvedAddress)
self.stateLock.withLockVoid {
self.stateLock.withLock {
self.state = .finished
}
return channel.eventLoop.makeSucceededFuture(summary)
Expand Down Expand Up @@ -166,6 +174,27 @@ public final class ICMPPingClient: Pingable {
}
return channel.eventLoop.makeFailedFuture(error)
}

if let device = self.configuration.device {
#if canImport(Darwin)
switch device.address {
case .v4:
return channel.setOption(.ipOption(.ip_bound_if), value: CInt(device.interfaceIndex))
case .v6:
return channel.setOption(.ipv6Option(.ipv6_bound_if), value: CInt(device.interfaceIndex))
case .unixDomainSocket:
self.stateLock.withLock {
self.state = .error
}
return channel.eventLoop.makeFailedFuture(PingError.icmpBindToUnixDomainSocket)
default:
()
}
#elseif canImport(Glibc)
return (channel as! SocketOptionProvider).setBindToDevice(device.name)
#endif
}

return channel.eventLoop.makeSucceededVoidFuture()
}
}
Expand Down Expand Up @@ -202,13 +231,18 @@ extension ICMPPingClient {
/// Time, in second, to wait for a reply for each packet sent. Default is 1s.
public var timeout: TimeAmount

/// The resolved socket address
public let resolvedAddress: SocketAddress

/// The outgoing device associated with the given interface name
public var device: NIONetworkDevice?

public init(endpoint: EndpointTarget,
count: Int = 10,
interval: TimeAmount = .seconds(1),
timeToLive: UInt8 = 64,
timeout: TimeAmount = .seconds(1)
timeout: TimeAmount = .seconds(1),
deviceName: String? = nil
) throws {
self.endpoint = endpoint
self.count = count
Expand All @@ -221,6 +255,21 @@ extension ICMPPingClient {
case .ipv6(let addr, let port):
self.resolvedAddress = try SocketAddress.makeAddressResolvingHost(addr, port: port ?? 0)
}

for device in try System.enumerateDevices() {
if device.name == deviceName, let address = device.address {
switch (address.protocol, self.endpoint) {
case (.inet, .ipv4), (.inet6, .ipv6):
logger.info("device selcted is \(device)")
self.device = device
default:
continue
}
}
if self.device != nil {
break
}
}
}
}

Expand Down
9 changes: 9 additions & 0 deletions Sources/LCLPing/Models/Errors+LCLPing.swift
Original file line number Diff line number Diff line change
Expand Up @@ -52,13 +52,16 @@ public enum PingError: Error {
case icmpPointerIndicatesError
case icmpMissingARequiredOption
case icmpBadLength
case icmpDestinationNotMulticast
case icmpBindToUnixDomainSocket

case httpMissingHost
case httpMissingSchema
case httpInvalidResponseStatusCode(Int)
case httpInvalidHandlerState
case httpMissingResponse
case httpInvalidURLSessionTask(Int)
case httpBindToUnixDomainSocket

case invalidHexFormat

Expand Down Expand Up @@ -165,6 +168,12 @@ extension PingError: CustomStringConvertible {
return "Missing HTTP response."
case .httpInvalidURLSessionTask(let id):
return "URLSession Task \(id) is invalid."
case .icmpDestinationNotMulticast:
return "Destination address is not a multicast address."
case .icmpBindToUnixDomainSocket:
return "Cannot bind to a unix domain socket device."
case .httpBindToUnixDomainSocket:
return "Cannot bind to a unix domain socket device."
}
}

Expand Down
20 changes: 20 additions & 0 deletions Sources/LCLPing/Utilities/LCLPing+ChannelOption.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
//
// This source file is part of the LCL open source project
//
// Copyright (c) 2021-2024 Local Connectivity Lab and the project authors
// Licensed under Apache License v2.0
//
// See LICENSE for license information
// See CONTRIBUTORS for the list of project authors
//
// SPDX-License-Identifier: Apache-2.0
//

import Foundation
import NIOCore

extension ChannelOption where Self == ChannelOptions.Types.SocketOption {
public static func ipv6Option(_ name: NIOBSDSocket.Option) -> Self {
.init(level: .ipv6, name: name)
}
}
37 changes: 37 additions & 0 deletions Sources/LCLPing/Utilities/LCLPing+SocketOption.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
//
// This source file is part of the LCL open source project
//
// Copyright (c) 2021-2024 Local Connectivity Lab and the project authors
// Licensed under Apache License v2.0
//
// See LICENSE for license information
// See CONTRIBUTORS for the list of project authors
//
// SPDX-License-Identifier: Apache-2.0
//

import Foundation
import NIOCore

extension NIOBSDSocket.Option {
#if canImport(Darwin)
public static let ip_bound_if: NIOBSDSocket.Option = Self(rawValue: IP_BOUND_IF)
public static let ipv6_bound_if: NIOBSDSocket.Option = Self(rawValue: IPV6_BOUND_IF)
#elseif canImport(Glibc)
public static let so_bindtodevice = Self(rawValue: SO_BINDTODEVICE)
#endif
}

extension SocketOptionProvider {
#if canImport(Glibc)
/// Sets the socket option SO_BINDTODEVICE to `value`.
///
/// - parameters:
/// - value: The value to set SO_BINDTODEVICE to.
/// - returns: An `EventLoopFuture` that fires when the option has been set,
/// or if an error has occurred.
public func setBindToDevice(_ value: String) -> EventLoopFuture<Void> {
self.unsafeSetSocketOption(level: .socket, name: .so_bindtodevice, value: value)
}
#endif
}

0 comments on commit 28f3728

Please sign in to comment.