Discovering what's out there

April 8th, 2019
#networking

Many of us feel nervous when meeting a group of people for the first time. What are the dynamics of the group, what are the in-jokes, will I find common ground with someone - are just a few questions that can plague you. A lot of your hard work at suppressing your crippling social self-doubt can unravel with a shaky introduction or misplaced comment. You may even find yourself wondering:

"Are they laughing with me because I'm funny or at me because of the overly pointy brogues that I'm wearing?"

Of course, that's nonsense. Pointy brogues are the height of fashion 👞.

It's not just you who can be anxious about new introductions; your app can be too. Computers are notoriously fickle about who they speak to and how they expect to be addressed. A barrage of errors await any app that breaks these social norms - it can be enough to make your app want to stay quietly in the corner rather than jump in and face potential rejection.

Thankfully, this social anxiety can be eased if your app already knows someone who understands the dynamics of the group - someone cool and suave like SSDP.

Photo of a group of friends sitting precariously on a range

This post will gradually build up to a working example however if you want to jump ahead then head on over to the completed example and take a look at SSDPSearchSessionConfiguration, SSDPSearchSession, UDPSocketController, UDPSocket, SSDPServiceParser and SSDPService to see how things end up.

If you are already familiar with SSDP, feel free to skip the next section and head directly to building a working Swift solution.

Getting to know SSDP

SSDP (Simple Service Discovery Protocol) is a discovery protocol used to determine what services are available on a network. It is defined as part of the UPnP spec. SSDP is a zero-configuration networking protocol designed to allow nodes to be added and removed from a network without any involvement from a central service such as DNS or by assigning static IP addresses to specific nodes. This decentralised, dynamic approach is possible because SSDP uses UDP as it's underlying transportation protocol which allows for multicast communication.

Diagram showing sending a multicast message on a network

Multicasting allows a node to transmit one message onto the network and for that message to be forwarded on to all interested nodes on the network without the sender node having to know what other network nodes are available (forwarding happens at the IP routing level). SSDP takes advantage of this forwarding functionality to allow any node to ask other nodes if they support a particular service, or conversely for a node which offers services to tell other nodes about those services.

For multicast messages, IANA has reserved the IPv4 address 239.255.255.250 and port 1900 for SSDP.

SSDP messages conform to the header field format of HTTP 1.1. It's important to note that SSDP does not allow any message to contain a body; everything is shared via those header fields.

An SSDP node is either a root device or control point. A root device offers one or more SSDP services; a control point uses SSDP services.

When a root device is responding to a discovery message it does so by sending a unicast (to a single specific node) message directly to the control point.

Diagram showing sending a unicast message on a network

That's a lot of information to take in 😥. If it doesn't all make sense that's ok, most of the details will come up again later, and when seen in context, those details are easier to understand.

SSDP messages fall into two categories:

  1. Discovery
  2. Advertisement

This post is mainly concerned with Discovery messages but to ensure that we have the fullest possible understanding of SSDP I'll cover Advertisement messages as well (feel free to skip the Advertisement section).

Discovery

Discovery involves two message types:

  1. Request
  2. Response

A request message is when a control point transmits an M-SEARCH message onto the network looking for a particular service (or as we shall see soon, any service) e.g.

M-SEARCH * HTTP/1.1
HOST: 239.255.255.250:1900
MAN: "ssdp:discover"
MX: 1
ST: urn:dial-multiscreen-org:service:dial:1

An M-SEARCH request message contains:

  • The host and port (HOST) the message will be sent to. Typically an M-Search message is multicast (like the example above) but can be unicast.
  • The message type (MAN), for an M-Search this is always ssdp:discover.
  • The search target (ST) of the service the search request is attempting to discover.
  • The maximum wait response time (MX) in seconds that a root device can take before responding. The MX field is an attempt to overcome a scaling issue implicit with SSDP. SSDP is a chatty protocol, in a network with a significant number of nodes that host SSDP services, sending an M-SEARCH message could result in accidentally DDOS-ing the questing node due to too many services responding at once. The MX field instructs the root device to wait a random time between 0 and MX before attempting to respond - this should allow the responses to be spaced out enough to ease the processing strain on the control point. The MX value should be between 1 and 5. Even with the MX workaround, SSDP is recommended only to be used in home or small office networks.

A root device should only respond with services that match the search target field of the the request e.g.

HTTP/1.1 200 OK
CACHE-CONTROL: max-age=3600
ST: urn:dial-multiscreen-org:service:dial:1
USN: uuid:0175c106-5400-10f8-802d-b0a7374360b7::urn:dial-multiscreen-org:service:dial:1
EXT:
SERVER: Roku UPnP/1.0 MiniUPnPd/1.4
LOCATION: http://192.168.1.104:8060/dial/dd.xml

An M-Search response message contains:

  • The cache-control (CACHE-CONTROL) value to determine for how long the message is valid.
  • The search target (ST) of the service that is responding. ST should be common across all devices of this type.
  • The unique service name (USN) to identify the service.
  • The server system information (SERVER) value providing information in the following format: [OS-Name] UPnP/[Version] [Product-Name]/[Product-Version].
  • The location URL (LOCATION) to allow the control point to gain more information about this service.

The EXT field is required for backwards compatibility with UPnP 1.0 but can otherwise be ignored.

An advertisement message is when a root device shares the status of each service it offers with the other nodes on the network.

There are three types of advertisement:

  1. Alive
  2. Update
  3. ByeBye

An alive message allows interested devices to know that a service is available. An alive message is a multicast NOTIFY message e.g.

NOTIFY * HTTP/1.1
HOST: 239.255.255.250:1900
CACHE-CONTROL: max-age=3600
NT: urn:dial-multiscreen-org:service:dial:1
NTS: ssdp:alive
LOCATION: http://192.168.1.104:8060/dial/dd.xml
USN: uuid:0175c106-5400-10f8-802d-b0a7374360b7::urn:dial-multiscreen-org:service:dial:1

