taas-server
Version:
Things-as-a-service for 'hidden servers' (behind firewalls/NATs) and 'mobile clients' using a third-party service
438 lines (345 loc) • 16.9 kB
JavaScript
var bcrypt = require('bcrypt')
, minimatch = require('minimatch')
, mqtt = require('mqtt')
, redis = require('redis')
, speakeasy = require('speakeasy')
, winston = require('winston')
, options = require('./local').options
;
options.logger = new (winston.Logger)({ transports : [ new (winston.transports.Console)({ level : 'error' })
, new (winston.transports.File) ({ filename : 'broker.log' })
]
});
options.logger.setLevels(winston.config.syslog.levels);
if (options.redisHost === '127.0.0.1') {
require('nedis').createServer({ server: options.redisHost, port: options.redisPort }).on('error', function(err) {
if (!err) return options.logger.info('REDIS listening on tcp://' + options.redisHost + ':' + options.redisPort);
if ((err.code === 'EADDRINUSE') && (err.syscall === 'listen')) return;
options.logger.alert('nedis err: ' + err.message);
process.exit(1);
}).listen(options.redisPort, options.redisHost);
}
var startP = false;
var client = redis.createClient(options.redisPort, options.redisHost, { parser: 'javascript' });
client.auth(options.redisAuth, function(err) {
if (err) throw err;
});
client.on('ready', function() {
var server;
if (startP) return;
startP = true;
options.logger.info('redis started');
server = (!!options.keyPath) ? mqtt.createSecureServer(options.keyPath, options.crtPath, listener)
: mqtt.createServer(listener);
server.on('error', function(err) {
options.logger.error('server', { event: 'error', diagnostic: err.message });
}).listen(options.mqttPort, function() {
options.logger.info('MQTT broker listening on mqtts://*:' + this.address().port);
});
}).on('connect', function() {
}).on('error', function(err) {
options.logger.error('redis error: ' + err.message);
throw err;
}).on('end', function() {
});
var listener = function(client) {
var self = this;
var clientId;
if (!self.clients) self.clients = {};
if (!self.publications) self.publications = {};
if (!self.timer1) {
self.timer1 = setInterval(function() {
var id, now;
now = new Date().getTime();
for (id in self.clients) {
if ((!self.clients.hasOwnProperty(id)) || (self.clients[id].nextping >= now) || (!self.clients[id].client)) continue;
options.logger.warning('client', properties(self, id, { event: 'timeout' }));
closer(self, self.clients[id].client);
break;
}
}, 1 * 1000);
}
if (!self.timer2) {
self.timer2 = setInterval(function() {
var i, messages, timestamp, topic;
timestamp = new Date().getTime() - (86400 * 1000);
for (topic in self.publications) {
if (!self.publications.hasOwnProperty(topic)) continue;
messages = self.publications[topic].messages;
if (!messages) continue;
for (i = messages.length - 1; i >= 0; i--) if (messages[i].timestamp < timestamp) break;
if (i >= 0) messages.splice(0, i + 1);
i = messages.length;
options.logger.debug('publications',
{ topic : topic
, count : messages.length
, min : (i > 0) ? messages[ 0].timestamp : null
, max : (i > 0) ? messages[i - 1].timestamp : null
});
}
}, 86400 * 1000);
}
client.on('connect', function(packet) {
var hcfP, lwt, password, username;
var fail = function(returnCode) {
options.logger.warning('client', properties(self, clientId, { event: 'connect', returnCode: returnCode }));
try { client.connack({ returnCode: returnCode }); } catch(ex) {}
return closer(self, client);
};
clientId = packet.clientId;
password = packet.password;
if (!!password) packet.password = '...';
options.logger.info('client', properties(self, clientId, { event: 'connect', packet: packet }));
if (packet.protocolVersion !== 3) return fail(1);
if (packet.protocolId !== 'MQIsdp') return fail(2);
if ((!packet.clientId) || (packet.clientId.length > 23)) return fail(2);
username = normalize(packet.username);
if ((!username) || (username.indexOf('#') !== -1) || (username.indexOf('+') !== -1) || (username.indexOf('*') !== -1)) {
return fail(4);
}
if ((!password) || (!password.length)) return fail(4);
authenticate(packet, packet.username, password, function(err, result) {
if (!!err) {
options.logger.info('client', properties(self, clientId, { event: 'authenticate', diagnostic: err.message }));
return fail(5);
}
if (!!self.clients[clientId]) {
hcfP = self.clients[clientId].client === client;
closer(self, self.clients[clientId]);
if (hcfP) return;
if (packet.clean) self.clients[clientId].subscriptions = {};
} else self.clients[clientId] = { clientId: clientId, subscriptions: {} };
self.clients[clientId].clean = packet.clean;
if (!!packet.will) {
lwt = { topic: normalize(packet.will.topic), payload: packet.will.payload, retain : packet.will.retain };
if (publishP(self, self.clients[clientId], lwt.topic)) self.clients[clientId].lwt = lwt;
}
self.clients[clientId].username = packet.username;
self.clients[clientId].keepalive = packet.keepalive * 1500;
self.clients[clientId].nextping = new Date().getTime() + self.clients[clientId].keepalive;
self.clients[clientId].permissions = result;
self.clients[clientId].client = client;
try { client.connack({ returnCode: 0 }); } catch(ex) { return closer(self, client); }
sync(self, clientId, null);
});
}).on('publish', function(packet) {
var message, topic;
options.logger.info('client', properties(self, clientId, { event: 'publish', packet: packet }));
if (!clientId) return closer(self, client);
if (packet.qos === 1) client.puback({ messageId: packet.messageId });
topic = normalize(packet.topic);
if (!publishP(self, self.clients[clientId], topic)) return;
message = { messageId: packet.messageId, payload: packet.payload, timestamp: new Date().getTime() };
sync(self, null, topic, message);
if (!packet.retain) return;
if (!self.publications[topic]) self.publications[topic] = { messages: [] };
self.publications[topic].messages.push(message);
}).on('puback', function(packet) {
options.logger.info('client', properties(self, clientId, { event: 'puback', packet: packet }));
if (!clientId) return closer(self, client);
}).on('subscribe', function(packet) {
var granted, grantP, i, pattern, patterns, qos;
options.logger.info('client', properties(self, clientId, { event: 'subscribe', packet: packet }));
if (!clientId) return closer(self, client);
granted = [];
patterns = [];
for (i = 0; i < packet.subscriptions.length; i++) {
pattern = normalize(packet.subscriptions[i].topic);
if (!pattern) pattern = '#';
grantP = subscribeP(self, self.clients[clientId], pattern);
qos = grantP && (packet.subscriptions[i].qos !== 0) ? 1 : 0;
granted.push(qos);
if (grantP) {
self.clients[clientId].subscriptions[pattern] = { qos: qos, timestamp: 0 };
patterns.push(pattern);
}
}
client.suback({ granted: granted, messageId: packet.messageId });
for (i = 0; i < patterns.length; i++) sync(self, clientId, patterns[i]);
options.logger.debug('client', properties(self, clientId,
{ event: 'subscriptions', subscriptions: self.clients[clientId].subscriptions }));
}).on('unsubscribe', function(packet) {
options.logger.info('client', properties(self, clientId, { event: 'unsubscribe', packet: packet }));
if (!clientId) return closer(self, client);
delete(self.clients[clientId].subscriptions[normalize(packet.topic)]);
client.unsuback({ messageId: packet.messageId });
options.logger.debug('client', properties(self, clientId,
{ event: 'subscriptions', subscriptions: self.clients[clientId].subscriptions }));
}).on('pingreq', function(packet) {
options.logger.debug('client', properties(self, clientId, { event: 'pingreq', packet: packet }));
if (!!self.clients[clientId]) self.clients[clientId].nextping = new Date().getTime() + self.clients[clientId].keepalive;
client.pingresp();
}).on('disconnect', function(packet) {
options.logger.info('client', properties(self, clientId, { event: 'disconnect', packet: packet }));
closer(self, client);
}).on('close', function(errP) {
var logf, lwt, message, status;
if (errP) {
logf = options.logger.error; status = 'error';
} else {
logf = options.logger.info; status = 'normal';
}
logf('client', properties(self, clientId, { event : 'close'
, status : status
, clean : (!!self.clients[clientId]) && self.clients[clientId].clean
}));
if (!!self.clients[clientId]) {
delete(self.clients[clientId].client);
if (!!self.clients[clientId].lwt) {
lwt = self.clients[clientId].lwt;
message = { payload: lwt.payload, timestamp: new Date().getTime() };
sync(self, null, lwt.topic, message);
if (lwt.retain) {
if (!self.publications[lwt.topic]) self.publications[lwt.topic] = { messages: [] };
self.publications[lwt.topic].messages.push(message);
}
}
if (self.clients[clientId].clean) delete(self.clients[clientId]);
}
}).on('error', function(err) {
options.logger.error('client', properties(self, clientId, { event: 'error', diagnostic: err.message }));
closer(self, client);
});
};
var authenticate = function(packet, username, password, cb) {
client.get(packet.username, function(err, reply) {
var branch, entry, i, now, otparams, parts, permissions;
if (err) return cb(err);
if (reply === null) return cb(new Error('no such entry'));
try { entry = JSON.parse(reply); } catch(ex) { return cb(ex); }
if (username === entry.uuid) username = 'taas/' + entry.labels[0] + '/steward';
parts = username.split('/');
if (parts.length < 3) return cb(new Error('invalid username'));
branch = parts.slice(1).join('/');
permissions = { publish : [ username, '*/' + branch, '*/' + branch + '/#' ]
, subscribe : [ username, username + '/#' ]
};
for (i = 2; i < parts.length; i++) permissions.subscribe.push(parts.slice(0, i).join('/') + '/#');
if (!!entry.uuid) {
permissions.publish.push('+/' + parts[1] + '/#');
permissions.subscribe.push('+/' + parts[1] + '/#');
}
if (packet.clientId !== branch) return cb(new Error('clientId must match branch'));
if (entry.authParams.protocol === 'bcrypt') {
return bcrypt.compare(password, entry.authParams.hash, function(err, result) {
if (!!err) return cb(err);
if (!result) return cb(new Error('authentication mismatch'));
return cb(null, permissions);
});
}
if (entry.authParams.protocol !== 'totp') {
return cb(new Error('unknown authentication protocol: ' + entry.authParams.protocol));
}
if (password.length < 6) return cb(new Error('response too short'));
// compare against previous, current, and next key to avoid worrying about clock synchornization...
now = [ parseInt(Date.now() / 1000, 10) ];
now.push(now[0] - 30);
now.push(now[0] + 30);
otparams = { key : entry.authParams.base32
, length : password.length
, encoding : 'base32'
, step : entry.authParams.step
};
for (i = 0; i < now.length; i++) {
otparams.time = now[i];
if (speakeasy.totp(otparams) === password.toString()) return cb(null, permissions);
}
cb(new Error('authentication mismatch'));
});
};
var closer = function(self, client) {
try { client.stream.destroy(); } catch(ex) { return options.logger.debug('client', { event: 'closer', status: 'err' }); }
options.logger.debug('client', { event: 'closer', status: 'ok' });
};
var matchP = function(topic, pattern) {
var i, parts;
parts = pattern.split('/');
for (i = 0; i < parts.length; i++) {
if (parts[i] === '#') parts[i] = '**';
else if (parts[i] === '+') parts[i] = '*';
}
pattern = parts.join('/');
if (pattern.indexOf('*') === -1) return (topic === pattern);
return minimatch(topic, pattern);
};
var normalize = function(s) {
var result;
if (!s) return null;
result = s.replace(/(([^/])\/+$)|(([^/]))|(\/+(\/))/g, '$2$4$6');
return ((result.length > 0) ? result : null);
};
var publishP = function(self, client, topic) {
var i, publish;
if ((!topic) || (topic.indexOf('#') !== -1) || (topic.indexOf('+') !== -1) || (topic.indexOf('*') !== -1)) return false;
if ((!client.permissions) || (!client.permissions.publish)) return true;
publish = client.permissions.publish;
for (i = 0; i < publish.length; i++) if (matchP(topic, publish[i])) return true;
options.logger.warning('client', properties(self, client.clientId,
{ event: 'publish', topic: topic, diagnostic: 'no match' }));
return false;
};
var properties = function(self, id, params) {
var k, props;
props = { id: id };
if (!!self.clients[id]) props.username = self.clients[id].username;
if (!!params) for (k in params) if (params.hasOwnProperty(k)) props[k] = params[k];
return props;
};
var subscribeP = function(self, client, pattern) {
var i, subscribe;
if (pattern.indexOf('*') !== -1) return false;
if ((!client.permissions) || (!client.permissions.subscribe)) return true;
subscribe = client.permissions.subscribe;
for (i = 0; i < subscribe.length; i++) if (pattern === subscribe[i]) return true;
options.logger.warning('client', properties(self, client.clientId,
{ event: 'subscribe', pattern: pattern, diagnostic: 'no match' }));
return false;
};
var sync = function(self, clientId, pattern, message) {
var client, i, j, messages, now, timestamp, topic;
if (!clientId) {
for (clientId in self.clients) if (self.clients.hasOwnProperty(clientId)) sync(self, clientId, pattern, message);
return;
}
client = self.clients[clientId];
if (!client.client) return;
if (!pattern) {
for (pattern in client.subscriptions) {
if (client.subscriptions.hasOwnProperty(pattern)) sync(self, clientId, pattern, message);
}
return;
}
if (!!message) {
topic = pattern;
for (pattern in client.subscriptions) {
if ((!client.subscriptions.hasOwnProperty(pattern)) || (!matchP(topic, pattern))) continue;
options.logger.debug('client', properties(self, clientId, { event : 'send'
, topic : topic
, messageId : message.messageId
, octets : message.payload.length
}));
client.client.publish({ topic: topic, messageId: message.messageId, payload: message.payload });
}
return;
}
now = new Date().getTime();
for (topic in self.publications) {
if ((!self.publications.hasOwnProperty(topic)) || (!matchP(topic, pattern))) continue;
messages = self.publications[topic].messages;
if (!messages) continue;
timestamp = client.subscriptions[pattern].timestamp;
for (i = j = 0; i < messages.length; i++) {
if (messages[i].timestamp < timestamp) continue;
message = messages[i];
options.logger.debug('client', properties(self, clientId, { event : 'send'
, topic : topic
, messageId : message.messageId
, octets : message.payload.length
}));
client.client.publish({ topic: topic, messageId: message.messageId, payload: message.payload });
j++;
}
if (j > 0) options.logger.debug('client', properties(self, clientId, { event: 'sync', topic: topic, count: j }));
client.subscriptions[pattern].timestamp = now;
}
};