Discovering What's Out There with SSDP
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 awaits 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.
This post will explore how to build a solution that allows us to send an SSDP message onto the network to discover if the device we want to connect with is present. And importantly, why we shouldn't trust every device that responds.

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,SSDPServiceParserandSSDPServiceto see how things end up.
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 its underlying transportation protocol, which allows for multicast communication.

Multicasting allows a node to transmit one message onto the network and for that message to be forwarded onto 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.250and port1900for 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 responds to a discovery message, it does so by sending a unicast (to a single specific node) message directly to the control point.

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:
- Discovery
- Advertisement
This post is mainly concerned with Discovery messages, but to ensure that we have the fullest possible understanding of SSDP, I will also cover Advertisement messages (feel free to skip the Advertisement section).
Discovery
Discovery involves two message types:
- Request
- 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, anM-Searchmessage is multicast (like the example above), but it can also be unicast. - The message type (
MAN), for anM-Search, is alwaysssdp:discover. - The search target (
ST) of the service the search request is attempting to discover. - The maximum wait response time (
MX) in seconds that aroot devicecan take before responding. TheMXfield 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 anM-SEARCHmessage could result in accidentally DDOS-ing the questing node due to too many services responding at once. TheMXfield instructs theroot deviceto wait a random time between 0 andMXbefore attempting to respond - this should allow the responses to be spaced out enough to ease the processing strain on thecontrol point. TheMXvalue should be between 1 and 5. Even with theMXworkaround, SSDP is recommended for use only in home or small office networks.
A root device should only respond with services that match the search target field of 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.STshould 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 thecontrol pointto gain more information about this service.
The
EXTfield is required for backwards compatibility with UPnP 1.0, but can otherwise be ignored.
Advertisement
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:
- Alive
- Update
- 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 ofSTin anM-Searchmessage). - The notification subtype (
NTS), for analivemessage this will always bessdp:alive(the equivalent ofMANin anM-Searchmessage). - The location URL (
LOCATION) to allow a receivingcontrol pointto 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, except it omits the LOCATION header field and has 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, freeing 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)
Networkframework 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 on 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.multicastentitlement 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:

SSDPSearchSessionConfigurationrepresents the configuration of an SSDP search session.SSDPSearchSessionis 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 to overcome the unreliable nature of UDP.SSDPSearchSessionDelegateis a protocol that informs the delegator of any found services, any errors encountered and ultimately that the search session has ended.SocketControllerFactoryis responsible for producing socket controllers configured to use sockets that use a specific transport protocol.UDPSocketControlleris a wrapper around aSocketinstance configured to use UDP.UDPSocketControllerhelps to hide the messiness of socket communication.UDPSocketControllerDelegateis a protocol that informs the delegator of any responses received and any errors encountered.SocketFactoryis responsible for producing fully configured sockets that are ready to be written/read to/from.UDPSocketis aSocketinstance configured to use UDP. AsSocketis from theBlueSocketdependency,Socketis wrapped in a protocol to limit the spread ofBlueSocketin the project.SSDPServiceParseris responsible for parsing an SSDP response intoServiceinstance. If the SSDP response is invalid, no service is created.SSDPServicerepresents 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 its 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.
- The
Sockettype inBlueSocketcan be used to create sockets with different configurations. InUDPSocketProtocol, we only want to cater for sockets configured that using UDP to send datagram messages, so inwrite(_:to:on:)a check is made to determine if thisSocketinstance has been configured for UDP and datagram - if it hasn't then a fatal error is thrown as this is a developer error. Thewrite(from:to:)method onSockettakes anAddresstype which is defined inSocket- as mentioned above we want to limit the spread ofBlueSocketin the project, so thewritemethod defined inUDPSocketProtocoldoesn't useAddressbut rather sticks with twoStringparametershostandport. In the extension, these parameters are used to create anAddressinstance, which is then forwarded to thewrite(from:to:)method onSocket. If, for some reason, anAddressinstance 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 anUDPSocketErrorcase, before a new exception is thrown. - Just like with
write(_:to:on:), inreadDatagram(into:), a check is made to ensure that we are dealing with a socket configured for UDP and datagrams. ThereadDatagram(into:)method onSocketreturns the number of bytes read as anIntand the address that sent those bytes as anAddress. We aren't interested in either of those return values, so thereadDatagram(into:)defined inSocketProtocoldoesn't have a return type; ourreadDatagram(into:)simply ignores those details. If, for some reason, an exception is thrown when reading from the socket, this exception is caught, wrapped inside aUDPSocketErrorcase, and then a new exception is thrown.
The
close()onSocketfits 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 to be called on UDP sockets sending datagram messages, so let's 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
Socketmodule into any class that usesSocket.
Now that we have UDPSocketProtocol, let's 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.
SocketFactoryconforms toSocketFactoryProtocolso that when testingUDPSocketController, we can replace theSocketFactoryinstance with a mock factory that conforms to that protocol. I use this technique throughout this example, so any other protocols named after a concrete type serve 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:
- A socket can be in 3 states:
ready,activeandclosed-UDPSocketControllerStaterepresents these states. AsUDPSocketControllerinteracts 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. - 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
UDPSocketControlleraccepts this host and port. It also accepts a socket factory instance, which is used to create a UDP socket. - 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 ifstateis in a closed state. - 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 ado...catchblock. If an exception is caught, the socket is closed, and any error is reported (we will implement this shortly).
UDPSocketController is designed to be tied to one host and port. If the host and port need to be changed, then a new instance of UDPSocketController must 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:
- Search target (
ST). - The IP address and port (
HOST). - 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
}
}
SSDPSearchSessionConfigurationhas a custom initialiser to allow for an assertion to be performed on the value ofmaximumWaitResponseTime(which needs to be between 1 and 5 inclusive). Having to write this custom initialiser manually 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
searchTargettossdp:allshould cause allroot devicesto 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:
- Configures the
M-Searchmessage using theSSDPSearchSessionConfigurationinstance. - Creates an
UDPSocketControllerinstance. - Writes the
M-Searchmessage to the socket controller. - Closes the socket controller if it's active.
With the
M-Searchmessage, you may have noticed the\r\ncharacter sequence at the end of each line - don't be tempted to remove this from theM-Searchmessage, as the\r\nsequence 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 osto get theos_logstatements 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. Using0sets 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.