An alive message contains:

  • The host and port (HOST) the message will be sent to.
  • The cache-control (CACHE-CONTROL) value to determine for how long the message is valid.
  • The notification type (NT) that defines the service it offers (the equivalent of ST in an M-Search message).
  • The notification subtype (NTS), for an alive message this will always be ssdp:alive (the equivalent of MAN in an M-Search message).
  • The location URL (LOCATION) to allow a receiving control point to gain more information about this service.
  • The unique service name (USN) to identify the service.

An update message allows changes to a service to be shared. An update message is also a multicast NOTIFY message like the alive message e.g.

NOTIFY * HTTP/1.1
HOST: 239.255.255.250:1900
NT: urn:dial-multiscreen-org:service:dial:1
NTS: ssdp:update
LOCATION: http://192.168.1.160:8060/dial/dd.xml
USN: uuid:0175c106-5400-10f8-802d-b0a7374360b7::urn:dial-multiscreen-org:service:dial:1

An update message has the same header fields as an alive message with only the NTS value differing between them.

A byebye message allows any interested nodes to know when a service is about to be removed from the network. A byebye message should be sent for each valid (non-expired) alive message that was sent. A byebye message is a multicast NOTIFY message e.g.

NOTIFY * HTTP/1.1
HOST: 239.255.255.250:1900
NT: urn:dial-multiscreen-org:service:dial:1
NTS: ssdp:byebye
USN: uuid:0175c106-5400-10f8-802d-b0a7374360b7::urn:dial-multiscreen-org:service:dial:1

Again a byebye message has a very similar structure to both an alive and update message only omitting the LOCATION header field and having a different NTS value.

Now that we have an understanding of what SSDP is let's get back to the solution.

Getting to know who's there 🔭

As SSDP communication is built on top of UDP, it isn't possible to use URLSession to send an M-Search message instead we need to read and write from a socket manually. I was tempted to dive in and write a simple socket layer to handle this, but the more I read up about the various network setups I would have to support, the more writing a socket layer started to look like an unforgiving task. Instead, I decided to introduce a 3rd-party dependency into the project: BlueSocket. BlueSocket will handle the nitty-gritty socket communication and free us up to focus on sending and receiving SSDP messages.

In the example project, CocoaPods is used to manage this dependency.

At the time of writing (April, 2019) the new(ish) Network framework doesn't support UDP multicasting.

Since I wrote this article, Apple has released iOS 14, which contained a range of user privacy improvements. One area of focus was around how an app accesses the local network. There are two changes that affect the below example; firstly, before accessing the local network, iOS will request permission from the user as it does for, e.g. accessing the camera and secondly, any app that wants to multicast on a network must also have the com.apple.developer.networking.multicast entitlement enabled - this entitlement needs to be requested from Apple. Without both the user permission and the entitlement, the below example won't be able to send multicast messages from a device (everything will still work on the simulator) - see this note for more details.

Before we get started, let's look at what we are going to build:

Class diagram showing the completed SSDP Discovery system

  • SSDPSearchSessionConfiguration represents the configuration of an SSDP search session.
  • SSDPSearchSession is responsible for triggering a search request, parsing and filtering received responses and passing back those services that match our search terms to whoever requested the search. A typical search session will write multiple search requests to the socket to try and overcome the unreliable nature of UDP.
  • SSDPSearchSessionDelegate is a protocol that informs the delegator of any found services, any errors encounter and ultimately that the search session has ended.
  • SocketControllerFactory is responsible for producing sockets controllers configured to use sockets that use a specific transport protocol.
  • UDPSocketController is a wrapper around a Socket instance configured to use UDP. UDPSocketController helps to hide the messiness of socket communication.
  • UDPSocketControllerDelegate a protocol that informs the delegator of any responses received and any errors encountered.
  • SocketFactory is responsible for producing fully configured sockets that are ready to be written/read to/from.
  • UDPSocket a Socket instance configured to use UDP. As Socket is from the BlueSocket dependency, Socket is wrapped in a protocol to limit the spread of BlueSocket in the project.
  • SSDPServiceParser is responsible for parsing an SSDP response into Service instance. If the SSDP response is invalid, no service is created.
  • SSDPService represents an SSDP service.

Don't worry if that doesn't all make sense yet, we will look into each class in greater depth below.

Now that we know where we are going let's start at the bottom with UDPSocket and work our way up.

Ideally when adding a 3rd-party dependency to our projects we want to hide that dependency behind a facade which should make removing that dependency easier by limiting it's spread in the project. In Swift we can use a protocol and an extension to wrap that 3rd-party dependency inside our facade:

enum UDPSocketError: Error {
    case addressCreationFailure
    case writeError(underlayingError: Error)
    case readError(underlayingError: Error)
}

protocol UDPSocketProtocol {
    func write(_ string: String, to host: String, on port: UInt) throws
    func readDatagram(into data: inout Data) throws
    func close()
}

extension Socket: UDPSocketProtocol {
    // 1
    func write(_ string: String, to host: String, on port: UInt) throws {
        guard let signature = self.signature, signature.socketType == .datagram, signature.proto == .udp else {
            fatalError("Only UDP sockets can use this method")
        }

        guard let address = Socket.createAddress(for: host, on: Int32(port)) else {
            throw(UDPSocketError.addressCreationFailure)
        }
        do {
            try write(from: string, to: address)
        } catch {
            throw(UDPSocketError.writeError(underlayingError: error))
        }
    }

    // 2
    func readDatagram(into data: inout Data) throws {
        guard let signature = self.signature, signature.socketType == .datagram, signature.proto == .udp else {
            fatalError("Only UDP sockets can use this method")
        }

        do {
            let (_,_) = try readDatagram(into: &data)
        } catch {
            throw(UDPSocketError.readError(underlayingError: error))
        }
    }
}

