Last active
June 16, 2019 06:08
-
-
Save satori99/d8ad553fb328e416ce455ae58b3d79a7 to your computer and use it in GitHub Desktop.
A simple SSDP server class which responds to M-SEARCH messages
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
/** | |
* @file ssdp.js | |
* @author satori99 | |
*/ | |
const debug = require( 'debug' )( 'SSDP' ); | |
const EventEmitter = require( 'events' ); | |
const dgram = require( 'dgram' ); | |
const net = require( 'net' ); | |
const os = require( 'os' ); | |
const SSDP_MULTICAST_ADDRESS = '239.255.255.250'; | |
const SSDP_MULTICAST_PORT = 1900; | |
/** | |
* Simple SSDP Server | |
* | |
* @class | |
* @extends {EventEmitter} | |
* | |
* @todo Add IPv6 support | |
*/ | |
class Server extends EventEmitter { | |
/** | |
* Internal UDP socket | |
* | |
* @private | |
* @type {dgram.Socket} | |
*/ | |
socket = null; | |
/** | |
* Creates a new SSDP Server | |
* | |
* @constructor | |
* @param {function} [handler] - Request handler | |
*/ | |
constructor ( handler ) { | |
super(); | |
if ( typeof handler === 'function' ) this.on( 'request', handler ); | |
debug( 'new' ); | |
} | |
/** | |
* Indicates if the server is listening | |
* | |
* @readonly | |
* @type {boolean} | |
*/ | |
get listening () { | |
return this.socket !== null; | |
} | |
/** | |
* Start listening for SSDP messages | |
* | |
* @param {string} address - Bind address | |
* @returns {Promise} A promise that resolves when server is listening | |
*/ | |
listen ( address ) { | |
return new Promise( ( resolve, reject ) => { | |
if ( ! net.isIPv4( address ) ) return reject( new Error( 'invalid address' ) ); | |
if ( this.listening ) return reject( new Error( 'already listening' ) ); | |
debug( 'starting...' ); | |
this.socket = dgram.createSocket( { type: 'udp4', reuseAddr: true } ); | |
this.socket.once( 'error', err => { | |
debug( 'error', err.message ); | |
this.socket = null; | |
reject( err ); | |
} ); | |
this.socket.bind( SSDP_MULTICAST_PORT, address, () => { | |
address = this.socket.address().address; | |
if ( address === '0.0.0.0' ) { | |
Object.values( os.networkInterfaces() ) | |
.flat( 1 ) | |
.filter( iface => iface.family === 'IPv4' ) | |
.forEach( iface => { | |
debug( 'Adding SSDP multicast membership for bind address', iface.address ); | |
this.socket.addMembership( SSDP_MULTICAST_ADDRESS, iface.address ); | |
} ); | |
} else { | |
debug( 'Adding SSDP multicast membership for bind address', address ); | |
this.socket.addMembership( SSDP_MULTICAST_ADDRESS, address ); | |
} | |
this.socket.removeAllListeners( 'error' ); | |
this.socket.setMulticastTTL( 3 ); | |
this.socket.setBroadcast( true ); | |
this.socket.on( 'message', ( buffer, remoteInfo ) => { | |
const lines = buffer.toString( 'utf8' ) | |
.split( /\r?\n/ ) | |
.map( line => line.trim() ) | |
.filter( line => !! line ); | |
if ( lines.length <= 1 ) return; | |
const match = lines[ 0 ].match( /^(?<method>M-SEARCH|NOTIFY)\s+\*\s+HTTP\/1\.1$/ ); | |
if ( ! match ) return; | |
debug( 'new request', remoteInfo ); | |
const headers = Object.fromEntries( | |
lines.slice( 1 ).filter( l => l.indexOf( ':' ) !== -1 ) | |
.map( line => { | |
const { groups: { name, value } } = /^(?<name>.*?):\s*(?<value>.*)$/.exec( line ); | |
return [ | |
name.toLowerCase(), | |
Number.isInteger( value ) | |
? parseFloat( value ) | |
: value, | |
]; | |
} ) ); | |
if ( headers.host !== `${SSDP_MULTICAST_ADDRESS}:${SSDP_MULTICAST_PORT}` ) return; | |
const request = { method: match.groups.method, headers }; | |
Object.defineProperty( request, 'remote', remoteInfo ); | |
this.emit( 'request', request ); | |
} ); | |
debug( 'started' ); | |
resolve(); | |
} ); | |
} ); | |
} | |
/** | |
* Bind address | |
* | |
* @param {string} [reqAddr] - If supplied AND the server is bound to | |
* multiple addresses, the returned address will be specific to the | |
* supplied request address | |
* @return {object?} Address object | |
*/ | |
address ( reqAddr ) { | |
const address = this.socket && this.socket.address(); | |
if ( address && address.address === '0.0.0.0' && reqAddr ) { | |
const addressToInteger = addr => addr.split( '.' ).reduce( ( acc, cur ) => acc * 256 + parseInt( cur ), 0 ); | |
address.address = Object.values( os.networkInterfaces() ) | |
.flat( 1 ) | |
.filter( i => i.family === 'IPv4' ) | |
.find( i => { | |
const netmask = addressToInteger( i.netmask ); | |
const bindAaddress = addressToInteger( i.address ); | |
const reqAddress = addressToInteger( reqAddr ); | |
return ( bindAaddress & netmask ) === ( reqAddress & netmask ); | |
} ) | |
.address; | |
} | |
return address; | |
} | |
/** | |
* Sends a buffer to the destination address | |
* | |
* @param {Buffer} buffer - | |
* @param {number} port - | |
* @param {string} address - | |
* @return {Promise} Resolves on send success | |
*/ | |
send ( buffer, port, address ) { | |
return new Promise( ( resolve, reject ) => { | |
if ( ! this.listening ) return reject( new Error( 'not listening' ) ); | |
debug( 'sending', buffer.size, 'to', `${address}:${port}` ); | |
this.socket.once( 'error', reject ); | |
this.socket.send( buffer, port, address, () => { | |
this.socket.removeAllListeners( 'error' ); | |
debug( 'sent', buffer.size, 'to', `${address}:${port}` ); | |
resolve(); | |
} ); | |
} ); | |
} | |
/** | |
* Broadcasts a buffer (to the SSDP multicast address) | |
* | |
* @param {Buffer} buffer - The buffer to send | |
*/ | |
broadcast ( buffer ) { | |
return this.send( | |
buffer, | |
SSDP_MULTICAST_PORT, | |
SSDP_MULTICAST_ADDRESS | |
); | |
} | |
/** | |
* Sends an M-SEARCH response to the specified address | |
* | |
* @param {object} options - Response options | |
* @param {string} options.uuid - Device UUID | |
* @param {string} options.target - Response target | |
* @param {string} options.location - Root device location URL | |
* @param {string} options.server - Server string | |
* @param {string} [options.date="<now>"] - GMT Date string | |
* @param {string} [options.maxAge=600] - Cache control max. age in seconds | |
* @param {string} [options.mx=0] - Max. response delay | |
* @param {object} port - destination port | |
* @param {object} address - destination address | |
*/ | |
respond ( options, port, address ) { | |
const defaultOptions = { | |
date: new Date().toGMTString(), | |
maxAge: 600, | |
mx: 0, | |
}; | |
options = Object.assign( defaultOptions, options ); | |
[ 'date', 'maxAge', 'location', 'server', 'target', 'uuid' ].forEach( name => { | |
if ( options[ name ] === undefined ) throw new Error( `mssing required option: '${name}'` ); | |
} ); | |
const buffer = Buffer.from( [ | |
'HTTP/1.1 200 OK', | |
`Cache-Control: max-age=${options.maxAge}`, | |
`Date: ${options.date}`, | |
`Location: ${options.location}`, | |
`Server: ${options.server}`, | |
`ST: ${options.target}`, | |
`USN: uuid:${options.uuid}::${options.target}`, | |
`EXT:`, | |
`Content-Length: 0`, | |
'\r\n', | |
].join( '\r\n' ) ); | |
const delayMs = Math.floor( Math.random() * ( ( options.mx * 1000 ) + 1 ) ); | |
return new Promise( ( resolve, reject ) => { | |
setTimeout( () => { | |
this.send( buffer, port, address ).then( resolve ).catch( reject ) | |
}, delayMs ); | |
} ); | |
} | |
/** | |
* Broadcasts an SSDP NOTIFY message | |
* | |
* @param {object} options - Notification options | |
* @param {string} options.uuid - Root device UUID | |
* @param {string} options.target - Notification target | |
* @param {string} options.type - Notification type | |
* @param {string} options.location - Root device location URL | |
* @param {string} options.server - Server string | |
* @param {string} [options.date="<now>"] - GMT Date string | |
* @param {string} [options.maxAge=600] - Cache control max. age in seconds | |
* @return {Promise} Resolves on broadcast success | |
*/ | |
notify ( options ) { | |
const defaultOptions = { | |
date: new Date().toGMTString(), | |
maxAge: 600, | |
}; | |
options = Object.assign( defaultOptions, options ); | |
[ 'maxAge', 'location', 'server', 'target', 'type', 'uuid' ].forEach( name => { | |
if ( options[ name ] === undefined ) throw new Error( `mssing required option: '${name}'` ); | |
} ); | |
const buffer = Buffer.from( [ | |
'NOTIFY * HTTP/1.1 ', | |
`Host: ${SSDP_MULTICAST_ADDRESS}:${SSDP_MULTICAST_PORT}`, | |
`Cache-Control: max-age=${options.maxAge}`, | |
`Location: ${options.location}`, | |
`Server: ${options.server}`, | |
`NT: ${options.target}`, | |
`NTS: ${options.type}`, | |
`USN: uuid:${options.uuid}::${options.target}`, | |
'\r\n', | |
].join( '\r\n' ) ); | |
return this.broadcast( buffer ); | |
} | |
/** | |
* Stop listening for SSDP messages | |
* | |
* @return {Promise} Resolves on close success | |
*/ | |
close () { | |
return new Promise( ( resolve, reject ) => { | |
if ( ! this.listening ) return reject( new Error( 'not listening' ) ); | |
debug( 'closing socket...' ); | |
this.socket.removeAllListeners( 'message' ); | |
this.socket.once( 'error', err => { | |
this.socket = null; | |
debug( 'error', err ); | |
reject( err ); | |
} ); | |
this.socket.close( () => { | |
this.socket.removeAllListeners( 'error' ); | |
this.socket = null; | |
debug( 'socket closed' ); | |
resolve(); | |
} ); | |
} ); | |
} | |
/** | |
* Creates a new SSDP Server | |
* | |
* @static | |
* @return {Server} SSDP Server instance | |
*/ | |
static createServer ( handler ) { | |
return new Server( handler ); | |
} | |
} | |
module.exports = Server; | |
if ( ! module.parent ) { | |
const server = Server.createServer( request => { | |
// console.debug( 'request:', request ); | |
} ) | |
server.listen( '0.0.0.0' ).then( () => { | |
console.log( 'listening...', server.address() ); | |
} ).catch( err => { | |
console.log( 'error:', err.message ); | |
} ); | |
process.on( 'SIGINT', () => { | |
if ( server.listening ) { | |
console.log( 'closing...' ); | |
server.close().then( () => { | |
console.log( 'closed' ); | |
} ).catch( err => { | |
console.error( 'error:', err.message ); | |
process.exit( 1 ); | |
} ); | |
} else { | |
console.error( 'force closed' ); | |
process.exit( 1 ); | |
} | |
} ); | |
} | |
/* EOF */ |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment