Skip to main content

SwiftNIO core - SocketAddress from packed bytes

Merged
19 Nov 2020
Swiftprotocol servers & clients
Contribution Type

This contribution is a new feature.

Introduction

SwiftNIO presentation
SwiftNIO presentation

Project

You can find the swiftNIO project presentation here.

Context

Current behavior

Currently a user can create a SocketAddress (represent a socket address to which we may want to connect) from a string representation of an IP address.

That would be good if we had helpers to create it from packed byte representation.

Implement the solution

To introduce the solution, we must know what an IP address (Internet Protocol address) is.
An IP address is a numerical label (an identifier) which is assigned to a device connected to a particular network (which uses IP to communicate).

We must also know that an IP address can be in the form of IPv4 or IPv6.

IPv4

IPv4 is the first version of IP. It uses a 32-bit address scheme and it is the most widely used IP version.
It is expressed in dotted-decimal notation, with every bits represented by a number from 1 to 255.

For example: 168.212.226.204.

IPv6

Conversely, IPv6 is the most recent version of IP. It uses a 128-bit address scheme and it resolve some issues which are associated with IPv4.
It is represented by eight sets of four hexadecimal digits separated by a colon.

For example: fe80:0:0:0:0:0:0:5 which can be abbreviated as fe80::5 in our test case bellow.

To recap, IP addresses have the following length:

  • IP V4 address: 4 bytes
  • IP V6 address: 16 bytes
IP address logic
IP address parsing

Create SocketAddress from packed byte representation

As said above, the way we are going to differentiate an IPV6 address from IPV4 is thanks to their size.

We are first going to retrieve our IP Address which is a ByteBuffer.

The ByteBufferstruct

ByteBuffer is a specific SwiftNIO type of object, it stores contiguoulsy allocated raw bytes.

Here is a definition of the API that we will use:

  • readableBytesView: a view into the readable bytes of the ByteBuffer
  • readableBytes: the number of bytes readable
  • copyBytes(at:to:length:): copies length bytes starting at the fromIndex to toIndex

Now that we have defined what a ByteBuffer is, we are going to retrieve its size with readableBytes and we will therefore be able to add our switch statement which will tell us if our packedIpAddress is in the form of IPv6 (a length of 16) or IPv4 (a length of 4).

Then inside our switch statement we will use our ByteBufferView (thanks to readableBytesView) to create a new SocketAddress.

Let's take a closer look at sockaddr_in() (the behavior of sockaddr_in6() is essentially the same):

IP address logic
sockaddr_in()

Sources/NIO/SocketAddresses.swift
/// Create a new `SocketAddress` for an IP address in ByteBuffer form.
///
/// - parameters:
/// - packedIpAddress: The IP address, in ByteBuffer form.
/// - port: The target port.
/// - returns: the `SocketAddress` corresponding to this string and port combination.
/// - throws: may throw `SocketAddressError.failedToParseIPByteBuffer` if the IP address cannot be parsed.
public init(packedIpAddress: ByteBuffer, port: Int) throws {
let packed = packedIpAddress.readableBytesView

switch packedIpAddress.readableBytes {
case 4:
var ipv4Addr = sockaddr_in()
ipv4Addr.sin_family = sa_family_t(AF_INET)
ipv4Addr.sin_port = in_port_t(port).bigEndian
withUnsafeMutableBytes(of: &ipv4Addr.sin_addr) { $0.copyBytes(from: packed) }
// Init our IPv4 address
self = .v4(.init(address: ipv4Addr, host: ""))
case 16:
var ipv6Addr = sockaddr_in6()
ipv6Addr.sin6_family = sa_family_t(AF_INET6)
ipv6Addr.sin6_port = in_port_t(port).bigEndian
withUnsafeMutableBytes(of: &ipv6Addr.sin6_addr) { $0.copyBytes(from: packed) }
// Init our IPv6 address
self = .v6(.init(address: ipv6Addr, host: ""))
default:
throw SocketAddressError.FailedToParseIPByteBuffer(address: packedIpAddress)
}
}

Add a new SocketAddressError

This error is thrown when we can't parse the packed byte representattion.

Sources/NIO/SocketAddresses.swift
extension SocketAddressError {
/// Unable to parse a given IP ByteBuffer
public struct FailedToParseIPByteBuffer: Error, Hashable {
public var address: ByteBuffer

public init(address: ByteBuffer) {
self.address = address
}
}
}

Add some tests

As said before, an IPv4 adress contains 4 bytes, let's take [0x7F, 0x00, 0x00, 0x01] which is the ByteBuffer representation of 127.0.0.1.

Tests/NIOTests/SocketAddressTest.swift
func testDescriptionWorksWithByteBufferIPv4IP() throws {
let IPv4: [UInt8] = [0x7F, 0x00, 0x00, 0x01]
let ipv4Address: ByteBuffer = ByteBuffer.init(bytes: IPv4)
let sa = try! SocketAddress(packedIpAddress: ipv4Address, port: 12345)
XCTAssertEqual("[IPv4]127.0.0.1:12345", sa.description)
}

An IPv6 adress contains 16 bytes, let's take the ByteBuffer representation of fe80::5.

Tests/NIOTests/SocketAddressTest.swift
func testDescriptionWorksWithByteBufferIPv6IP() throws {
let IPv6: [UInt8] =
[0xfe, 0x80, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x05]
let ipv6Address: ByteBuffer = ByteBuffer.init(bytes: IPv6)
let sa = try! SocketAddress(packedIpAddress: ipv6Address, port: 12345)
XCTAssertEqual("[IPv6]fe80::5:12345", sa.description)
}

If we provide a ByteBuffer IP address with a wrong length we need to throw a new FailedToParseIPByteBuffer error.

Tests/NIOTests/SocketAddressTest.swift
func testRejectsWrongIPByteBufferLength() {
let wrongIP: [UInt8] = [0x01, 0x7F, 0x00]
let ipAddress: ByteBuffer = ByteBuffer.init(bytes: wrongIP)
XCTAssertThrowsError(try SocketAddress(packedIpAddress: ipAddress, port: 12345)) { error in
switch error {
case is SocketAddressError.FailedToParseIPByteBuffer:
XCTAssertEqual(ipAddress, (error as! SocketAddressError.FailedToParseIPByteBuffer).address)
default:
XCTFail("unexpected error: \(error)")
}
}
}

Takeaway

Problems encountered

At first I started implementing the new error in the existing enum SocketAddressError:

Sources/NIO/SocketAddresses.swift
public enum SocketAddressError: Error {
/// The host is unknown (could not be resolved).
case unknown(host: String, port: Int)
/// The requested `SocketAddress` is not supported.
case unsupported
/// The requested UDS path is too long.
case unixDomainSocketPathTooLong
/// Unable to parse a given IP string
case failedToParseIPString(String)
/// Unable to parse a given IP ByteBuffer
case failedToParseIPByteBuffer(ByteBuffer)

But adding new cases to enumerations is a Semver major change.
This is why I implemented the error as a struct.

What did I learn ?

This contribution allowed me to learn more about IP addresses and packed bytes representation.
Swift is not the language I usually use, so it allowed me to put into practice some concepts that I have learned in the past.