Socket does a lot more than what we need for our SSDP solution. UDPSocketProtocol reduces the range of tasks that can be performed on a Socket instance to only the three that we need: write, read and close. As well as reducing scope, UDPSocketProtocol also simplifies the interface of Socket by wrapping the Socket methods in our own methods.

  1. The Socket type in BlueSocket can be used to create sockets with different configurations. In UDPSocketProtocol, we only want to cater for sockets configured that using UDP to send datagram messages, so in write(_:to:on:) a check is made to determine if this Socket instance has been configured for UDP and datagram - if it hasn't then a fatal error is thrown as this is a developer error. The write(from:to:) method on Socket takes an Address type which is defined in Socket - as mentioned above we want to limit the spread of BlueSocket in the project, so the write method defined in UDPSocketProtocol doesn't use Address but rather sticks with two String parameters host and port. In the extension, these parameters are used to create an Address instance which is then forwarded to the write(from:to:) method on Socket. If for some reason an Address instance can't be created, an exception is thrown. It's also possible for an exception to be thrown when writing to the socket, if an exception is thrown that exception is caught, wrapped inside an UDPSocketError case before a new exception is thrown.
  2. Just like with write(_:to:on:), in readDatagram(into:) a check is made to ensure that we are dealing with a socket configured to UDP and datagram. The readDatagram(into:) method on Socket returns the number of bytes read as an Int and the address that sent those bytes as an Address. We aren't interested in either of those return values, so the readDatagram(into:) defined in SocketProtocol doesn't have a return type - our readDatagram(into:) just eats those details. If so some reason when reading from the socket an exception is thrown, this exception is caught, wrapped inside an UDPSocketError case before a new exception is thrown.

The close() on Socket fits our needs perfectly so we don't need to wrap that method.

UDPSocketProtocol does a good job of hiding Socket but it can be called on with all configurations of Socket when we really only intend for it be called on UDP sockets sending datagram messages so lets make making that socket configuration as easy as possible:

extension Socket {
    static func createUDPSocket() throws -> UDPSocketProtocol {
        return try Socket.create(type: .datagram, proto: .udp)
    }
}

You will need to import the Socket module into any class that uses Socket.

Now that we have UDPSocketProtocol, lets build Socket instances wrapped in it:

protocol SocketFactoryProtocol {
    func createUDPSocket() -> UDPSocketProtocol?
}

class SocketFactory: SocketFactoryProtocol {

    // MARK: - UDP

    func createUDPSocket() -> UDPSocketProtocol? {
        guard let socket = try? Socket.createUDPSocket() else {
            return nil
        }

        return socket
    }
}

The above factory, attempts to create a UDP socket, if successful that Socket instance is returned hidden behind the UDPSocketProtocol protocol; if unsuccessful, nil is returned.

SocketFactory conforms to SocketFactoryProtocol so that when it is injected into UDPSocketController - during unit testing we can replace the actual factory with a mock factory that conforms to that protocol. I use this technique throughout this example, so any other protocols that are named after a concrete type are for this purpose.

Now that we have our socket, let's try writing to it:

protocol UDPSocketControllerProtocol: AnyObject {
    var state: UDPSocketControllerState { get }

    func write(message: String)
    func close()
}

// 1
enum UDPSocketControllerState {
    case ready
    case active
    case closed

    var isReady: Bool {
        self == .ready
    }

    var isActive: Bool {
        self == .active
    }

    var isClosed: Bool {
        self == .closed
    }
}

class UDPSocketController: UDPSocketControllerProtocol {
    private(set) var state: UDPSocketControllerState = .ready

    private let socket: UDPSocketProtocol

    private let host: String
    private let port: UInt

    private let callbackQueue: OperationQueue
    private let socketWriterQueue = DispatchQueue(label: "com.williamboles.udpsocket.writer.queue",  attributes: .concurrent)

    // MARK: - Init

    // 2
    init?(host: String, port: UInt, socketFactory: SocketFactoryProtocol) {
        guard let socket = socketFactory.createUDPSocket() else {
            return nil
        }

        self.host = host
        self.port = port
        self.socket = socket
    }

    // MARK: - Write

    // 3
    func write(message: String) {
        guard !state.isClosed else {
            os_log(.info, "Attempting to write to a closed socket")
            return
        }

        state = .active

        write(message: message, on: socketWriterQueue)
    }

    // 4
    private func write(message: String, on queue: DispatchQueue) {
        queue.async {
            do {
                try self.socket.write(message, to: self.host, on: self.port)
            } catch {
                self.closeAndReportError(error)
            }
        }
    }

    // MARK: - Close

    private func closeAndReportError(_ error: Error) {
        close()
        os_log(.info, "Error received: \r%{public}@", error)
        //TODO: Implement reporting error
    }

    func close() {
        state = .closed
        socket.close()
    }
}

Let's look at what we did above:

  1. A socket can be in 3 states: ready, active and closed - UDPSocketControllerState represents these states. As UDPSocketController interacts with its socket, it will move through states. This will ensure that we don't try to write to a socket that can't be written to.
  2. To write a message to a socket, we need the host and port on the remote machine that we want to communicate with. The init'er of UDPSocketController accepts this host and port. It also accepts a socket factory instance which is used to create a UDP socket.
  3. When writing to a socket, we only want to allow communication with a socket that hasn't been closed so the first action of the write(message:) is to check if state is in a closed state.
  4. Writing to a socket can be a time-consuming operation, so the operation is pushed onto a background queue: socketWriterQueue. As a write operation can throw an exception, we wrap that operation in a do...catch whereupon catching an exception the socket is closed, and any error reported (we will implement this shortly).

UDPSocketController is designed to be tied to one host and port. If that host and port needs to be changed, then a new instance of UDPSocketController needs to be created.

Let's build a factory to produce socket controllers:

protocol SocketControllerFactoryProtocol {
    func createUDPSocketController(host: String, port: UInt, socketFactory: SocketFactoryProtocol) -> UDPSocketControllerProtocol?
}

class SocketControllerFactory: SocketControllerFactoryProtocol {

    // MARK: - UDP

    func createUDPSocketController(host: String, port: UInt, socketFactory: SocketFactoryProtocol) -> UDPSocketControllerProtocol? {
        UDPSocketController(host: host, port: port, socketFactory: socketFactory)
    }
}

Now that we have a socket controller and a socket to write to let's look at how to give it an M-Search message to send.