If you are using a VPN, you may need to disable it to see anything in the terminal.
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 the 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 theUDIDof 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 andCtrl-Cto killtcpdump.
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.
- Reading from a
BlueSocketsocket configured to read datagram messages using UDP is a blocking call - any thread thatreadDatagram(into:)is called on will be blocked at that line until there is data 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. - We only need to configure the socket to listen once - on the first write.
- 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...catchblock. If an exception is caught, the socket is closed, and any error is 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, where the field name serves as the dictionary key and the field's value is the 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:
- Splitting the string into a dictionary using
\r\nto determine fields and:to determine key and value pairs. - 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
nilis returned. - As an SSDP response can contain non-mandatory fields, the mandatory fields are stripped from the response dictionary, leaving only the non-mandatory fields present.
- An
SSDPServiceis created using the mandatory and non-mandatory fields.
I could have combined
SSDPServiceandSSDPServiceParserinto a single type with the initialiser 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:allvalue, 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:
- Store all valid services that have been received during that search session.
- Inform the delegate when the search has been stopped, returning all valid services found.
- Inform the delegate when a valid service has been found.
- Wrap any received error in an
SSDPSearchSessionErrorerror and inform the delegate of that error.
It's interesting to note that
searchSession(_:, didFindService:)is called as soon as a validSSDPServiceinstance 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 found that having one closure handle three possible states resulted in code that was very busy, and readability suffered as a consequence.
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 ends 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 reaches 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 reaches 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. Having a value that isn't a multiple would result in the situation where the session was stopped before the lastM-Searchiteration'smaximumWaitResponseTimehad expired, potentially resulting in ignored responses because theroot deviceswaited until themaximumWaitResponseTimevalue before responding. It's easy to imagine this mistake happening. By expressing the timeout as the value to multiplymaximumWaitResponseTimeby, 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.
- Increased the total search time for multiple
M-Searchmessage writes. - Check that
maximumSearchRequestsBeforeClosingis greater than 0 and return if it isn't. - Invalidate the new search request timer.
- Send multiple
M-Searchmessages 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:
- A check is made to see if
servicesFoundDuringSearchalready contains the service that has been parsed.
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
cacheControlbecause it changes with each response, as 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.