Repository: stephen/nodetunes Branch: master Commit: 5e04b3b3ae7d Files: 21 Total size: 38.7 KB Directory structure: gitextract_r8tg7z38/ ├── .gitignore ├── .jscsrc ├── .travis.yml ├── CHANGELOG.md ├── README.md ├── examples/ │ ├── server.js │ ├── server_multiple.js │ └── server_stdout.js ├── gulpfile.js ├── index.js ├── lib/ │ ├── helper.js │ ├── rtp.js │ ├── rtsp.js │ ├── rtspmethods.js │ ├── server.js │ └── streams/ │ ├── base.js │ ├── output.js │ └── pcm.js ├── package.json ├── private.key └── test/ └── rtspmethods.test.js ================================================ FILE CONTENTS ================================================ ================================================ FILE: .gitignore ================================================ lib-cov lcov.info *.seed *.log *.csv *.dat *.out *.pid *.gz *.pcap *.pcm pids logs results build .grunt node_modules .DS_STORE ================================================ FILE: .jscsrc ================================================ { "preset": "airbnb" } ================================================ FILE: .travis.yml ================================================ language: node_js before_install: - sudo apt-get update -qq - sudo apt-get install -y libavahi-compat-libdnssd-dev libasound2-dev node_js: - "0.10" - "0.12" cache: directories: - node_modules ================================================ FILE: CHANGELOG.md ================================================ Changelog --------- ##### 0.3.0 - Added support for ALAC encoding (iOS 9, OS X El Capitan) ##### 0.2.1 - Bumped mdns version - Added support for node 4.0.0 ##### 0.2.0 - Added support for node 0.12.x - Added jscs airbnb checking to build process ##### 0.1.2 - Updated to mdns 2.2.2 (completes 0.11.x support) - Added warning/disconnect for unsupported codecs (non-PCM) ##### 0.1.1 - Added verbose output for Apple Challenge creation ##### 0.1.0 - Upgraded to httplike 1.0.1 - Added option for verbose output - Added disconnect timeout (see #8) ##### 0.0.19 - Removed dependency on ursa package. ##### 0.0.18 - Locked down dependencies, moved to mdns package ##### 0.0.17 - Fixed bug where unknown request method (e.g. `GET`) would crash session - Removed legacy `MessageBuilder` responses - Fixed buggy response errors to use `httplike` errors ##### 0.0.16 - Added support for fetching human-readable client name ##### 0.0.14 - Solved issues created in 0.0.12 - Fixed clientConnected/clientDisconnected issues - Correct cleanup for RTP binding ##### 0.0.12 - EXPERIMENTAL - added testing infrastructure, refactoring. - Moved to httplike v0.0.7 (trim on method support) ##### 0.0.11 - Fixed 'undefined' bug in RTSP replies ##### 0.0.10 - Fixed scope leakage issues - Fixed file naming issues ##### 0.0.7 - Changed output audio stream pattern to better match callback pattern. See new ```examples/server.js``` ================================================ FILE: README.md ================================================ NodeTunes ========= [![build status](https://secure.travis-ci.org/stephen/nodetunes.png)](http://travis-ci.org/stephen/nodetunes) nodetunes is an implementation of the Apple AirTunes v2 (audio AirPlay) protocol written in node.js. ``` npm install nodetunes ``` See ```examples/server.js``` for example usage. ================================================ FILE: examples/server.js ================================================ 'use strict'; var AirTunesServer = require('../index'); var Speaker = require('speaker'); var speaker = new Speaker({ channels: 2, bitDepth: 16, sampleRate: 44100, }); var server = new AirTunesServer({ serverName: 'NodeTunes Speaker' }); server.on('clientConnected', function(stream) { stream.pipe(speaker); }); server.start(); ================================================ FILE: examples/server_multiple.js ================================================ 'use strict'; var AirTunesServer = require('../index'); var Speaker = require('speaker'); var server1 = new AirTunesServer({ serverName: 'NodeTunes 1' }); var server2 = new AirTunesServer({ serverName: 'NodeTunes 2' }); server1.on('clientConnected', function(stream) { stream.on('data', function(d) { process.stdout.write('\rWriting for Server 1: ' + d.length + ' bytes @ ' + new Date().getTime() + '\t'); }); }); server2.on('clientConnected', function(stream) { stream.on('data', function(d) { process.stdout.write('\rWriting for Server 2: ' + d.length + ' bytes @ ' + new Date().getTime() + '\t'); }); }); server1.start(); server2.start(); ================================================ FILE: examples/server_stdout.js ================================================ 'use strict'; var AirTunesServer = require('../index'); var Speaker = require('speaker'); var server = new AirTunesServer({ serverName: 'NodeTunes Stdout' }); server.on('clientConnected', function(stream) { stream.pipe(process.stdout); }); server.start(); ================================================ FILE: gulpfile.js ================================================ var gulp = require('gulp'); var mocha = require('gulp-mocha'); var jscs = require('gulp-jscs'); gulp.task('test', function() { gulp.src('./test/*.js') .pipe(mocha({ reporter: 'spec' })); }); gulp.task('lint', function() { gulp.src('./**/*.js') .pipe(jscs()); }); gulp.task('default', ['test', 'lint']); ================================================ FILE: index.js ================================================ 'use strict'; module.exports = require(__dirname + '/lib/server'); ================================================ FILE: lib/helper.js ================================================ 'use strict'; var forge = require('node-forge'); var rsa = forge.pki.rsa; var fs = require('fs'); var crypto = require('crypto'); var debug = require('debug')('nodetunes:helper'); var parseSdp = function(msg) { var multi = ['a', 'p', 'b']; var lines = msg.split('\r\n'); var output = {}; for (var i = 0; i < lines.length; i++) { var sp = lines[i].split(/=(.+)?/); if (sp.length == 3) { // for some reason there's an empty item? if (multi.indexOf(sp[0]) != -1) { // some attributes are multiline... if (!output[sp[0]]) output[sp[0]] = []; output[sp[0]].push(sp[1]); } else { output[sp[0]] = sp[1]; } } } return output; }; var dmapTypes = { mper: 8, asal: 'str', asar: 'str', ascp: 'str', asgn: 'str', minm: 'str', astn: 2, asdk: 1, caps: 1, astm: 4, }; var parseDmap = function(buffer) { var output = {}; for (var i = 8; i < buffer.length;) { var itemType = buffer.slice(i, i + 4); var itemLength = buffer.slice(i + 4, i + 8).readUInt32BE(0); if (itemLength !== 0) { var data = buffer.slice(i + 8, i + 8 + itemLength); if (dmapTypes[itemType] == 'str') { output[itemType.toString()] = data.toString(); } else if (dmapTypes[itemType] == 1) { output[itemType.toString()] = data.readUInt8(0); } else if (dmapTypes[itemType] == 2) { output[itemType.toString()] = data.readUInt16BE(0); } else if (dmapTypes[itemType] == 4) { output[itemType.toString()] = data.readUInt32BE(0); } else if (dmapTypes[itemType] == 8) { output[itemType.toString()] = (data.readUInt32BE(0) << 8) + data.readUInt32BE(4); } } i += 8 + itemLength; } return output; }; var getPrivateKey = function() { var keyFile = fs.readFileSync(__dirname + '/../private.key'); var privkey = forge.pki.privateKeyFromPem(keyFile); return privkey; }; var privateKey = getPrivateKey(); var generateAppleResponse = function(challengeBuf, ipAddr, macAddr) { debug = require('debug')('nodetunes:helper'); // HACK: need to reload debug here (https://github.com/visionmedia/debug/issues/150) debug('building challenge for %s (ip: %s, mac: %s)', challengeBuf.toString('base64'), ipAddr.toString('hex'), macAddr.toString('hex')); var fullChallenge = Buffer.concat([challengeBuf, ipAddr, macAddr]); // im sure there's an easier way to pad this buffer var padding = []; for (var i = fullChallenge.length; i < 32; i++) { padding.push(0); } fullChallenge = Buffer.concat([fullChallenge, new Buffer(padding)]).toString('binary'); var response = forge.pki.rsa.encrypt(fullChallenge, privateKey, 0x01); debug('computed challenge: %s', forge.util.encode64(response)); return forge.util.encode64(response); }; var generateRfc2617Response = function(username, realm, password, nonce, uri, method) { var md5 = function(content) { return crypto.createHash('md5').update(content).digest().toString('hex'); }; var ha1 = md5(username + ':' + realm + ':' + password); var ha2 = md5(method + ':' + uri); var response = md5(ha1 + ':' + nonce + ':' + ha2); return response; }; var getDecoderOptions = function(audioOptions) { if (!audioOptions) return {} var decoderOptions = { frameLength: parseInt(audioOptions[1], 10), compatibleVersion: parseInt(audioOptions[2], 10), bitDepth: parseInt(audioOptions[3], 10), pb: parseInt(audioOptions[4], 10), mb: parseInt(audioOptions[5], 10), kb: parseInt(audioOptions[6], 10), channels: parseInt(audioOptions[7], 10), maxRun: parseInt(audioOptions[8], 10), maxFrameBytes: parseInt(audioOptions[9], 10), avgBitRate: parseInt(audioOptions[10], 10), sampleRate: parseInt(audioOptions[11], 10) }; return decoderOptions; }; var decryptAudioData = function(data, audioAesKey, audioAesIv, headerSize) { var tmp = new Buffer(16); if (!headerSize) headerSize = 12; var remainder = (data.length - 12) % 16; var endOfEncodedData = data.length - remainder; var decipher = crypto.createDecipheriv('aes-128-cbc', audioAesKey, audioAesIv); decipher.setAutoPadding(false); for (var i = headerSize, l = endOfEncodedData - 16; i <= l; i += 16) { data.copy(tmp, 0, i, i + 16); decipher.update(tmp).copy(data, i, 0, 16); } return data.slice(headerSize); }; module.exports.decryptAudioData = decryptAudioData; module.exports.getDecoderOptions = getDecoderOptions; module.exports.parseSdp = parseSdp; module.exports.parseDmap = parseDmap; module.exports.generateAppleResponse = generateAppleResponse; module.exports.generateRfc2617Response = generateRfc2617Response; module.exports.rsaPrivateKey = privateKey; ================================================ FILE: lib/rtp.js ================================================ 'use strict'; var dgram = require('dgram'); var tools = require('./helper'); var crypto = require('crypto'); var debug = require('debug')('nodetunes:rtp'); function RtpServer(rtspServer) { this.rtspServer = rtspServer; debug = require('debug')('nodetunes:rtp'); // HACK: need to reload debug here (https://github.com/visionmedia/debug/issues/150) } RtpServer.prototype.start = function() { debug('starting rtp servers'); var socketType = this.rtspServer.ipv6 ? 'udp6' : 'udp4'; this.baseServer = dgram.createSocket(socketType); this.controlServer = dgram.createSocket(socketType); this.timingServer = dgram.createSocket(socketType); this.baseServer.bind(this.rtspServer.ports[0]); this.controlServer.bind(this.rtspServer.ports[1]); this.timingServer.bind(this.rtspServer.ports[2]); this.timeoutCounter = -1; this.timeoutChecker = null; this.baseServer.on('message', function(msg) { var seq = msg.readUInt16BE(2); var audio = tools.decryptAudioData(msg, this.rtspServer.audioAesKey, this.rtspServer.audioAesIv); this.rtspServer.outputStream.add(audio, seq); }.bind(this)); this.controlServer.on('message', function(msg) { // timeout logic for socket disconnects if (this.timeoutCounter === -1 && this.rtspServer.controlTimeout) { this.timeoutChecker = setInterval(function() { this.timeoutCounter++; if (this.timeoutCounter >= this.rtspServer.controlTimeout) { this.rtspServer.timeoutHandler(); } }.bind(this), 1000); } this.timeoutCounter = 0; }.bind(this)); this.timingServer.on('message', function(msg) { }.bind(this)); }; RtpServer.prototype.stop = function() { if (this.baseServer) { debug('stopping rtp servers'); try { if (this.timeoutChecker) clearInterval(this.timeoutChecker); this.baseServer.close(); this.controlServer.close(); this.timingServer.close(); } catch (err) { } } }; module.exports = RtpServer; ================================================ FILE: lib/rtsp.js ================================================ 'use strict'; var net = require('net'); var ServerParser = require('httplike'); var tools = require('./helper'); var RtpServer = require('./rtp'); var httplike = require('httplike'); var debug = require('debug')('nodetunes:rtsp'); var error = require('debug')('nodetunes:error'); var util = require('util'); function RtspServer(options, external) { debug = require('debug')('nodetunes:rtsp'); // HACK: need to reload debug here (https://github.com/visionmedia/debug/issues/150) this.external = external; this.options = options; this.ports = []; this.rtp = new RtpServer(this); this.macAddress = options.macAddress; this.metadata = {}; this.outputStream = null; this.handling = null; this.clientConnected = null; this.controlTimeout = options.controlTimeout; this.methodMapping = require('./rtspmethods')(this); } RtspServer.prototype.connectHandler = function(socket) { if (this.handling && !this.clientConnected) { socket.end(); return; } this.socket = socket; this.handling = this.socket; socket.id = new Date().getTime(); var parser = new ServerParser(socket, { protocol: 'RTSP/1.0', statusMessages: { 453: 'NOT ENOUGH BANDWIDTH', }, }); parser.on('message', function(req, res) { res.set('CSeq', req.getHeader('CSeq')); res.set('Server', 'AirTunes/105.1'); var method = this.methodMapping[req.method]; if (method) { debug('received method %s (CSeq: %s)\n%s', req.method, req.getHeader('CSeq'), util.inspect(req.headers)); method(req, res); } else { error('received unknown method:', req.method); res.send(400); socket.end(); } }.bind(this)); socket.on('close', this.disconnectHandler.bind({ self: this, socket: socket })); }; RtspServer.prototype.timeoutHandler = function() { debug('client timeout detected (no ping in %s seconds)', this.controlTimeout); if (this.clientConnected) this.clientConnected.destroy(); }; RtspServer.prototype.disconnectHandler = function() { // keep in mind 'this' is bound to an object that looks like { self: this, socket: socket } // (see above) // handle case where multiple connections are being sought, // but none have fully connected yet if (this.socket === this.self.handling) { this.self.handling = null; } // handle case where "connected" client has been disconnected if (this.socket === this.self.clientConnected) { debug('client disconnected'); this.self.clientConnected = null; this.self.outputStream = null; this.self.rtp.stop(); this.self.external.emit('clientDisconnected'); } }; module.exports = RtspServer; ================================================ FILE: lib/rtspmethods.js ================================================ 'use strict'; var tools = require('./helper'); var ipaddr = require('ipaddr.js'); var randomstring = require('randomstring'); var crypto = require('crypto'); var debug = require('debug')('nodetunes:rtspmethods'); var OutputStream = require('./streams/output'); var AlacDecoderStream = require('alac2pcm'); var PcmDecoderStream = require('./streams/pcm'); var decoderStreams = { '96 AppleLossless': AlacDecoderStream, '96 L16/44100/2': PcmDecoderStream }; module.exports = function(rtspServer) { var nonce = ''; var options = function(req, res) { res.set('Public', 'ANNOUNCE, SETUP, RECORD, PAUSE, FLUSH, TEARDOWN, OPTIONS, GET_PARAMETER, SET_PARAMETER, POST, GET'); if (req.getHeader('Apple-Challenge')) { // challenge response consists of challenge + ip address + mac address + padding to 32 bytes, // encrypted with the ApEx private key (private encryption mode w/ PKCS1 padding) var challengeBuf = new Buffer(req.getHeader('Apple-Challenge'), 'base64'); var ipAddrRepr = ipaddr.parse(rtspServer.socket.address().address); if (ipAddrRepr.kind() === 'ipv6' && ipAddrRepr.isIPv4MappedAddress()) { ipAddrRepr = ipAddrRepr.toIPv4Address(); } var ipAddr = new Buffer(ipAddrRepr.toByteArray()); var macAddr = new Buffer(rtspServer.macAddress.replace(/:/g, ''), 'hex'); res.set('Apple-Response', tools.generateAppleResponse(challengeBuf, ipAddr, macAddr)); } res.send(); }; var announce = function(req, res) { debug(req.content.toString()); if (rtspServer.clientConnected) { debug('already streaming; rejecting new client'); res.status(453).send(); } else if (rtspServer.options.password && !req.getHeader('Authorization')) { var md5sum = crypto.createHash('md5'); md5sum.update = randomstring.generate(); res.status(401); nonce = md5sum.digest('hex').toString('hex'); res.set('WWW-Authenticate', 'Digest realm="roap", nonce="' + nonce + '"'); res.send(); } else if (rtspServer.options.password && req.getHeader('Authorization')) { var auth = req.getHeader('Authorization'); var params = auth.split(/, /g); var map = {}; params.forEach(function(param) { var pair = param.replace(/["]/g, '').split('='); map[pair[0]] = pair[1]; }); var expectedResponse = tools.generateRfc2617Response('iTunes', 'roap', rtspServer.options.password, nonce, map.uri, 'ANNOUNCE'); var receivedResponse = map.response; if (expectedResponse === receivedResponse) { announceParse(req, res); } else { res.send(401); } } else { announceParse(req, res); } }; var announceParse = function(req, res) { var sdp = tools.parseSdp(req.content.toString()); for (var i = 0; i < sdp.a.length; i++) { var spIndex = sdp.a[i].indexOf(':'); var aKey = sdp.a[i].substring(0, spIndex); var aValue = sdp.a[i].substring(spIndex + 1); if (aKey == 'rsaaeskey') { rtspServer.audioAesKey = tools.rsaPrivateKey.decrypt(new Buffer(aValue, 'base64').toString('binary'), 'RSA-OAEP'); } else if (aKey == 'aesiv') { rtspServer.audioAesIv = new Buffer(aValue, 'base64'); } else if (aKey == 'rtpmap') { rtspServer.audioCodec = aValue; if (aValue.indexOf('L16') === -1 && aValue.indexOf('AppleLossless') === -1) { //PCM: L16/(...) //ALAC: 96 AppleLossless rtspServer.external.emit('error', { code: 415, message: 'Codec not supported (' + aValue + ')' }); res.status(415).send(); } } else if (aKey == 'fmtp') { rtspServer.audioOptions = aValue.split(' '); } } if (sdp.i) { rtspServer.metadata.clientName = sdp.i; debug('client name reported (%s)', rtspServer.metadata.clientName); rtspServer.external.emit('clientNameChange', sdp.i); } if (sdp.c) { if (sdp.c.indexOf('IP6') !== -1) { debug('ipv6 usage detected'); rtspServer.ipv6 = true; } } var decoderOptions = tools.getDecoderOptions(rtspServer.audioOptions); var decoderStream = new decoderStreams[rtspServer.audioCodec](decoderOptions); rtspServer.clientConnected = res.socket; rtspServer.outputStream = new OutputStream(); debug('client considered connected'); rtspServer.outputStream.setDecoder(decoderStream); rtspServer.external.emit('clientConnected', rtspServer.outputStream); res.send(); }; var setup = function(req, res) { rtspServer.ports = []; var getRandomPort = function() { var min = 5000; var max = 9999; return Math.floor(Math.random() * (max - min + 1)) + min; }; rtspServer.ports = [getRandomPort(), getRandomPort(), getRandomPort()]; if (rtspServer.ports.length >= 3) { rtspServer.rtp.start(); debug('setting udp ports (audio: %s, control: %s, timing: %s)', rtspServer.ports[0], rtspServer.ports[1], rtspServer.ports[2]); res.set('Transport', 'RTP/AVP/UDP;unicast;mode=record;server_port=' + rtspServer.ports[0] + ';control_port=' + rtspServer.ports[1] + ';timing_port=' + rtspServer.ports[2]); res.set('Session', '1'); res.set('Audio-Jack-Status', 'connected'); res.send(); } }; var record = function(req, res) { if (!req.getHeader('RTP-Info')) { // jscs:disable // it seems like iOS airplay does something else } else { var rtpInfo = req.getHeader('RTP-Info').split(';'); var initSeq = rtpInfo[0].split('=')[1]; var initRtpTime = rtpInfo[1].split('=')[1]; if (!initSeq || !initRtpTime) { res.send(400); } else { res.set('Audio-Latency', '0'); // FIXME } } res.send(); }; var flush = function(req, res) { res.set('RTP-Info', 'rtptime=1147914212'); // FIXME res.send(); }; var teardown = function(req, res) { rtspServer.rtp.stop(); res.send(); }; var setParameter = function(req, res) { if (req.getHeader('Content-Type') == 'application/x-dmap-tagged') { // metadata dmap/daap format var dmapData = tools.parseDmap(req.content); rtspServer.metadata = dmapData; rtspServer.external.emit('metadataChange', rtspServer.metadata); debug('received metadata (%s)', JSON.stringify(rtspServer.metadata)); } else if (req.getHeader('Content-Type') == 'image/jpeg') { rtspServer.metadata.artwork = req.content; rtspServer.external.emit('artworkChange', req.content); debug('received artwork (length: %s)', rtspServer.metadata.artwork.length); } else if (req.getHeader('Content-Type') == 'text/parameters') { var data = req.content.toString().split(': '); rtspServer.metadata = rtspServer.metadata || {}; debug('received text metadata (%s: %s)', data[0], data[1].trim()); if (data[0] == 'volume') { rtspServer.metadata.volume = parseFloat(data[1]); rtspServer.external.emit('volumeChange', rtspServer.metadata.volume); } else if (data[0] == 'progress') { rtspServer.metadata.progress = data[1]; rtspServer.external.emit('progressChange', rtspServer.metadata.progress); } } else { debug('uncaptured SET_PARAMETER method: %s', req.content.toString().trim()); } res.send(); }; var getParameter = function(req, res) { debug('uncaptured GET_PARAMETER method: %s', req.content.toString().trim()); res.send(); }; return { OPTIONS: options, ANNOUNCE: announce, SETUP: setup, RECORD: record, FLUSH: flush, TEARDOWN: teardown, SET_PARAMETER: setParameter, // metadata, volume control GET_PARAMETER: getParameter, // asked for by iOS? }; }; ================================================ FILE: lib/server.js ================================================ 'use strict'; var mdns = require('mdns'); var net = require('net'); var portastic = require('portastic'); var randomMac = require('random-mac'); var RtspServer = require('./rtsp'); var EventEmitter = require('events').EventEmitter; var util = require('util'); var debug = require('debug')('nodetunes:server'); function NodeTunes(options) { options = options || {}; this.options = options; options.serverName = options.serverName || 'NodeTunes'; options.macAddress = options.macAddress || randomMac().toUpperCase().replace(/:/g, ''); options.recordDumps = options.recordDumps || false; options.recordMetrics = options.recordMetrics || false; options.controlTimeout = (options.controlTimeout !== undefined && options.controlTimeout !== null ? options.controlTimeout : 5); if (options.verbose) { require('debug').enable('nodetunes:*'); debug = require('debug')('nodetunes:server'); // HACK: need to reload debug here (https://github.com/visionmedia/debug/issues/150) } this.txtSetup = { txtvers: '1', // txt record version? ch: '2', // # channels cn: '0,1', // codec; 0=pcm, 1=alac, 2=aac, 3=aac elc; fwiw Sonos supports aac; pcm required for iPad+Spotify; OS X works with pcm et: '0,1', // encryption; 0=none, 1=rsa, 3=fairplay, 4=mfisap, 5=fairplay2.5; need rsa for os x md: '0', // metadata; 0=text, 1=artwork, 2=progress pw: (options.password ? 'true' : 'false'), // password enabled sr: '44100', // sampling rate (e.g. 44.1KHz) ss: '16', // sample size (e.g. 16 bit?) tp: 'TCP,UDP', // transport protocol vs: '105.1', // server version? am: 'AirPort4,107', // device model ek: '1', // ? from ApEx; setting to 1 enables iTunes; seems to use ALAC regardless of 'cn' setting sv: 'false', // ? from ApEx da: 'true', // ? from ApEx vn: '65537', // ? from ApEx; maybe rsa key modulus? happens to be the same value fv: '76400.10', // ? from ApEx; maybe AirPort software version (7.6.4) sf: '0x5' // ? from ApEx }; this.netServer = null; this.rtspServer = new RtspServer(this.options, this); } util.inherits(NodeTunes, EventEmitter); NodeTunes.prototype.start = function(callback) { debug('starting nodetunes server (%s)', this.options.serverName); portastic.find({ min: 5000, max: 5050, retrieve: 1, }, function(err, port) { if (err) { if (callback) { callback(err); } else { throw err; } } this.netServer = net.createServer(this.rtspServer.connectHandler.bind(this.rtspServer)).listen(port, function() { var ad = mdns.createAdvertisement(mdns.tcp('raop'), port, { name: this.options.macAddress + '@' + this.options.serverName, txtRecord: this.txtSetup, }); if (callback) { callback(null, { port: port, macAddress: this.options.macAddress, }); } debug('broadcasting mdns advertisement (for port %s)', port); }.bind(this)); }.bind(this)); }; NodeTunes.prototype.stop = function() { debug('stopping nodetunes server'); this.netServer.close(); }; module.exports = NodeTunes; ================================================ FILE: lib/streams/base.js ================================================ 'use strict'; module.exports = BaseDecoderStream; var Readable = require('readable-stream').Readable; var PriorityQueue = require('priorityqueuejs'); var util = require('util'); function BaseDecoderStream() { Readable.call(this); this.isFlowing = true; this.bufferQueue = new PriorityQueue(function(a, b) { return b.sequenceNumber - a.sequenceNumber; }); }; util.inherits(BaseDecoderStream, Readable); BaseDecoderStream.prototype.add = function(chunk, sequenceNumber, isRetransmit) { this._push({ chunk: chunk, sequenceNumber: sequenceNumber }); }; BaseDecoderStream.prototype._push = function(data) { if (this.isFlowing) { var result = this.push(data.chunk); if (!result) { this.isFlowing = false; } return result; } else { this.bufferQueue.enq(data); } }; BaseDecoderStream.prototype._read = function() { this.isFlowing = true; if (this.bufferQueue.size() === 0) return; while (this.bufferQueue.size() > 0) { if (!this._push(this.bufferQueue.deq())) return; } }; ================================================ FILE: lib/streams/output.js ================================================ 'use strict'; module.exports = OutputStream; var PassThrough = require('readable-stream').PassThrough; var BaseStream = require('./base'); var util = require('util'); function OutputStream() { PassThrough.call(this); this.baseStream = new BaseStream(); this.decoder = null; }; util.inherits(OutputStream, PassThrough); OutputStream.prototype.setDecoder = function(decoder) { this.decoder = decoder; this.baseStream.pipe(decoder).pipe(this); }; OutputStream.prototype.add = function(chunk, sequenceNumber) { this.baseStream.add(chunk, sequenceNumber); }; ================================================ FILE: lib/streams/pcm.js ================================================ 'use strict'; module.exports = PcmDecoderStream; var Transform = require('readable-stream').Transform; var util = require('util'); function PcmDecoderStream() { Transform.apply(this, arguments); }; util.inherits(PcmDecoderStream, Transform); PcmDecoderStream.prototype._transform = function(pcmData, enc, cb) { var swapBuf = new Buffer(pcmData.length); // endian hack for (var i = 0; i < pcmData.length; i += 2) { swapBuf[i] = pcmData[i + 1]; swapBuf[i + 1] = pcmData[i]; }; cb(null, swapBuf); }; ================================================ FILE: package.json ================================================ { "name": "nodetunes", "version": "0.3.0", "author": "Stephen Wan ", "description": "AirTunes v2 Server Implementation", "contributors": [ { "name": "Stephen Wan", "email": "stephen@stephenwan.net" } ], "engines": { "node": "0.12.x" }, "scripts": { "start": "node ./examples/server.js", "test": "gulp test" }, "repository": { "type": "git", "url": "https://github.com/stephen/nodetunes.git" }, "dependencies": { "alac2pcm": "^1.1.0", "debug": "^2.1.0", "forge": "^2.2.0", "httplike": "^1.0.2", "ipaddr.js": "^1.0.1", "mdns": "^2.2.10", "metricstream": "0.0.0", "node-forge": "^0.6.16", "portastic": "0.0.1", "priorityqueuejs": "0.2.0", "random-mac": "0.0.4", "randomstring": "1.0.3", "readable-stream": "^2.0.2" }, "devDependencies": { "gulp": "~3.6.2", "gulp-jscs": "^2.0.0", "gulp-mocha": "~0.4.1", "mocha": "~1.18.2", "request": "~2.34.0", "speaker": "0.2.6" }, "license": "MIT" } ================================================ FILE: private.key ================================================ -----BEGIN RSA PRIVATE KEY----- MIIEpQIBAAKCAQEA59dE8qLieItsH1WgjrcFRKj6eUWqi+bGLOX1HL3U3GhC/j0Qg90u3sG/1CUt wC5vOYvfDmFI6oSFXi5ELabWJmT2dKHzBJKa3k9ok+8t9ucRqMd6DZHJ2YCCLlDRKSKv6kDqnw4U wPdpOMXziC/AMj3Z/lUVX1G7WSHCAWKf1zNS1eLvqr+boEjXuBOitnZ/bDzPHrTOZz0Dew0uowxf /+sG+NCK3eQJVxqcaJ/vEHKIVd2M+5qL71yJQ+87X6oV3eaYvt3zWZYD6z5vYTcrtij2VZ9Zmni/ UAaHqn9JdsBWLUEpVviYnhimNVvYFZeCXg/IdTQ+x4IRdiXNv5hEewIDAQABAoIBAQDl8Axy9XfW BLmkzkEiqoSwF0PsmVrPzH9KsnwLGH+QZlvjWd8SWYGN7u1507HvhF5N3drJoVU3O14nDY4TFQAa LlJ9VM35AApXaLyY1ERrN7u9ALKd2LUwYhM7Km539O4yUFYikE2nIPscEsA5ltpxOgUGCY7b7ez5 NtD6nL1ZKauw7aNXmVAvmJTcuPxWmoktF3gDJKK2wxZuNGcJE0uFQEG4Z3BrWP7yoNuSK3dii2jm lpPHr0O/KnPQtzI3eguhe0TwUem/eYSdyzMyVx/YpwkzwtYL3sR5k0o9rKQLtvLzfAqdBxBurciz aaA/L0HIgAmOit1GJA2saMxTVPNhAoGBAPfgv1oeZxgxmotiCcMXFEQEWflzhWYTsXrhUIuz5jFu a39GLS99ZEErhLdrwj8rDDViRVJ5skOp9zFvlYAHs0xh92ji1E7V/ysnKBfsMrPkk5KSKPrnjndM oPdevWnVkgJ5jxFuNgxkOLMuG9i53B4yMvDTCRiIPMQ++N2iLDaRAoGBAO9v//mU8eVkQaoANf0Z oMjW8CN4xwWA2cSEIHkd9AfFkftuv8oyLDCG3ZAf0vrhrrtkrfa7ef+AUb69DNggq4mHQAYBp7L+ k5DKzJrKuO0r+R0YbY9pZD1+/g9dVt91d6LQNepUE/yY2PP5CNoFmjedpLHMOPFdVgqDzDFxU8hL AoGBANDrr7xAJbqBjHVwIzQ4To9pb4BNeqDndk5Qe7fT3+/H1njGaC0/rXE0Qb7q5ySgnsCb3DvA cJyRM9SJ7OKlGt0FMSdJD5KG0XPIpAVNwgpXXH5MDJg09KHeh0kXo+QA6viFBi21y340NonnEfdf 54PX4ZGS/Xac1UK+pLkBB+zRAoGAf0AY3H3qKS2lMEI4bzEFoHeK3G895pDaK3TFBVmD7fV0Zhov 17fegFPMwOII8MisYm9ZfT2Z0s5Ro3s5rkt+nvLAdfC/PYPKzTLalpGSwomSNYJcB9HNMlmhkGzc 1JnLYT4iyUyx6pcZBmCd8bD0iwY/FzcgNDaUmbX9+XDvRA0CgYEAkE7pIPlE71qvfJQgoA9em0gI LAuE4Pu13aKiJnfft7hIjbK+5kyb3TysZvoyDnb3HOKvInK7vXbKuU4ISgxB2bB3HcYzQMGsz1qJ 2gG0N5hvJpzwwhbhXqFKA4zaaSrw622wDniAK5MlIE0tIAKKP4yxNGjoD2QYjhBGuhvkWKY= -----END RSA PRIVATE KEY----- ================================================ FILE: test/rtspmethods.test.js ================================================ var net = require('net'); var assert = require('assert'); var Nodetunes = require('../index'); var Parser = require('httplike').ClientParser; var helper = require('../lib/helper'); describe('RTSP Methods', function() { // test constants var rsaAesKey = 'ldAdTcI8b2okzDhz3bCnFPwwMVwwGCVt8+0bqURzomwUVWh5gwuee14E8FszGvrJvl5+3lfXMMDw3MRTO4arG380WNq3hl7H+ck' + 'wgID2ZiV3YgSwh/oVA5QieD65m5vtYyNqe1dypQHOE0Fz/fOXb5ySpmzVvbJbMKP7H7DucpoXTWvk9CHMLZU8z9vWUVxMi862FPNLFWfrCE9NBM' + 'bwFk2r40QdbYC5fd+6d/ynrDLit6V5T/l8ESi6tcC4vRFrM8j2gQkGwLilpbKL+k38rBvZK+zTs8k/k25zOb7xtfrKoWJ7soIska+unVnEF5ILE' + 'XyE3eg0NsB/IrmqKIrV9Q=='; var rsaAesIv = 'VkH+lhtE7jGkV5rUPM64aQ=='; var codec = '96 L16/44100/2'; var macAddress = '5F513885F785'; var announceContent = 'v=0\r\no=AirTunes 7709564614789383330 0 IN IP4 172.17.104.138\r\ns=AirTunes\r\n' + 'i=Stephen\'s iPad\r\nc=IN IP4 172.17.104.138\r\nt=0 0\r\nm=audio 0 RTP/AVP 96\r\na=rtpmap:' + codec + '\r\n' + 'a=rsaaeskey:' + rsaAesKey + '\r\na=aesiv:' + rsaAesIv + '\r\na=min-latency:11025\r\na=max-latency:88200'; var server = null; var port = -1; var client = new net.Socket(); var parser = new Parser(client); beforeEach(function(done) { client = new net.Socket(); parser = new Parser(client); server = new Nodetunes({ macAddress: macAddress }); server.start(function(err, d) { port = d.port; done(); }); }); afterEach(function(done) { server.stop(); client.end(); done(); }); // tests describe('General', function() { it('should report CSeq correctly', function(done) { var x = 0; parser.on('message', function(m) { assert(m.getHeader('CSeq') === '' + x); x++; if (x === 100) { done(); } }); client.connect(port, 'localhost', function() { for (var i = 0; i < 100; i++) { client.write('OPTIONS * RTSP/1.0\r\nCSeq:' + i + '\r\nUser-Agent: AirPlay/190.9\r\n\r\n'); } }); }); it('should only allow one client', function(done) { var secondClient = new net.Socket(); var secondParser = new Parser(secondClient); secondParser.on('message', function(m) { assert.equal(m.statusCode, 453); assert.equal(m.statusMessage.toUpperCase(), 'NOT ENOUGH BANDWIDTH'); secondClient.end(); done(); }); client.connect(port, 'localhost', function() { client.write('ANNOUNCE * RTSP/1.0\r\nCSeq:0\r\nUser-Agent: AirPlay/190.9\r\nContent-Length:' + announceContent.length + '\r\n\r\n' + announceContent); parser.on('message', function(m) { secondClient.connect(port, 'localhost', function() { secondClient.write('ANNOUNCE * RTSP/1.0\r\nCSeq:0\r\nUser-Agent: AirPlay/190.9\r\nContent-Length:' + announceContent.length + '\r\n\r\n' + announceContent); }); }); }); }); }); describe('OPTIONS', function() { it('should respond with available method options', function(done) { parser.on('message', function(m) { assert(m.protocol === 'RTSP/1.0'); assert(m.statusCode === 200); assert(m.statusMessage === 'OK'); assert(m.getHeader('Server') === 'AirTunes/105.1'); assert(m.getHeader('CSeq') === '0'); assert(m.getHeader('Public') === 'ANNOUNCE, SETUP, RECORD, PAUSE, FLUSH, TEARDOWN, OPTIONS, GET_PARAMETER, SET_PARAMETER, POST, GET'); done(); }); client.connect(port, 'localhost', function() { client.write('OPTIONS * RTSP/1.0\r\nCSeq:0\r\nUser-Agent: AirPlay/190.9\r\n\r\n'); }); }); it('should respond with to options with apple challenge response (TODO)', function(done) { parser.on('message', function(m) { assert(m.statusCode === 200); done(); }); client.connect(port, 'localhost', function() { client.write('OPTIONS * RTSP/1.0\r\nCSeq:0\r\nUser-Agent: AirPlay/190.9\r\nApple-Challenge: WkfiX/5gzeKemPDHyBwww==\r\n\r\n'); }); }); }); describe('ANNOUNCE', function() { it('should respond with announce acknowledgement', function(done) { parser.on('message', function(m) { assert(m.statusCode === 200); assert(server.rtspServer.audioCodec === codec); assert(server.rtspServer.audioAesKey.toString('base64') === helper.rsaPrivateKey.decrypt(new Buffer(rsaAesKey, 'base64').toString('binary'), 'RSA-OAEP').toString('base64')); assert(server.rtspServer.audioAesIv.toString('base64') === rsaAesIv); assert(server.rtspServer.metadata.clientName === 'Stephen\'s iPad'); done(); }); client.connect(port, 'localhost', function() { client.write('ANNOUNCE * RTSP/1.0\r\nCSeq:0\r\nUser-Agent: AirPlay/190.9\r\nContent-Length:' + announceContent.length + '\r\n\r\n' + announceContent); }); }); it('should respond with password required (TODO)', function(done) { parser.on('message', function(m) { assert(m.statusCode === 200); assert(server.rtspServer.audioCodec === codec); assert(server.rtspServer.audioAesKey.toString('base64') === helper.rsaPrivateKey.decrypt(new Buffer(rsaAesKey, 'base64').toString('binary'), 'RSA-OAEP').toString('base64')); assert(server.rtspServer.audioAesIv.toString('base64') === rsaAesIv); done(); }); client.connect(port, 'localhost', function() { client.write('ANNOUNCE * RTSP/1.0\r\nCSeq:0\r\nUser-Agent: AirPlay/190.9\r\nContent-Length:' + announceContent.length + '\r\n\r\n' + announceContent); }); }); it('should respond with password validation (TODO)', function(done) { parser.on('message', function(m) { assert(m.statusCode === 200); assert(server.rtspServer.audioCodec === codec); assert(server.rtspServer.audioAesKey.toString('base64') === helper.rsaPrivateKey.decrypt(new Buffer(rsaAesKey, 'base64').toString('binary'), 'RSA-OAEP').toString('base64')); assert(server.rtspServer.audioAesIv.toString('base64') === rsaAesIv); done(); }); client.connect(port, 'localhost', function() { client.write('ANNOUNCE * RTSP/1.0\r\nCSeq:0\r\nUser-Agent: AirPlay/190.9\r\nContent-Length:' + announceContent.length + '\r\n\r\n' + announceContent); }); }); }); describe('SETUP', function() { it('should respond with setup acknowledgement (TODO)', function(done) { var counter = 0; parser.on('message', function(m) { counter++; assert(m.statusCode === 200); // TODO: check client ports saved on server if (counter == 1) { client.write('SETUP * RTSP/1.0\r\nCSeq:0\r\nUser-Agent: AirPlay/190.9\r\nTransport:RTP/AVP/UDP;unicast;mode=record;timing_port=56631;x-events;control_port=62727\r\n\r\n'); } else if (counter == 2) { assert(server.rtspServer.ports.length === 3); assert(typeof server.rtspServer.ports[0] === 'number'); assert(typeof server.rtspServer.ports[1] === 'number'); assert(typeof server.rtspServer.ports[2] === 'number'); client.destroy(); done(); } }); client.connect(port, 'localhost', function() { client.write('ANNOUNCE * RTSP/1.0\r\nCSeq:0\r\nUser-Agent: AirPlay/190.9\r\nContent-Length:' + announceContent.length + '\r\n\r\n' + announceContent); }); }); }); describe('RECORD', function() { it('should respond with record acknowledgement (TODO)', function(done) { parser.on('message', function(m) { assert(m.statusCode === 200); done(); }); client.connect(port, 'localhost', function() { client.write('RECORD * RTSP/1.0\r\nCSeq:0\r\nUser-Agent: AirPlay/190.9\r\n\r\n'); }); }); }); describe('FLUSH', function() { it('should respond with flush acknowledgement (IMPL TODO)', function(done) { parser.on('message', function(m) { assert(m.statusCode === 200); done(); }); client.connect(port, 'localhost', function() { client.write('FLUSH * RTSP/1.0\r\nCSeq:0\r\nUser-Agent: AirPlay/190.9\r\n\r\n'); }); }); }); describe('TEARDOWN', function() { it('should respond with teardown acknowledgement (TODO)', function(done) { parser.on('message', function(m) { assert(m.statusCode === 200); done(); }); client.connect(port, 'localhost', function() { client.write('OPTIONS * RTSP/1.0\r\nCSeq:2\r\nUser-Agent: AirPlay/190.9\r\n\r\n'); }); }); }); describe('GET_PARAMETER', function() { it('should respond with volume parameter (IMPL TODO)', function(done) { parser.on('message', function(m) { assert(m.statusCode === 200); done(); }); var content = 'volume'; client.connect(port, 'localhost', function() { client.write('GET_PARAMETER * RTSP/1.0\r\nCSeq:2\r\nUser-Agent: AirPlay/190.9\r\nContent-Length:' + content.length + '\r\n\r\n' + content); }); }); }); describe('SET_PARAMETER', function() { it('should set and acknowledge volume', function(done) { server.on('volumeChange', function(volume) { assert(volume === -2.25); done(); }); var content = 'volume: -2.250000'; client.connect(port, 'localhost', function() { client.write('SET_PARAMETER * RTSP/1.0\r\nCSeq:2\r\nUser-Agent: AirPlay/190.9\r\nContent-Type:text/parameters\r\nContent-Length:' + content.length + '\r\n\r\n' + content); }); }); it('should set and acknowedge text metadata (TODO)', function(done) { parser.on('message', function(m) { assert(m.statusCode === 200); done(); }); var content = 'bawkbawk'; client.connect(port, 'localhost', function() { client.write('SET_PARAMETER * RTSP/1.0\r\nCSeq:2\r\nUser-Agent: AirPlay/190.9\r\nContent-Type:application/x-dmap-tagged\r\nContent-Length:' + content.length + '\r\n\r\n' + content); }); }); it('should set and acknowedge album metadata (TODO)', function(done) { parser.on('message', function(m) { assert(m.statusCode === 200); done(); }); var content = 'bawkbawk'; client.connect(port, 'localhost', function() { client.write('SET_PARAMETER * RTSP/1.0\r\nCSeq:2\r\nUser-Agent: AirPlay/190.9\r\nContent-Type:application/x-dmap-tagged\r\nContent-Length:' + content.length + '\r\n\r\n' + content); }); }); }); });