3 pieces of information are required to send an M-Search message:

  1. Search target (ST).
  2. The IP address and port (HOST).
  3. Maximum wait response time (MX).

These pieces of information can be represented as:

struct SSDPSearchSessionConfiguration {
    let searchTarget: String
    let host: String
    let port: UInt
    let maximumWaitResponseTime: TimeInterval

    // MARK: - Init

    init(searchTarget: String, host: String, port: UInt, maximumWaitResponseTime: TimeInterval) {
        assert(maximumWaitResponseTime >= 1 && maximumWaitResponseTime <= 5, "maximumWaitResponseTime should be between 1 and 5 (inclusive)")

        self.searchTarget = searchTarget
        self.host = host
        self.port = port
        self.maximumWaitResponseTime = maximumWaitResponseTime
    }
}

SSDPSearchSessionConfiguration has a custom initialiser to allow for an assertion to be performed on the value of maximumWaitResponseTime (which needs to be between 1 and 5 inclusive). I believe having to manually write this custom initialiser is a price worth paying to allow for quicker feedback during development if an invalid value is passed in (by causing the app to crash).

While it's possible to send unicast M-Search messages, here we are only interested in sending multicast messages so to make things easier let's add a small factory method to return a preconfigured multicast SSDPSearchSessionConfiguration instance:

extension SSDPSearchSessionConfiguration {

    static func createMulticastConfiguration(forSearchTarget searchTarget: String, maximumWaitResponseTime: TimeInterval = 3) -> SSDPSearchSessionConfiguration {
        let configuration = SSDPSearchSessionConfiguration(searchTarget: searchTarget, host: "239.255.255.250", port: 1900, maximumWaitResponseTime: maximumWaitResponseTime)

        return configuration
    }
}

Setting the searchTarget to ssdp:all should cause all root devices to respond with their full range of SSDP services.

With this configuration it is possible to build a simple searcher class to drive any UDPSocketController instance:

protocol SSDPSearchSessionProtocol {
    func startSearch()
    func stopSearch()
}

class SSDPSearchSession: SSDPSearchSessionProtocol {
    private let socketController: UDPSocketControllerProtocol
    private let configuration: SSDPSearchSessionConfiguration

    // 1
    private lazy var mSearchMessage = {
        // Each line must end in `\r\n`
        return "M-SEARCH * HTTP/1.1\r\n" +
            "HOST: \(configuration.host):\(configuration.port)\r\n" +
            "MAN: \"ssdp:discover\"\r\n" +
            "ST: \(configuration.searchTarget)\r\n" +
            "MX: \(Int(configuration.maximumWaitResponseTime))\r\n" +
        "\r\n"
    }()

    // MARK: - Init

    // 2
    init?(configuration: SSDPSearchSessionConfiguration, socketControllerFactory: SocketControllerFactoryProtocol = SocketControllerFactory()) {
        guard let socketController = socketControllerFactory.createUDPSocketController(host: configuration.host, port: configuration.port, socketFactory: SocketFactory()) else {
            return nil
        }
        self.socketController = socketController
        self.configuration = configuration
    }

    deinit {
        stopSearch()
    }

    // MARK: - Search

    // 3
    func startSearch() {
        os_log(.info, "SSDP search session starting")
        writeMessageToSocket(mSearchMessage)
    }

    func stopSearch() {
        os_log(.info, "SSDP search session stopping")
        close()
    }

    // MARK: - Close

    // 4
    private func close() {
        if socketController.state.isActive {
            socketController.close()
        }
    }

    // MARK: - Write

    private func writeMessageToSocket(_ message: String) {
        os_log(.info, "Writing to socket: \r%{public}@", message)
        socketController.write(message: message)
    }
}

SSDPSearchSession above:

  1. Configures the M-Search message using the SSDPSearchSessionConfiguration instance.
  2. Creates an UDPSocketController instance.
  3. Writes the M-Search message to the socket controller.
  4. Closes the socket controller if it's active.

With the M-Search message you may have noticed the \r\n character sequence at the end of each line - don't be tempted to remove this from the M-Search message as the \r\n sequence is part of the protocol spec.

SSDPSearchSession is designed to be a single-use instance - once stopSearch() is called and the socket closed, a new instance of SSDPSearchSession is needed to perform another search.

Don't forget to add import os to get the os_log statements to compile.

Calling startSearch() should cause an M-Search message to be written to the network however as grizzly, well-travelled developers we know that trusting our code to do something without testing it, is a recipe for disappointment 😞. To test that this message is being written to the network, we can snoop on our network traffic using tcpdump.

To test on the simulator, open your terminal and run:

sudo tcpdump -vv -A -s 0 'port 1900 and host 239.255.255.250 and udp'

-vv: verbose output.
-A: print each packet in ASCII.
-s 0: sets the amount of captured data for each frame. Using 0 sets the amount to the default: 65535 bytes - we explicitly set this here for backwards compatibility with recent older versions of tcpdump.
'port 1900 and host 239.255.255.250 and udp' is using Berkeley Packet Filter (BPF) syntax to filter what traffic we see.

The above command will capture all the UDP traffic using host 239.255.255.250 on port 1900 (i.e. SSDP traffic) on your local machine.

Screenshot of tcpdump running

If you are using a VPN, you may need to disable it to see anything in the console.

Testing on a device is slightly more difficult. We need to tell tcpdump which device to snoop on by creating a remote virtual interface using rvictl:

Connect your device and grab its UDID, then open terminal and run:

rvictl -s {UDID} && sudo tcpdump -vv -A -s 0 -i rvi0 'port 1900 and host 239.255.255.250 and udp'

Replacing {UDID} with the UDID of the device.

You should see similar traffic to what you would if testing on the simulator.

Run rvictl -x {UDID} to stop the remote virtual interface and Ctrl-C to kill tcpdump.

Now that we have confirmation that M-Search messages are being sent, let's build the functionality to parse any responses we may receive. First UDPSocketController needs to read anything sent to the socket:

class UDPSocketController: UDPSocketControllerProtocol {
    //Omitted other properties

