'use strict'; module.exports.create = function (deps, config) { var stunnel = require('stunnel'); var jwt = require('jsonwebtoken'); var activeTunnels = {}; var activeDomains = {}; var customNet = { createConnection: function (opts, cb) { console.log('[gl.tunnel] creating connection'); // here "reader" means the socket that looks like the connection being accepted // here "writer" means the remote-looking part of the socket that driving the connection var writer; function usePair(err, reader) { if (err) { process.nextTick(function () { writer.emit('error', err); }); return; } var wrapOpts = Object.assign({localAddress: '127.0.0.2', localPort: 'tunnel-0'}, opts); wrapOpts.firstChunk = opts.data; wrapOpts.hyperPeek = !!opts.data; // Also override the remote and local address info. We use `defineProperty` because // otherwise we run into problems of setting properties with only getters defined. Object.defineProperty(reader, 'remoteAddress', { value: wrapOpts.remoteAddress }); Object.defineProperty(reader, 'remotePort', { value: wrapOpts.remotePort }); Object.defineProperty(reader, 'remoteFamiliy', { value: wrapOpts.remoteFamiliy }); Object.defineProperty(reader, 'localAddress', { value: wrapOpts.localAddress }); Object.defineProperty(reader, 'localPort', { value: wrapOpts.localPort }); Object.defineProperty(reader, 'localFamiliy', { value: wrapOpts.localFamiliy }); deps.tcp.handler(reader, wrapOpts); process.nextTick(function () { // this cb will cause the stream to emit its (actually) first data event // (even though it already gave a peek into that first data chunk) console.log('[tunnel] callback, data should begin to flow'); cb(); }); } // We used to use `stream-pair` for non-tls connections, but there are places // that require properties/functions to be present on the socket that aren't // present on a JSStream so it caused problems. writer = require('socket-pair').create(usePair); return writer; } }; function fillData(data) { if (typeof data === 'string') { data = { jwt: data }; } if (!data.jwt) { throw new Error("missing 'jwt' from tunnel data"); } var decoded = jwt.decode(data.jwt); if (!decoded) { throw new Error('invalid JWT'); } if (!data.tunnelUrl) { if (!decoded.aud) { throw new Error('missing tunnelUrl and audience'); } data.tunnelUrl = 'wss://' + decoded.aud + '/'; } data.domains = (decoded.domains || []).slice().sort().join(','); if (!data.domains) { throw new Error('JWT contains no domains to be forwarded'); } return data; } async function removeToken(data) { data = fillData(data); // Not sure if we might want to throw an error indicating 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; } console.log('removing token from tunnel at', data.tunnelUrl); return activeTunnels[data.tunnelUrl].clear(data.jwt).then(function () { delete activeDomains[data.domains]; }); } async function addToken(data) { data = fillData(data); if (activeDomains[data.domains]) { // If already have a token with the exact same domains and to the same tunnel // server there isn't really a need to add a new one if (activeDomains[data.domains].tunnelUrl === data.tunnelUrl) { return; } // Otherwise we want to detach from the other tunnel server in favor of the new one console.warn('added token with the exact same domains as another'); await removeToken(activeDomains[data.domains]); } 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: customNet // 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, 'for domains', data.domains); await activeTunnels[data.tunnelUrl].append(data.jwt); // Now that we know the tunnel server accepted our token we can save it // to keep record of what domains we are handling and what tunnel server // those domains should go to. activeDomains[data.domains] = data; // This is mostly for the start, but return the host for the tunnel server // we've connected to (after stripping the protocol and path away). return data.tunnelUrl.replace(/^[a-z]*:\/\//i, '').replace(/\/.*/, ''); } async function acquireToken(session, domains) { var OAUTH3 = deps.OAUTH3; // The OAUTH3 library stores some things on the root session object that we usually // just leave inside the token, but we need to pull those out before we use it here session.provider_uri = session.provider_uri || session.token.provider_uri || session.token.iss; session.client_uri = session.client_uri || session.token.azp; session.scope = session.scope || session.token.scp; console.log('asking for tunnel token from', session.token.aud); var opts = { api: 'tunnel.token' , session: session , data: { domains: domains , device: { hostname: config.device.hostname , id: config.device.uid || config.device.id } } }; var directives = await OAUTH3.discover(session.token.aud); var tokenData = await OAUTH3.api(directives.api, opts); return addToken(tokenData); } function disconnectAll() { Object.keys(activeTunnels).forEach(function (key) { activeTunnels[key].end(); }); } function currentTokens() { return JSON.parse(JSON.stringify(activeDomains)); } return { start: acquireToken , startDirect: addToken , remove: removeToken , disconnect: disconnectAll , current: currentTokens }; };