goldilocks.js/lib/tunnel-client-manager.js

182 lines
5.8 KiB
JavaScript

'use strict';
module.exports.create = function (deps, config) {
var PromiseA = require('bluebird');
var fs = PromiseA.promisifyAll(require('fs'));
var stunnel = require('stunnel');
var activeTunnels = {};
var path = require('path');
var tokensPath = path.join(__dirname, '..', 'var', 'tokens.json');
var storage = {
_read: function () {
var tokens;
try {
tokens = require(tokensPath);
} catch (err) {
tokens = {};
}
return tokens;
}
, _write: function (tokens) {
return fs.mkdirAsync(path.dirname(tokensPath)).catch(function (err) {
if (err.code !== 'EEXIST') {
console.error('failed to mkdir', path.dirname(tokensPath), err.toString());
}
}).then(function () {
return fs.writeFileAsync(tokensPath, JSON.stringify(tokens), 'utf8');
});
}
, _makeKey: function (token) {
// We use a stripped down version of the token contents so that if the token is
// re-issued the nonce and the iat and any other less important things are different
// we don't save essentially duplicate tokens multiple times.
var parsed = JSON.parse((new Buffer(token.split('.')[1], 'base64')).toString());
var stripped = {};
['aud', 'iss', 'domains'].forEach(function (key) {
if (parsed[key]) {
stripped[key] = parsed[key];
}
});
stripped.domains.sort();
var hash = require('crypto').createHash('sha256');
return hash.update(JSON.stringify(stripped)).digest('hex');
}
, all: function () {
var tokens = storage._read();
return PromiseA.resolve(Object.keys(tokens).map(function (key) {
return tokens[key];
}));
}
, save: function (token) {
return PromiseA.resolve().then(function () {
var curTokens = storage._read();
curTokens[storage._makeKey(token)] = token;
return storage._write(curTokens);
});
}
, del: function (token) {
return PromiseA.resolve().then(function () {
var curTokens = storage._read();
delete curTokens[storage._makeKey(token)];
return storage._write(curTokens);
});
}
};
function addToken(data) {
if (!data.tunnelUrl) {
var decoded;
try {
decoded = JSON.parse(new Buffer(data.jwt.split('.')[1], 'base64').toString('ascii'));
} catch (err) {
console.warn('invalid web token given to tunnel manager', err);
return PromiseA.reject(err);
}
if (!decoded.aud) {
console.warn('tunnel manager given token with no tunnelUrl or audience');
var err = new Error('missing tunnelUrl and audience');
return PromiseA.reject(err);
}
data.tunnelUrl = 'wss://' + decoded.aud + '/';
}
if (!activeTunnels[data.tunnelUrl]) {
console.log('creating new tunnel client for', data.tunnelUrl);
// We create the tunnel without an initial token so we can append the token and
// get the promise that should tell us more about if it worked or not.
activeTunnels[data.tunnelUrl] = stunnel.connect({
stunneld: data.tunnelUrl
, net: deps.tunnel.net
// NOTE: the ports here aren't that important since we are providing a custom
// `net.createConnection` that doesn't actually use the port. What is important
// is that any services we are interested in are listed in this object and have
// a '*' sub-property.
, services: {
https: { '*': 443 }
, http: { '*': 80 }
, smtp: { '*': 25 }
, smtps: { '*': 587 /*also 465/starttls*/ }
, ssh: { '*': 22 }
}
});
}
console.log('appending token to tunnel at', data.tunnelUrl);
return activeTunnels[data.tunnelUrl].append(data.jwt);
}
function removeToken(data) {
if (!data.tunnelUrl) {
var decoded;
try {
decoded = JSON.parse(new Buffer(data.jwt.split('.')[1], 'base64').toString('ascii'));
} catch (err) {
console.warn('invalid web token given to tunnel manager', err);
return PromiseA.reject(err);
}
if (!decoded.aud) {
console.warn('tunnel manager given token with no tunnelUrl or audience');
var err = new Error('missing tunnelUrl and audience');
return PromiseA.reject(err);
}
data.tunnelUrl = 'wss://' + decoded.aud + '/';
}
// Not sure if we actually want to return an error that the token didn't even belong to a
// server that existed, but since it never existed we can consider it as "removed".
if (!activeTunnels[data.tunnelUrl]) {
return PromiseA.resolve();
}
console.log('removing token from tunnel at', data.tunnelUrl);
return activeTunnels[data.tunnelUrl].clear(data.jwt);
}
if (typeof config.tunnel === 'string') {
config.tunnel.split(',').forEach(function (jwt) {
addToken({ jwt: jwt, owner: 'config' });
});
}
storage.all().then(function (stored) {
stored.forEach(function (result) {
addToken(result);
});
});
return {
add: function (data) {
return addToken(data).then(function () {
return storage.save(data);
});
}
, remove: function (data) {
return storage.del(data.jwt).then(function () {
return removeToken(data);
});
}
, get: function (owner) {
return storage.all().then(function (tokens) {
var result = {};
tokens.forEach(function (data) {
if (!result[data.owner]) {
result[data.owner] = {};
}
if (!result[data.owner][data.tunnelUrl]) {
result[data.owner][data.tunnelUrl] = [];
}
data.decoded = JSON.parse(new Buffer(data.jwt.split('.')[0], 'base64'));
result[data.owner][data.tunnelUrl].push(data);
});
if (owner) {
return result[owner] || {};
}
return result;
});
}
};
};