    // 1
    private let socketListeningQueue = DispatchQueue(label: "com.williamboles.udpsocket.listen.queue",  attributes: .concurrent)

    //Omitted other methods

    // MARK: - Write

    func write(message: String) {
        //Omitted code

        // 2
        let shouldStartListening = state.isReady
        state = .active

        if shouldStartListening {
           startListening(on: socketListeningQueue)
        }

        //Omitted code
    }

    // MARK: - Listen

    // 3
    private func startListening(on queue: DispatchQueue) {
        queue.async {
            do {
                repeat {
                    var data = Data()
                    try self.socket.readDatagram(into: &data) //blocking call
                    self.reportResponseReceived(data)
                } while self.state.isActive
            } catch {
                if self.state.isActive { // ignore any errors for non-active sockets
                    self.closeAndReportError(error)
                }
            }
        }
    }

    private func reportResponseReceived(_ data: Data) {
        os_log(.info, "Response received: \r%{public}@", response)
        //TODO: Implement reporting response received
    }

    //Omitted other methods
}

With the above changes, UDPSocketController is now able to read from its socket.

  1. Reading from a BlueSocket socket configured to read datagram messages using UDP is a blocking call - any thread that readDatagram(into:) is called on will be blocked at that line until there is data there to be read. To avoid the app from freezing, reading from the socket must be pushed off the caller queue and onto a background queue: socketWriterQueue.
  2. We only need to configure the socket to listen once - on the first write.
  3. Once a response is received, that response is converted into a string and (for the moment) logged. Finally, if the controller is still listening for responses, the socket is polled again. As a read operation can throw an exception, we wrap that operation in a do...catch whereupon catching an exception the socket is closed, and any error reported (we will implement this shortly). An interesting point to note is that closing a socket that is being polled will throw an exception. So when an exception is thrown during polling, we only care about that exception if the session is listening.

If you have devices on your network that support SSDP, you should start to see responses in the Console when running the above code. However, if you don't, it's possible to fake a response using netcat. You will need to extract the host and port from the M-Search request via tcpdump and run the following command:

echo "HTTP/1.1 200 OK\r\nCache-Control: max-age=3600\r\nST: urn:dial-multiscreen-org:service:dial:1\r\nUSN: uuid:0175c106-5400-10f8-802d-b0a7374360b7::urn:dial-multiscreen-org:service:dial:1\r\nExt: \r\nServer: Roku UPnP/1.0 MiniUPnPd/1.4\r\nLOCATION: http://192.168.1.104:8060/\r\n\r\n" | nc -u {host} {port}

Replacing {host} {port} with the extracted values.

The above command will send a response pretending to be a Roku set-top box.

Now that it is possible to read and write from a socket, lets pass any responses (and any errors) out of our socket controller:

protocol UDPSocketControllerDelegate: AnyObject {
    func controller(_ controller: UDPSocketControllerProtocol, didReceiveResponse response: Data)
    func controller(_ controller: UDPSocketControllerProtocol, didEncounterError error: Error)
}
protocol UDPSocketControllerProtocol: AnyObject {
    //Omitted other properties
    var delegate: UDPSocketControllerDelegate? { get set }

    //Omitted methods
}

class UDPSocketController: UDPSocketControllerProtocol {
    //Omitted other properties

    weak var delegate: UDPSocketControllerDelegate?

    private let callbackQueue: OperationQueue

    init?(host: String, port: UInt, socketFactory: SocketFactoryProtocol, callbackQueue: OperationQueue) {
        //Omitted rest of method

        self.callbackQueue = callbackQueue
    }

    //Omitted other methods

    private func reportResponseReceived(_ data: Data) {
        callbackQueue.addOperation {
           self.delegate?.controller(self, didReceiveResponse: data)
        }
    }

    private func closeAndReportError(_ error: Error) {
        close()
        callbackQueue.addOperation {
            self.delegate?.controller(self, didEncounterError: error)
        }
    }
}

With the above changes, SSDPSearchSession can now add itself as the delegate of UDPSocketControllerDelegate. To make the communication via that delegate more predictable when creating that socket, the thread that the communication will happen on is passed in.

Let's update the SocketControllerFactory to support the callbackQueue:

protocol SocketControllerFactoryProtocol {
    func createUDPSocketController(host: String, port: UInt, socketFactory: SocketFactoryProtocol, callbackQueue: OperationQueue) -> UDPSocketControllerProtocol?
}

class SocketControllerFactory: SocketControllerFactoryProtocol {

    // MARK: - UDP

    func createUDPSocketController(host: String, port: UInt, socketFactory: SocketFactoryProtocol, callbackQueue: OperationQueue) -> UDPSocketControllerProtocol? {
        UDPSocketController(host: host, port: port, socketFactory: socketFactory, callbackQueue: callbackQueue)
    }
}

Now, let's update SSDPSearchSession to be the delegate of UDPSocketControllerDelegate:

class SSDPSearchSession: SSDPSearchSessionProtocol, UDPSocketControllerDelegate {
    //Omitted properties

    init?(configuration: SSDPSearchSessionConfiguration, socketControllerFactory: SocketControllerFactoryProtocol = SocketControllerFactory()) {
        guard let socketController = socketControllerFactory.createUDPSocketController(host: configuration.host, port: configuration.port, socketFactory: SocketFactory(), callbackQueue: .main) else {
            return nil
        }

        //Omitted other assignments

        self.socketController.delegate = self
    }

    //Omitted other methods

    // MARK: - UDPSocketControllerDelegate

    func controller(_ controller: UDPSocketControllerProtocol, didReceiveResponse response: Data) {
        os_log(.info, "Received response: \r%{public}@", response)
        //TODO: Implement
    }

    func controller(_ controller: UDPSocketControllerProtocol, didEncounterError error: Error) {
        os_log(.info, "Encountered socket error: \r%{public}@", error.localizedDescription)
        close()
        //TODO: Implement
    }
}

SSDPSearchSession can now receive responses from its UDPSocketController, let's turn those responses into something useful:

struct SSDPService {
    let cacheControl: Date
    let location: URL
    let server: String
    let searchTarget: String
    let uniqueServiceName: String
    let otherHeaders: [String: String]
}

An M-Search response has mandatory and optional/custom header fields. In SSDPService, the mandatory header fields are mapped to named properties, and the optional/custom header fields are mapped to the otherHeaders property. Each optional/custom header field is represented as a dictionary with the field name as the dictionary key and field's value as dictionary value.

To get an SSDPService instance, it needs to be parsed:

private enum SSDPServiceResponseKey: String {
    case cacheControl = "CACHE-CONTROL"
    case location = "LOCATION"
    case server = "SERVER"
    case searchTarget = "ST"
    case uniqueServiceName = "USN"
}

protocol SSDPServiceParserProtocol {
    func parse(_ data: Data) -> SSDPService?
}

class SSDPServiceParser: SSDPServiceParserProtocol {
    private let dateFactory: DateFactoryProtocol

    // Init

    init(dateFactory: DateFactoryProtocol =  DateFactory()) {
        self.dateFactory = dateFactory
    }

    // MARK: - Parse

    func parse(_ data: Data) -> SSDPService? {
        guard let responseString = String(data: data, encoding: .utf8) else {
            return nil
        }

        os_log(.info, "Received SSDP response: \r%{public}@", responseString)

        // 1
        var responseDict = parseResponseIntoDictionary(responseString)

        // 2
        guard let cacheControl = parseCacheControl(responseDict[SSDPServiceResponseKey.cacheControl.rawValue]),
            let location = parseLocation(responseDict[SSDPServiceResponseKey.location.rawValue]),
            let server = responseDict[SSDPServiceResponseKey.server.rawValue],
            let searchTarget = responseDict[SSDPServiceResponseKey.searchTarget.rawValue],
            let uniqueServiceName = responseDict[SSDPServiceResponseKey.uniqueServiceName.rawValue] else {
                return nil
        }

        // 3
        responseDict.removeValue(forKey: SSDPServiceResponseKey.cacheControl.rawValue)
        responseDict.removeValue(forKey: SSDPServiceResponseKey.location.rawValue)
        responseDict.removeValue(forKey: SSDPServiceResponseKey.server.rawValue)
        responseDict.removeValue(forKey: SSDPServiceResponseKey.searchTarget.rawValue)
        responseDict.removeValue(forKey: SSDPServiceResponseKey.uniqueServiceName.rawValue)

        // 4
        return SSDPService(cacheControl: cacheControl, location: location, server: server, searchTarget: searchTarget, uniqueServiceName: uniqueServiceName, otherHeaders: responseDict)
    }

    private func parseResponseIntoDictionary(_ response: String) -> [String: String] {
        var elements = [String: String]()
        for element in response.split(separator: "\r\n") {
            let keyValuePair = element.split(separator: ":", maxSplits: 1)
            guard keyValuePair.count == 2 else {
                continue
            }

            let key = String(keyValuePair[0]).uppercased().trimmingCharacters(in: CharacterSet.whitespacesAndNewlines)
            let value = String(keyValuePair[1]).trimmingCharacters(in: CharacterSet.whitespacesAndNewlines)

            elements[key] = value
        }

        return elements
    }

    private func parseCacheControl(_ value: String?) -> Date? {
        guard let cacheControlRange = value?.range(of: "[0-9]+$", options: .regularExpression),
            let cacheControlString = value?[cacheControlRange],
            let cacheControlTimeInterval = TimeInterval(cacheControlString) else {
                return nil
        }

        let currentDate = dateFactory.currentDate()
        return currentDate.addingTimeInterval(cacheControlTimeInterval)
    }

    private func parseLocation(_ value: String?) -> URL? {
        guard let urlString = value,
            let url = URL(string: urlString) else {
                return nil
        }

        return url
    }
}

SSDPServiceParser above takes the string response and attempts to parse it into an SSDPService instance by:

  1. Splitting the string into a dictionary using \r\n to determine fields and : to determine key and value pairs.
  2. The response dictionary is checked to ensure that all mandatory fields are present. If any of the mandatory fields are missing, the SSDP response is considered invalid, and nil is returned.
  3. As an SSDP response can contain non-mandatory fields, the mandatory fields are stripped from the response dictionary so leaving the only the non-mandatory fields present.
  4. An SSDPService is created using the mandatory and non-mandatory fields.

I could have combined SSDPService and SSDPServiceParser into the one type with the init'er accepting the string response, but I think having an independent parser makes the code easier to read.

The DateFactory is used to make unit-testing this parser possible:

protocol DateFactoryProtocol {
    func currentDate() -> Date
}

class DateFactory: DateFactoryProtocol {

    // MARK: - Current

    func currentDate() -> Date {
        return Date()
    }
}

Let's update SSDPSearchSession to use SSDPServiceParser:

class SSDPSearchSession: SSDPSearchSessionProtocol, UDPSocketControllerDelegate {
    //Omitted other properties

    private let parser: SSDPServiceParserProtocol

    // MARK: - Init

    init?(configuration: SSDPSearchSessionConfiguration, socketControllerFactory: SocketControllerFactoryProtocol = SocketControllerFactory(), parser: SSDPServiceParserProtocol = SSDPServiceParser()) {
        //Omitted other assignments
        self.parser = parser
    }

    //Omitted methods

    // MARK: - UDPSocketControllerDelegate

    func controller(_ controller: UDPSocketControllerProtocol, didReceiveResponse response: Data) {
        guard !response.isEmpty,
            let service = parser.parse(response) else {
                return
        }

        os_log(.info, "Received service \r%{public}@", service)
    }

    //Omitted methods
}

Once you start parsing responses, you will notice that some root devices respond to any M-Search message they receive rather than just those discovery requests that match one of their services. To counter these chatty root devices we need to ensure that the parsed SSDPService instance is the searched-for-service:

class SSDPSearchSession {
    //Omitted properties & methods

    // MARK: - UDPSocketControllerDelegate

