Skip to content

Instantly share code, notes, and snippets.

@satori99
Last active June 16, 2019 06:08
Show Gist options
  • Save satori99/d8ad553fb328e416ce455ae58b3d79a7 to your computer and use it in GitHub Desktop.
Save satori99/d8ad553fb328e416ce455ae58b3d79a7 to your computer and use it in GitHub Desktop.
A simple SSDP server class which responds to M-SEARCH messages
/**
* @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