    func controller(_ controller: UDPSocketControllerProtocol, didReceiveResponse response: Data) {
       guard !response.isEmpty,
           let service = parser.parse(response),
           searchedForService(service) else {
               return
       }

       os_log(.info, "Received service \r%{public}@", service)
    }

    private func searchedForService(_ service: SSDPService) -> Bool {
       return service.searchTarget.contains(configuration.searchTarget) || configuration.searchTarget == "ssdp:all"
    }

    //Omitted methods
}

If the search target is set to the special ssdp:all value, all services that respond are treated as valid.

SSDPSearchSession is doing good work but isn't able to share the fruits of its labour with anyone. Let's add in a delegate to tell interested parties how things are going with the search:

protocol SSDPSearchSessionDelegate: AnyObject {
    func searchSession(_ searchSession: SSDPSearchSession, didFindService service: SSDPService)
    func searchSession(_ searchSession: SSDPSearchSession, didEncounterError error: SSDPSearchSessionError)
    func searchSessionDidStopSearch(_ searchSession: SSDPSearchSession, foundServices: [SSDPService])
}

enum SSDPSearchSessionError: Error {
    case searchAborted(Error)
}

class SSDPSearchSession: SSDPSearchSessionProtocol, UDPSocketControllerDelegate {
    //Omitted properties

    // 1
    private var servicesFoundDuringSearch = [SSDPService]()

    weak var delegate: SSDPSearchSessionDelegate?

    //Omitted methods

    func stopSearch() {
        os_log(.info, "SSDP search session stopping")
        close()

        // 2
        delegate?.searchSessionDidStopSearch(self, foundServices:servicesFoundDuringSearch)
    }

    //Omitted methods

    // MARK: - UDPSocketControllerDelegate

    func controller(_ controller: UDPSocketControllerProtocol, didReceiveResponse response: Data) {
        guard !response.isEmpty,
            let service = parser.parse(response),
            searchedForService(service) else {
                return
        }

        os_log(.info, "Received a valid service response")

        servicesFoundDuringSearch.append(service)

        // 3
        delegate?.searchSession(self, didFindService: service)
    }

    func controller(_ controller: UDPSocketControllerProtocol, didEncounterError error: Error) {
        os_log(.info, "Encountered socket error: \r%{public}@", error.localizedDescription)

        // 4
        let wrappedError = SSDPSearchSessionError.searchAborted(error)
        delegate?.searchSession(self, didEncounterError: wrappedError)
        close()
    }

    //Omitted methods
}

With the above changes, we now:

  1. Store all valid services that have been received during that search session.
  2. Inform the delegate when the search has been stopped, returning all valid services found.
  3. Inform the delegate when a valid service has been found.
  4. Wrap any received error in an SSDPSearchSessionError error and inform the delegate of that error.

It's interesting to note that searchSession(_:, didFindService:) is called as soon as a valid SSDPService instance is parsed rather than waiting for all services to be parsed. This will allow the app to respond immediately to any found services.

An alternative to delegation would have been to pass a closure into startSearch(). In fact, using a closure was my preferred option to begin with. However, after experimenting, I felt that having one closure handling three possible states resulted in code that was very busy and that readability suffered because of this.

Every M-Search message contains an MX value that represents the maximum time a service can wait before responding. When this time has elapsed, it can be confidently assumed that all services that can respond, have responded. Meaning that MX can be used as a timeout for the search session:

class SSDPSearchSession: SSDPSearchSessionProtocol, UDPSocketControllerDelegate {
    //Omitted other properties

    private let searchTimeout: TimeInterval

    private var timeoutTimer: Timer?

    // MARK: - Init

    init?(configuration: SSDPSearchSessionConfiguration, socketControllerFactory: SocketControllerFactoryProtocol = SocketControllerFactory(), parser: SSDPServiceParserProtocol = SSDPServiceParser()) {
        //Omitted other assignments

        self.searchTimeout = configuration.maximumWaitResponseTime + 0.1
    }

    //Omitted methods

    // MARK: - Search

    func startSearch() {
        //Omitted code

        timeoutTimer = Timer.scheduledTimer(withTimeInterval: searchTimeout, repeats: false, block: { [weak self] (timer) in
            self?.searchTimedOut()
        })
    }

    private func searchTimedOut() {
        os_log(.info, "SSDP search timed out")
        stopSearch()
    }

    //Omitted methods

    // MARK: - Close

    private func close() {
        timeoutTimer?.invalidate()
        timeoutTimer = nil

        //Omitted code
    }

    //Omitted methods
}

With the above changes, the search session is ended after maximumWaitResponseTime seconds causing the socket to be closed. The more eagle-eyed reader may have spotted that timeoutTimer has a trigger time that is 0.1 seconds longer than maximumWaitResponseTime - this is to allow any responses from root devices that waited the full maximumWaitResponseTime seconds before responding to reach the app and be processed before the search session is ended.

Some people are harder to talk to than others

If you have been combining the above code snippets into a working project, you will now be able to search for SSDP services and parse any response received. However, every so often, you may notice that an SSDP service that you know exists on the network does not respond.

🤔

As described above, SSDP uses the unreliable UDP transportation protocol (because UDP supports multicasting). UDP is unreliable because there is no acknowledgement if a message made it to its destination. This means there is no way of knowing if a service hasn't responded because the message was dropped along the way or that the service is no longer available. Unreliability isn't a great characteristic for a discovery service to have. While not foolproof, it is possible to increase the reliability of an SSDP based discovery service by sending multiple M-Search messages over the lifecycle of an SSDPSearchService instance. Sending multiple M-Search messages will increase the chances that at least one message makes it to each root device on the network. For this to be possible, our SSDPSearchService instance must exist longer than the MX value before timing out:

struct SSDPSearchSessionConfiguration {
    //Omitted properties

    let maximumSearchRequestsBeforeClosing: UInt

    // MARK: - Init

    init(searchTarget: String, host: String, port: UInt, maximumWaitResponseTime: TimeInterval, maximumSearchRequestsBeforeClosing: UInt) {
        //Omitted other assignments

        self.maximumSearchRequestsBeforeClosing = maximumSearchRequestsBeforeClosing
    }
}

extension SSDPSearchSessionConfiguration {

    static func createMulticastConfiguration(forSearchTarget searchTarget: String, maximumWaitResponseTime: TimeInterval = 3, maximumSearchRequestsBeforeClosing: UInt = 3) -> SSDPSearchSessionConfiguration {
        let configuration = SSDPSearchSessionConfiguration(searchTarget: searchTarget, host: "239.255.255.250", port: 1900, maximumWaitResponseTime: maximumWaitResponseTime, maximumSearchRequestsBeforeClosing: maximumSearchRequestsBeforeClosing)

        return configuration
    }
}

maximumSearchRequestsBeforeClosing will control how many M-Search messages are sent before the search session is closed. maximumSearchRequestsBeforeClosing needs to be at least 1, or no M-Search will be sent.

An alternative to using a count property would have been to use a total-duration property. However, the total-duration approach would have required the config-maintainer to ensure that this timeout property was always a multiple of the maximumWaitResponseTime. As having a value which isn't a multiple would result in the situation where the session was stopped before the last M-Search iteration's maximumWaitResponseTime had expired - potentially resulting in ignored responses because the root devices waited until the maximumWaitResponseTime value before responding. It's easy to imagine this mistake happening. By expressing the timeout as the value to multiply maximumWaitResponseTime by, we ensure that this error scenario can never happen.

class SSDPSearchSession: SSDPSearchSessionProtocol, UDPSocketControllerDelegate {
    //Omitted properties

    private var searchRequestTimer: Timer?

    //Omitted methods

    // MARK: - Init

    init?(configuration: SSDPSearchSessionConfiguration, socketControllerFactory: SocketControllerFactoryProtocol = SocketControllerFactory(), parser: SSDPServiceParserProtocol = SSDPServiceParser()) {
        //Omitted other assignments

        // 1
        self.searchTimeout = (TimeInterval(configuration.maximumSearchRequestsBeforeClosing) * configuration.maximumWaitResponseTime) + 0.1
    }

    // MARK: - Search

    func startSearch() {
        // 2
        guard configuration.maximumSearchRequestsBeforeClosing > 0 else {
            delegate?.searchSessionDidStopSearch(self, foundServices: servicesFoundDuringSearch)
            return
        }

        os_log(.info, "SSDP search session starting")
        sendMSearchMessages()

        //Omitted code
    }

    //Omitted methods

    // MARK: - Close

    private func close() {
        //Omitted code

        // 3
        searchRequestTimer?.invalidate()
        searchRequestTimer = nil

        //Omitted code
    }

    // MARK: Write

    private func sendMSearchMessages() {
        let message = mSearchMessage

        // 4
        if configuration.maximumSearchRequestsBeforeClosing > 1 {
            let window = searchTimeout - configuration.maximumWaitResponseTime
            let interval = window / TimeInterval((configuration.maximumSearchRequestsBeforeClosing - 1))

            searchRequestTimer = Timer.scheduledTimer(withTimeInterval: interval, repeats: true, block: { [weak self] (timer) in
                self?.writeMessageToSocket(message)
            })
        }
        writeMessageToSocket(message)
    }

    //Omitted methods
}

With the above changes, a new M-Search message is sent every maximumWaitResponseTime seconds until the desired number of messages have been sent.

  1. Increased the total search time for multiple M-Search message writes.
  2. Check that maximumSearchRequestsBeforeClosing is greater than 0 and return if it isn't.
  3. Invalidate new search request timer.
  4. Send multiple M-Search messages inside the maximum send window.

However, while making SSDPSearchSession a more reliable discovery service, sending multiple M-Search messages creates a new problem. In ideal network conditions, the same SSDP service would respond to each sent M-Search message. If these responses were just blindly passed to the delegate, the SSDPSearchService instance would in effect be spamming that delegate with the same service multiple times. Thankfully each service parsed is already stored in the servicesFoundDuringSearch array (to be used when searchSessionDidStopSearch(_:, foundServices:) is called), so to prevent becoming a spammer a check can be made to determine if an SSDPService instance representing the same service has already been passed to the delegate:

class SSDPSearchSession: SSDPSearchSessionProtocol, UDPSocketControllerDelegate {
    //Omitted properties and methods

    // MARK: - UDPSocketControllerDelegate

    func controller(_ controller: UDPSocketControllerProtocol, didReceiveResponse response: Data) {
        guard !response.isEmpty,
            let service = parser.parse(response),
            searchedForService(service),
            // 1
            !servicesFoundDuringSearch.contains(service) else {
                return
        }

        //Omitted code
    }

    //Omitted methods
}

With the above change:

  1. A check is made to see if servicesFoundDuringSearch already contains the service that has been parsed.

In order to get the above code to work, SSDPService needs to conform to Equatable:

struct SSDPService: Equatable {
    //Omitted properties

    // MARK: - Equatable

    static func == (lhs: SSDPService, rhs: SSDPService) -> Bool {
        return lhs.location == rhs.location &&
            lhs.server == rhs.server &&
            lhs.searchTarget == rhs.searchTarget &&
            lhs.uniqueServiceName == rhs.uniqueServiceName
    }
}

The above custom equality check excludes cacheControl as this will change with each response because it's based on the date when the service was parsed.

🎉🎉🎉

And that's everything you need for discovering what SSDP services are available on a network - congratulations.

Happy to get to know everyone 🎯

Social situations can be tricky. It's easy to think that you don't have anything of value to add and to allow that thought to leave you alone in the corner. However, with a little bit of effort (and bravery), you can reach out and get to know new people.

This is just as true for your app.

SSDP is a lightweight, widely supported protocol that makes it straightforward to discover services on a network. It has a few gotchas but provided that we treat it with care and don't 100% trust any root devices to behave as expected, SSDP can be a useful tool to have in the toolbox.

To see the above code snippets together in a working example, head over to the repo and clone the project.

What do you think? Let me know by getting in touch on Twitter - @wibosco