Compare commits

...

14 Commits

14 changed files with 1031 additions and 626 deletions

9
.gitignore vendored
View File

@ -43,3 +43,12 @@ jspm_packages
# Optional REPL history
.node_repl_history
# Snapcraft
/parts/
/prime/
/stage/
.snapcraft
*.snap
*.tar.bz2

View File

@ -10,7 +10,7 @@
, "immed": true
, "undef": true
, "unused": true
, "latedef": true
, "latedef": "nofunc"
, "curly": true
, "trailing": true
}

View File

@ -173,3 +173,23 @@ The user and group `telebit` should be created.
# Linux
sudo setcap 'cap_net_bind_service=+ep' $(which node)
```
API
===
The authentication method is abstract so that it can easily be implemented for various users and use cases.
```
// bin/telebit-relay.js
state.authenticate() // calls either state.extensions.authenticate or state.defaults.authenticate
// which, in turn, calls Server.onAuth()
state.extensions = require('../lib/extensions');
state.extensions.authenticate({
state: state // lib/relay.js in-memory state
, auth: 'xyz.abc.123' // arbitrary token, typically a JWT (default handler)
})
// lib/relay.js
Server.onAuth(state, srv, rawAuth, validatedTokenData);
```

View File

@ -47,7 +47,13 @@ function applyConfig(config) {
} else {
state.Promise = require('bluebird');
}
state.tlsOptions = {}; // TODO just close the sockets that would use this early? or use the admin servername
state.tlsOptions = {
// Handles disconnected devices
// TODO allow user to opt-in to wildcard hosting for a better error page?
SNICallback: function (servername, cb) {
return state.greenlock.tlsOptions.SNICallback(state.config.webminDomain || state.servernames[0], cb);
}
}; // TODO just close the sockets that would use this early? or use the admin servername
state.config = config;
state.servernames = config.servernames || [];
state.secret = state.config.secret;

35
lib/ago-test.js Normal file
View File

@ -0,0 +1,35 @@
'use strict';
var timeago = require('./ago.js').AGO;
function test() {
[ 1.5 * 1000 // a moment ago
, 4.5 * 1000 // moments ago
, 10 * 1000 // 10 seconds ago
, 59 * 1000 // a minute ago
, 60 * 1000 // a minute ago
, 61 * 1000 // a minute ago
, 119 * 1000 // a minute ago
, 120 * 1000 // 2 minutes ago
, 121 * 1000 // 2 minutes ago
, (60 * 60 * 1000) - 1000 // 59 minutes ago
, 1 * 60 * 60 * 1000 // an hour ago
, 1.5 * 60 * 60 * 1000 // an hour ago
, 2.5 * 60 * 60 * 1000 // 2 hours ago
, 1.5 * 24 * 60 * 60 * 1000 // a day ago
, 2.5 * 24 * 60 * 60 * 1000 // 2 days ago
, 7 * 24 * 60 * 60 * 1000 // a week ago
, 14 * 24 * 60 * 60 * 1000 // 2 weeks ago
, 27 * 24 * 60 * 60 * 1000 // 3 weeks ago
, 28 * 24 * 60 * 60 * 1000 // 4 weeks ago
, 29 * 24 * 60 * 60 * 1000 // 4 weeks ago
, 1.5 * 30 * 24 * 60 * 60 * 1000 // a month ago
, 2.5 * 30 * 24 * 60 * 60 * 1000 // 2 months ago
, (12 * 30 * 24 * 60 * 60 * 1000) + 1000 // 12 months ago
, 13 * 30 * 24 * 60 * 60 * 1000 // over a year ago
].forEach(function (d) {
console.log(d, '=', timeago(d));
});
}
test();

50
lib/ago.js Normal file
View File

@ -0,0 +1,50 @@
;(function (exports) {
'use strict';
exports.AGO = function timeago(ms) {
var ago = Math.floor(ms / 1000);
var part = 0;
if (ago < 2) { return "a moment ago"; }
if (ago < 5) { return "moments ago"; }
if (ago < 60) { return ago + " seconds ago"; }
if (ago < 120) { return "a minute ago"; }
if (ago < 3600) {
while (ago >= 60) { ago -= 60; part += 1; }
return part + " minutes ago";
}
if (ago < 7200) { return "an hour ago"; }
if (ago < 86400) {
while (ago >= 3600) { ago -= 3600; part += 1; }
return part + " hours ago";
}
if (ago < 172800) { return "a day ago"; }
if (ago < 604800) {
while (ago >= 172800) { ago -= 172800; part += 1; }
return part + " days ago";
}
if (ago < 1209600) { return "a week ago"; }
if (ago < 2592000) {
while (ago >= 604800) { ago -= 604800; part += 1; }
return part + " weeks ago";
}
if (ago < 5184000) { return "a month ago"; }
if (ago < 31536001) {
while (ago >= 2592000) { ago -= 2592000; part += 1; }
return part + " months ago";
}
if (ago < 315360000) { // 10 years
return "more than year ago";
}
// TODO never
return "";
};
}('undefined' !== typeof module ? module.exports : window));

View File

@ -1,45 +1,138 @@
'use strict';
var Devices = module.exports;
Devices.add = function (store, servername, newDevice) {
var devices = store[servername] || [];
devices.push(newDevice);
store[servername] = devices;
// TODO enumerate store's keys and device's keys for documentation
Devices.addPort = function (store, serverport, newDevice) {
// TODO make special
return Devices.add(store, serverport, newDevice, true);
};
Devices.add = function (store, servername, newDevice, isPort) {
if (isPort) {
if (!store._ports) { store._ports = {}; }
}
// add domain (also handles ports at the moment)
if (!store._domains) { store._domains = {}; }
if (!store._domains[servername]) { store._domains[servername] = []; }
store._domains[servername].push(newDevice);
Devices.touch(store, servername);
// add device
// TODO only use a device id
var devId = newDevice.id || servername;
if (!newDevice.__servername) {
newDevice.__servername = servername;
}
if (!store._devices) { store._devices = {}; }
if (!store._devices[devId]) {
store._devices[devId] = newDevice;
if (!store._devices[devId].domainsMap) { store._devices[devId].domainsMap = {}; }
if (!store._devices[devId].domainsMap[servername]) { store._devices[devId].domainsMap[servername] = true; }
}
};
Devices.alias = function (store, servername, alias) {
if (!store._domains[servername]) {
store._domains[servername] = [];
}
if (!store._domains[servername]._primary) {
store._domains[servername]._primary = servername;
}
if (!store._domains[servername].aliases) {
store._domains[servername].aliases = {};
}
store._domains[alias] = store._domains[servername];
store._domains[servername].aliases[alias] = true;
};
Devices.remove = function (store, servername, device) {
var devices = store[servername] || [];
// Check if this domain has an active device
var devices = store._domains[servername] || [];
var index = devices.indexOf(device);
if (index < 0) {
console.warn('attempted to remove non-present device', device.deviceId, 'from', servername);
return null;
}
// unlink this domain from this device
var domainsMap = store._devices[devices[index].id || servername].domainsMap;
delete domainsMap[servername];
/*
// remove device if no domains remain
// nevermind, a device can hang around in limbo for a bit
if (!Object.keys(domains).length) {
delete store._devices[devices[index].id || servername];
}
*/
// unlink this device from this domain
return devices.splice(index, 1)[0];
};
Devices.close = function (store, device) {
var dev = store._devices[device.id || device.__servername];
// because we're actually using names rather than don't have reliable deviceIds yet
if (!dev) {
Object.keys(store._devices).some(function (key) {
if (store._devices[key].socketId === device.socketId) {
// TODO double check that all domains are removed
delete store._devices[key];
return true;
}
});
}
};
Devices.bySocket = function (store, socketId) {
var dev;
Object.keys(store._devices).some(function (k) {
if (store._devices[k].socketId === socketId) {
dev = store._devices[k];
return dev;
}
});
return dev;
};
Devices.list = function (store, servername) {
if (store[servername] && store[servername].length) {
return store[servername];
console.log('[dontkeepme] servername', servername);
// efficient lookup first
if (store._domains[servername] && store._domains[servername].length) {
// aliases have ._primary which is the name of the original
return store._domains[servername]._primary && store._domains[store._domains[servername]._primary] || store._domains[servername];
}
// There wasn't an exact match so check any of the wildcard domains, sorted longest
// first so the one with the biggest natural match with be found first.
var deviceList = [];
Object.keys(store).filter(function (pattern) {
return pattern[0] === '*' && store[pattern].length;
Object.keys(store._domains).filter(function (pattern) {
return pattern[0] === '*' && store._domains[pattern].length;
}).sort(function (a, b) {
return b.length - a.length;
}).some(function (pattern) {
var subPiece = pattern.slice(1);
if (subPiece === servername.slice(-subPiece.length)) {
console.log('"'+servername+'" matches "'+pattern+'"');
deviceList = store[pattern];
console.log('[Devices.list] "'+servername+'" matches "'+pattern+'"');
deviceList = store._domains[pattern];
// Devices.alias(store, '*.example.com', 'sub.example.com'
// '*.example.com' retrieves a reference to 'example.com'
// and this reference then also referenced by 'sub.example.com'
// Hence this O(n) check is replaced with the O(1) check above
Devices.alias(store, pattern, servername);
return true;
}
});
return deviceList;
};
/*
Devices.active = function (store, id) {
var dev = store._devices[id];
return !!dev;
};
*/
Devices.exist = function (store, servername) {
return !!(Devices.list(store, servername).length);
if (Devices.list(store, servername).length) {
Devices.touch(store, servername);
return true;
}
return false;
};
Devices.next = function (store, servername) {
var devices = Devices.list(store, servername);
@ -51,5 +144,20 @@ Devices.next = function (store, servername) {
device = devices[devices._index || 0];
devices._index = (devices._index || 0) + 1;
if (device) { Devices.touch(store, servername); }
return device;
};
Devices.touchDevice = function (store, device) {
// TODO use device.id (which will be pubkey thumbprint) and store._devices[id].domainsMap
Object.keys(device.domainsMap).forEach(function (servername) {
Devices.touch(store, servername);
});
};
Devices.touch = function (store, servername) {
if (!store._recency) { store._recency = {}; }
store._recency[servername] = Date.now();
};
Devices.lastSeen = function (store, servername) {
if (!store._recency) { store._recency = {}; }
return store._recency[servername] || 0;
};

View File

@ -10,7 +10,7 @@ function noSniCallback(tag) {
var err = new Error("[noSniCallback] no handler set for '" + tag + "':'" + servername + "'");
console.error(err.message);
cb(new Error(err));
}
};
}
module.exports.create = function (state) {
@ -72,38 +72,44 @@ module.exports.create = function (state) {
state.tlsInvalidSniServer.on('tlsClientError', function () {
console.error('tlsClientError InvalidSniServer');
});
state.httpsInvalid = function (servername, socket) {
state.createHttpInvalid = function (opts) {
return http.createServer(function (req, res) {
if (!opts.servername) {
res.statusCode = 422;
res.setHeader('Content-Type', 'text/plain; charset=utf-8');
res.end(
"3. An inexplicable temporal shift of the quantum realm... that makes me feel uncomfortable.\n\n"
+ "[ERROR] No SNI header was sent. I can only think of two possible explanations for this:\n"
+ "\t1. You really love Windows XP and you just won't let go of Internet Explorer 6\n"
+ "\t2. You're writing a bot and you forgot to set the servername parameter\n"
);
return;
}
// TODO use req.headers.host instead of servername (since domain fronting is disabled anyway)
res.statusCode = 502;
res.setHeader('Content-Type', 'text/html; charset=utf-8');
res.end(
"<h1>Oops!</h1>"
+ "<p>It looks like '" + encodeURIComponent(opts.servername) + "' isn't connected right now.</p>"
+ "<p><small>Last seen: " + opts.ago + "</small></p>"
+ "<p><small>Error: 502 Bad Gateway</small></p>"
);
});
};
state.httpsInvalid = function (opts, socket) {
// none of these methods work:
// httpsServer.emit('connection', socket); // this didn't work
// tlsServer.emit('connection', socket); // this didn't work either
//console.log('chunkLen', firstChunk.byteLength);
console.log('[httpsInvalid] servername', servername);
console.log('[httpsInvalid] servername', opts.servername);
//state.tlsInvalidSniServer.emit('connection', wrapSocket(socket));
var tlsInvalidSniServer = tls.createServer(state.tlsOptions, function (tlsSocket) {
console.log('[tlsInvalid] tls connection');
// things get a little messed up here
var httpInvalidSniServer = http.createServer(function (req, res) {
if (!servername) {
res.statusCode = 422;
res.end(
"3. An inexplicable temporal shift of the quantum realm... that makes me feel uncomfortable.\n\n"
+ "[ERROR] No SNI header was sent. I can only think of two possible explanations for this:\n"
+ "\t1. You really love Windows XP and you just won't let go of Internet Explorer 6\n"
+ "\t2. You're writing a bot and you forgot to set the servername parameter\n"
);
return;
}
res.end(
"You came in hot looking for '" + servername + "' and, granted, the IP address for that domain"
+ " must be pointing here (or else how could you be here?), nevertheless either it's not registered"
+ " in the internal system at all (which Seth says isn't even a thing) or there is no device"
+ " connected on the south side of the network which has informed me that it's ready to have traffic"
+ " for that domain forwarded to it (sorry I didn't check that deeply to determine which).\n\n"
+ "Either way, you're doing strange things that make me feel uncomfortable... Please don't touch me there any more.");
});
httpInvalidSniServer.emit('connection', tlsSocket);
// We create an entire http server object because it's difficult to figure out
// how to access the original tlsSocket to get the servername
state.createHttpInvalid(opts).emit('connection', tlsSocket);
});
tlsInvalidSniServer.on('tlsClientError', function () {
console.error('tlsClientError InvalidSniServer httpsInvalid');
@ -136,10 +142,21 @@ module.exports.create = function (state) {
console.log('[Admin] custom or null tlsOptions for SNICallback');
tunnelAdminTlsOpts.SNICallback = tunnelAdminTlsOpts.SNICallback || noSniCallback('admin');
}
var MPROXY = Buffer.from("MPROXY");
state.tlsTunnelServer = tls.createServer(tunnelAdminTlsOpts, function (tlsSocket) {
if (state.debug) { console.log('[Admin] new tls-terminated connection'); }
// things get a little messed up here
(state.httpTunnelServer || state.httpServer).emit('connection', tlsSocket);
tlsSocket.once('readable', function () {
var firstChunk = tlsSocket.read();
tlsSocket.unshift(firstChunk);
if (0 === MPROXY.compare(firstChunk.slice(0, 4))) {
tlsSocket.end("MPROXY isn't supported yet");
return;
}
// things get a little messed up here
(state.httpTunnelServer || state.httpServer).emit('connection', tlsSocket);
});
});
state.tlsTunnelServer.on('tlsClientError', function () {
console.error('tlsClientError TunnelServer client error');

View File

@ -16,7 +16,13 @@ module.exports = function pipeWs(servername, service, srv, conn, serviceport) {
function sendWs(data, serviceOverride) {
if (srv.ws && (!conn.tunnelClosing || serviceOverride)) {
try {
srv.ws.send(Packer.pack(browserAddr, data, serviceOverride), { binary: true });
if (data && !Buffer.isBuffer(data)) {
data = Buffer.from(JSON.stringify(data));
}
srv.ws.send(Packer.packHeader(browserAddr, data, serviceOverride), { binary: true });
if (data) {
srv.ws.send(data, { binary: true });
}
// If we can't send data over the websocket as fast as this connection can send it to us
// (or there are a lot of connections trying to send over the same websocket) then we
// need to pause the connection for a little. We pause all connections if any are paused
@ -39,6 +45,10 @@ module.exports = function pipeWs(servername, service, srv, conn, serviceport) {
conn.serviceport = serviceport;
conn.service = service;
// send peek at data too?
srv.ws.send(Packer.packHeader(browserAddr, null, 'connection'), { binary: true });
// TODO convert to read stream?
conn.on('data', function (chunk) {
//if (state.debug) { console.log('[pipeWs] client', cid, ' => srv', rid, chunk.byteLength, 'bytes'); }
sendWs(chunk);

View File

@ -1,535 +1,12 @@
'use strict';
var url = require('url');
var PromiseA = require('bluebird');
var sni = require('sni');
var Packer = require('proxy-packer');
var PortServers = {};
function timeoutPromise(duration) {
return new PromiseA(function (resolve) {
setTimeout(resolve, duration);
});
}
var Devices = require('./device-tracker');
var pipeWs = require('./pipe-ws.js');
var Server = {
_initCommandHandlers: function (state, srv) {
var commandHandlers = {
add_token: function addToken(newAuth) {
return Server.addToken(state, srv, newAuth);
}
, delete_token: function (token) {
return state.Promise.resolve(function () {
var err;
if (token !== '*') {
err = Server.removeToken(state, srv, token);
if (err) { return state.Promise.reject(err); }
}
Object.keys(srv.grants).some(function (jwtoken) {
err = Server.removeToken(state, srv, jwtoken);
return err;
});
if (err) { return state.Promise.reject(err); }
return null;
});
}
};
commandHandlers.auth = commandHandlers.add_token;
commandHandlers.authn = commandHandlers.add_token;
commandHandlers.authz = commandHandlers.add_token;
srv._commandHandlers = commandHandlers;
}
, _initPackerHandlers: function (state, srv) {
var packerHandlers = {
oncontrol: function (tun) {
var cmd;
try {
cmd = JSON.parse(tun.data.toString());
} catch (e) {}
if (!Array.isArray(cmd) || typeof cmd[0] !== 'number') {
var msg = 'received bad command "' + tun.data.toString() + '"';
console.warn(msg, 'from websocket', srv.socketId);
Server.sendTunnelMsg(srv, null, [0, {message: msg, code: 'E_BAD_COMMAND'}], 'control');
return;
}
if (cmd[0] < 0) {
// We only ever send one command and we send it once, so we just hard coded the ID as 1.
if (cmd[0] === -1) {
if (cmd[1]) {
console.warn('received error response to hello from', srv.socketId, cmd[1]);
}
}
else {
console.warn('received response to unknown command', cmd, 'from', srv.socketId);
}
return;
}
if (cmd[0] === 0) {
console.warn('received dis-associated error from', srv.socketId, cmd[1]);
return;
}
function onSuccess() {
Server.sendTunnelMsg(srv, null, [-cmd[0], null], 'control');
}
function onError(err) {
Server.sendTunnelMsg(srv, null, [-cmd[0], err], 'control');
}
if (!srv._commandHandlers[cmd[1]]) {
onError({ message: 'unknown command "'+cmd[1]+'"', code: 'E_UNKNOWN_COMMAND' });
return;
}
console.log('command:', cmd[1], cmd.slice(2));
return srv._commandHandlers[cmd[1]].apply(null, cmd.slice(2)).then(onSuccess, onError);
}
, onmessage: function (tun) {
var cid = Packer.addrToId(tun);
if (state.debug) { console.log("remote '" + Server.logName(state, srv) + "' has data for '" + cid + "'", tun.data.byteLength); }
var browserConn = Server.getBrowserConn(state, srv, cid);
if (!browserConn) {
Server.sendTunnelMsg(srv, tun, {message: 'no matching connection', code: 'E_NO_CONN'}, 'error');
return;
}
browserConn.write(tun.data);
// tunnelRead is how many bytes we've read from the tunnel, and written to the browser.
browserConn.tunnelRead = (browserConn.tunnelRead || 0) + tun.data.byteLength;
// If we have more than 1MB buffered data we need to tell the other side to slow down.
// Once we've finished sending what we have we can tell the other side to keep going.
// If we've already sent the 'pause' message though don't send it again, because we're
// probably just dealing with data queued before our message got to them.
if (!browserConn.remotePaused && browserConn.bufferSize > 1024*1024) {
Server.sendTunnelMsg(srv, tun, browserConn.tunnelRead, 'pause');
browserConn.remotePaused = true;
browserConn.once('drain', function () {
Server.sendTunnelMsg(srv, tun, browserConn.tunnelRead, 'resume');
browserConn.remotePaused = false;
});
}
}
, onpause: function (tun) {
var cid = Packer.addrToId(tun);
console.log('[TunnelPause]', cid);
var browserConn = Server.getBrowserConn(state, srv, cid);
if (browserConn) {
browserConn.manualPause = true;
browserConn.pause();
} else {
Server.sendTunnelMsg(srv, tun, {message: 'no matching connection', code: 'E_NO_CONN'}, 'error');
}
}
, onresume: function (tun) {
var cid = Packer.addrToId(tun);
console.log('[TunnelResume]', cid);
var browserConn = Server.getBrowserConn(state, srv, cid);
if (browserConn) {
browserConn.manualPause = false;
browserConn.resume();
} else {
Server.sendTunnelMsg(srv, tun, {message: 'no matching connection', code: 'E_NO_CONN'}, 'error');
}
}
, onend: function (tun) {
var cid = Packer.addrToId(tun);
console.log('[TunnelEnd]', cid);
Server.closeBrowserConn(state, srv, cid);
}
, onerror: function (tun) {
var cid = Packer.addrToId(tun);
console.warn('[TunnelError]', cid, tun.message);
Server.closeBrowserConn(state, srv, cid);
}
};
srv._packerHandlers = packerHandlers;
srv.unpacker = Packer.create(srv._packerHandlers);
}
, _initSocketHandlers: function (state, srv) {
function refreshTimeout() {
srv.lastActivity = Date.now();
}
function checkTimeout() {
// Determine how long the connection has been "silent", ie no activity.
var silent = Date.now() - srv.lastActivity;
// If we have had activity within the last activityTimeout then all we need to do is
// call this function again at the soonest time when the connection could be timed out.
if (silent < state.activityTimeout) {
srv.timeoutId = setTimeout(checkTimeout, state.activityTimeout - silent);
}
// Otherwise we check to see if the pong has also timed out, and if not we send a ping
// and call this function again when the pong will have timed out.
else if (silent < state.activityTimeout + state.pongTimeout) {
if (state.debug) { console.log('pinging', Server.logName(state, srv)); }
try {
srv.ws.ping();
} catch (err) {
console.warn('failed to ping home cloud', Server.logName(state, srv));
}
srv.timeoutId = setTimeout(checkTimeout, state.pongTimeout);
}
// Last case means the ping we sent before didn't get a response soon enough, so we
// need to close the websocket connection.
else {
console.warn('home cloud', Server.logName(state, srv), 'connection timed out');
srv.ws.close(1013, 'connection timeout');
}
}
function forwardMessage(chunk) {
refreshTimeout();
if (state.debug) { console.log('[ws] device => client : demultiplexing message ', chunk.byteLength, 'bytes'); }
//console.log(chunk.toString());
srv.unpacker.fns.addChunk(chunk);
}
function hangup() {
clearTimeout(srv.timeoutId);
console.log('[ws] device hangup', Server.logName(state, srv), 'connection closing');
Object.keys(srv.grants).forEach(function (jwtoken) {
Server.removeToken(state, srv, jwtoken);
});
srv.ws.terminate();
}
srv.lastActivity = Date.now();
srv.timeoutId = null;
srv.timeoutId = setTimeout(checkTimeout, state.activityTimeout);
// Note that our websocket library automatically handles pong responses on ping requests
// before it even emits the event.
srv.ws.on('ping', refreshTimeout);
srv.ws.on('pong', refreshTimeout);
srv.ws.on('message', forwardMessage);
srv.ws.on('close', hangup);
srv.ws.on('error', hangup);
}
, init: function init(state, srv) {
Server._initCommandHandlers(state, srv);
Server._initPackerHandlers(state, srv);
Server._initSocketHandlers(state, srv);
// Status Code '1' for Status 'hello'
Server.sendTunnelMsg(srv, null, [1, 'hello', [srv.unpacker._version], Object.keys(srv._commandHandlers)], 'control');
}
, sendTunnelMsg: function sendTunnelMsg(srv, addr, data, service) {
srv.ws.send(Packer.pack(addr, data, service), {binary: true});
}
, logName: function logName(state, srv) {
var result = Object.keys(srv.grants).map(function (jwtoken) {
return srv.grants[jwtoken].currentDesc;
}).join(';');
return result || srv.socketId;
}
, onAuth: function onAuth(state, srv, newAuth, grant) {
console.log('\n[relay.js] onAuth');
console.log(newAuth);
console.log(grant);
//var stringauth;
var err;
if (!grant || 'object' !== typeof grant) {
console.log('[relay.js] invalid token', grant);
err = new Error("invalid access token");
err.code = "E_INVALID_TOKEN";
return state.Promise.reject(err);
}
if ('string' !== typeof newAuth) {
newAuth = JSON.stringify(newAuth);
}
console.log('check for upgrade token');
if (grant.jwt && newAuth !== grant.jwt) {
console.log('new token to send back');
// Access Token
Server.sendTunnelMsg(
srv
, null
, [ 3
, 'access_token'
, { jwt: grant.jwt }
]
, 'control'
);
// these aren't needed internally once they're sent
grant.jwt = null;
}
/*
if (!Array.isArray(grant.domains) || !grant.domains.length) {
err = new Error("invalid domains array");
err.code = "E_INVALID_NAME";
return state.Promise.reject(err);
}
*/
if (grant.domains.some(function (name) { return typeof name !== 'string'; })) {
console.log('bad domain names');
err = new Error("invalid domain name(s)");
err.code = "E_INVALID_NAME";
return state.Promise.reject(err);
}
console.log('strolling through pleasantries');
// Add the custom properties we need to manage this remote, then add it to all the relevant
// domains and the list of all this websocket's grants.
grant.domains.forEach(function (domainname) {
console.log('add', domainname, 'to device lists');
srv.domainsMap[domainname] = true;
Devices.add(state.deviceLists, domainname, srv);
});
srv.domains = Object.keys(srv.domainsMap);
srv.currentDesc = (grant.device && (grant.device.id || grant.device.hostname)) || srv.domains.join(',');
grant.currentDesc = (grant.device && (grant.device.id || grant.device.hostname)) || grant.domains.join(',');
grant.srv = srv;
//grant.ws = srv.ws;
//grant.upgradeReq = srv.upgradeReq;
grant.clients = {};
if (!grant.ports) { grant.ports = []; }
function openPort(serviceport) {
function tcpListener(conn) {
Server.onDynTcpConn(state, srv, srv.portsMap[serviceport], conn);
}
serviceport = parseInt(serviceport, 10) || 0;
if (!serviceport) {
// TODO error message about bad port
return;
}
if (PortServers[serviceport]) {
console.log('reuse', serviceport, 'for this connection');
//grant.ports = [];
srv.portsMap[serviceport] = PortServers[serviceport];
srv.portsMap[serviceport].on('connection', tcpListener);
srv.portsMap[serviceport].tcpListener = tcpListener;
Devices.add(state.deviceLists, serviceport, srv);
} else {
try {
console.log('use new', serviceport, 'for this connection');
srv.portsMap[serviceport] = PortServers[serviceport] = require('net').createServer(tcpListener);
srv.portsMap[serviceport].tcpListener = tcpListener;
srv.portsMap[serviceport].listen(serviceport, function () {
console.info('[DynTcpConn] Port', serviceport, 'now open for', grant.currentDesc);
Devices.add(state.deviceLists, serviceport, srv);
});
srv.portsMap[serviceport].on('error', function (e) {
// TODO try again with random port
console.error("Server Error assigning a dynamic port to a new connection:", e);
});
} catch(e) {
// what a wonderful problem it will be the day that this bug needs to be fixed
// (i.e. there are enough users to run out of ports)
console.error("Error assigning a dynamic port to a new connection:", e);
}
}
}
grant.ports.forEach(openPort);
srv.grants[newAuth] = grant;
console.info("[ws] authorized", srv.socketId, "for", grant.currentDesc);
console.log('notify of grants', grant.domains, grant.ports);
Server.sendTunnelMsg(
srv
, null
, [ 2
, 'grant'
, [ ['ssh+https', grant.domains[0], 443 ]
, ['ssh', 'ssh.' + state.config.sharedDomain, grant.ports ]
, ['tcp', 'tcp.' + state.config.sharedDomain, grant.ports ]
, ['https', grant.domains[0] ]
]
]
, 'control'
);
return null;
}
, onDynTcpConn: function onDynTcpConn(state, srv, server, conn) {
var serviceport = server.address().port;
console.log('[DynTcpConn] new connection on', serviceport);
var nextDevice = Devices.next(state.deviceLists, serviceport);
if (!nextDevice) {
conn.write("[Sanity Error] I've got a blank space baby, but nowhere to write your name.");
conn.end();
try {
server.close();
} catch(e) {
console.error("[DynTcpConn] failed to close server:", e);
}
return;
}
conn.once('data', function (firstChunk) {
if (state.debug) { console.log("[DynTcp]", serviceport, "examining firstChunk from", Packer.socketToId(conn)); }
conn.pause();
//conn.unshift(firstChunk);
conn._handle.onread(firstChunk.length, firstChunk);
var servername;
var hostname;
var str;
var m;
if (22 === firstChunk[0]) {
servername = (sni(firstChunk)||'').toLowerCase();
} else if (firstChunk[0] > 32 && firstChunk[0] < 127) {
str = firstChunk.toString();
m = str.match(/(?:^|[\r\n])Host: ([^\r\n]+)[\r\n]*/im);
hostname = (m && m[1].toLowerCase() || '').split(':')[0];
}
if (servername || hostname) {
if (servername) {
conn.write("TLS with sni is allowed only on standard ports. If you've registered '" + servername + "' use port 443.");
} else {
conn.write("HTTP with Host headers is not allowed on dynamic ports. If you've registered '" + hostname + "' use port 80.");
}
conn.end();
return;
}
// pipeWs(servername, servicename, srv, client, serviceport)
// remote.clients is managed as part of the piping process
if (state.debug) { console.log("[DynTcp]", serviceport, "piping to srv (via loadbal)"); }
pipeWs(null, 'tcp', nextDevice, conn, serviceport);
process.nextTick(function () { conn.resume(); });
});
}
, addToken: function addToken(state, srv, newAuth) {
console.log("addToken", newAuth);
if (srv.grants[newAuth]) {
console.log("addToken - duplicate");
// return { message: "token sent multiple times", code: "E_TOKEN_REPEAT" };
return state.Promise.resolve(null);
}
return state.authenticate({ auth: newAuth }).then(function (authnToken) {
console.log('\n[relay.js] newAuth');
console.log(newAuth);
console.log('\n[relay.js] authnToken');
console.log(authnToken);
if (authnToken.id) {
state.srvs[authnToken.id] = state.srvs[authnToken.id] || {};
state.srvs[authnToken.id].updateAuth = function (validToken) {
return Server.onAuth(state, srv, newAuth, validToken);
};
}
// will return rejection if necessary
return state.srvs[authnToken.id].updateAuth(authnToken);
});
}
, removeToken: function removeToken(state, srv, jwtoken) {
var grant = srv.grants[jwtoken];
if (!grant) {
return { message: 'specified token not present', code: 'E_INVALID_TOKEN'};
}
// Prevent any more browser connections for this grant being sent to this srv,
// and any existing connections from trying to send more data across the connection.
grant.domains.forEach(function (domainname) {
Devices.remove(state.deviceLists, domainname, srv);
});
grant.ports.forEach(function (portnumber) {
Devices.remove(state.deviceLists, portnumber, srv);
if (!srv.portsMap[portnumber]) { return; }
try {
srv.portsMap[portnumber].close(function () {
console.log("[DynTcpConn] closing server for ", portnumber);
delete srv.portsMap[portnumber];
delete PortServers[portnumber];
});
} catch(e) { /*ignore*/ }
});
// Close all of the existing browser connections associated with this websocket connection.
Object.keys(grant.clients).forEach(function (cid) {
Server.closeBrowserConn(state, srv, cid);
});
delete srv.grants[jwtoken];
console.log("[ws] removed token '" + grant.currentDesc + "' from", srv.socketId);
return null;
}
, getBrowserConn: function getBrowserConn(state, srv, cid) {
return srv.clients[cid];
}
, closeBrowserConn: function closeBrowserConn(state, srv, cid) {
if (!srv.clients[cid]) {
return;
}
PromiseA.resolve().then(function () {
var conn = srv.clients[cid];
conn.tunnelClosing = true;
conn.end();
// If no data is buffered for writing then we don't need to wait for it to drain.
if (!conn.bufferSize) {
return timeoutPromise(500);
}
// Otherwise we want the connection to be able to finish, but we also want to impose
// a time limit for it to drain, since it shouldn't have more than 1MB buffered.
return new PromiseA(function (resolve) {
var timeoutId = setTimeout(resolve, 60*1000);
conn.once('drain', function () {
clearTimeout(timeoutId);
setTimeout(resolve, 500);
});
});
}).then(function () {
if (srv.clients[cid]) {
console.warn(cid, 'browser connection still present after calling `end`');
srv.clients[cid].destroy();
return timeoutPromise(500);
}
}).then(function () {
if (srv.clients[cid]) {
console.error(cid, 'browser connection still present after calling `destroy`');
delete srv.clients[cid];
}
}).catch(function (err) {
console.warn('failed to close browser connection', cid, err);
});
}
, parseAuth: function parseAuth(state, srv) {
var authn = (srv.upgradeReq.headers.authorization||'').split(/\s+/);
if (authn[0] && 'basic' === authn[0].toLowerCase()) {
try {
authn = new Buffer(authn[1], 'base64').toString('ascii').split(':');
return authn[1];
} catch (err) { }
}
return url.parse(srv.upgradeReq.url, true).query.access_token;
}
};
var Server = require('./server.js');
module.exports.store = { Devices: Devices };
module.exports.create = function (state) {
state.deviceLists = {};
state.deviceLists = { _domains: {}, _devices: {} };
state.deviceCallbacks = {};
state.srvs = {};
@ -556,12 +33,16 @@ module.exports.create = function (state) {
var initToken;
srv.ws = _ws;
srv.upgradeReq = _upgradeReq;
// TODO use device's ECDSA thumbprint as device id
srv.id = null;
srv.socketId = Packer.socketToId(srv.upgradeReq.socket);
srv.grants = {};
srv.clients = {};
srv.domainsMap = {};
srv.portsMap = {};
srv.pausedConns = [];
srv.domains = [];
srv.ports = [];
if (state.debug) { console.log('[ws] connection', srv.socketId); }

580
lib/server.js Normal file
View File

@ -0,0 +1,580 @@
'use strict';
var url = require('url');
var sni = require('sni');
var Packer = require('proxy-packer');
var PromiseA;
try {
PromiseA = require('bluebird');
} catch(e) {
PromiseA = global.Promise;
}
function timeoutPromise(duration) {
return new PromiseA(function (resolve) {
setTimeout(resolve, duration);
});
}
var Devices = require('./device-tracker');
var pipeWs = require('./pipe-ws.js');
var PortServers = {};
var Server = {
_initCommandHandlers: function (state, srv) {
var commandHandlers = {
add_token: function addToken(newAuth) {
return Server.addToken(state, srv, newAuth);
}
, delete_token: function (token) {
return state.Promise.resolve(function () {
var err;
if (token !== '*') {
err = Server.removeToken(state, srv, token);
if (err) { return state.Promise.reject(err); }
}
Object.keys(srv.grants).some(function (jwtoken) {
err = Server.removeToken(state, srv, jwtoken);
return err;
});
if (err) { return state.Promise.reject(err); }
return null;
});
}
};
commandHandlers.auth = commandHandlers.add_token;
commandHandlers.authn = commandHandlers.add_token;
commandHandlers.authz = commandHandlers.add_token;
srv._commandHandlers = commandHandlers;
}
, _initPackerHandlers: function (state, srv) {
var packerHandlers = {
oncontrol: function (tun) {
var cmd;
try {
cmd = JSON.parse(tun.data.toString());
} catch (e) {}
if (!Array.isArray(cmd) || typeof cmd[0] !== 'number') {
var msg = 'received bad command "' + tun.data.toString() + '"';
console.warn(msg, 'from websocket', srv.socketId);
Server.sendTunnelMsg(srv, null, [0, {message: msg, code: 'E_BAD_COMMAND'}], 'control');
return;
}
if (cmd[0] < 0) {
// We only ever send one command and we send it once, so we just hard coded the ID as 1.
if (cmd[0] === -1) {
if (cmd[1]) {
console.warn('received error response to hello from', srv.socketId, cmd[1]);
}
}
else {
console.warn('received response to unknown command', cmd, 'from', srv.socketId);
}
return;
}
if (cmd[0] === 0) {
console.warn('received dis-associated error from', srv.socketId, cmd[1]);
return;
}
function onSuccess() {
Server.sendTunnelMsg(srv, null, [-cmd[0], null], 'control');
}
function onError(err) {
Server.sendTunnelMsg(srv, null, [-cmd[0], err], 'control');
}
if (!srv._commandHandlers[cmd[1]]) {
onError({ message: 'unknown command "'+cmd[1]+'"', code: 'E_UNKNOWN_COMMAND' });
return;
}
console.log('command:', cmd[1], cmd.slice(2));
return srv._commandHandlers[cmd[1]].apply(null, cmd.slice(2)).then(onSuccess, onError);
}
, onconnection: function (/*tun*/) {
// I don't think this event can happen since this relay
// is acting the part of the client, but just in case...
// (in fact it should probably be explicitly disallowed)
console.error("[SANITY FAIL] reverse connection start");
}
, onmessage: function (tun) {
var cid = Packer.addrToId(tun);
if (state.debug) { console.log("remote '" + Server.logName(state, srv) + "' has data for '" + cid + "'", tun.data.byteLength); }
var browserConn = Server.getBrowserConn(state, srv, cid);
if (!browserConn) {
Server.sendTunnelMsg(srv, tun, {message: 'no matching connection', code: 'E_NO_CONN'}, 'error');
return;
}
browserConn.write(tun.data);
// tunnelRead is how many bytes we've read from the tunnel, and written to the browser.
browserConn.tunnelRead = (browserConn.tunnelRead || 0) + tun.data.byteLength;
// If we have more than 1MB buffered data we need to tell the other side to slow down.
// Once we've finished sending what we have we can tell the other side to keep going.
// If we've already sent the 'pause' message though don't send it again, because we're
// probably just dealing with data queued before our message got to them.
if (!browserConn.remotePaused && browserConn.bufferSize > 1024*1024) {
Server.sendTunnelMsg(srv, tun, browserConn.tunnelRead, 'pause');
browserConn.remotePaused = true;
browserConn.once('drain', function () {
Server.sendTunnelMsg(srv, tun, browserConn.tunnelRead, 'resume');
browserConn.remotePaused = false;
});
}
}
, onpause: function (tun) {
var cid = Packer.addrToId(tun);
console.log('[TunnelPause]', cid);
var browserConn = Server.getBrowserConn(state, srv, cid);
if (browserConn) {
browserConn.manualPause = true;
browserConn.pause();
} else {
Server.sendTunnelMsg(srv, tun, {message: 'no matching connection', code: 'E_NO_CONN'}, 'error');
}
}
, onresume: function (tun) {
var cid = Packer.addrToId(tun);
console.log('[TunnelResume]', cid);
var browserConn = Server.getBrowserConn(state, srv, cid);
if (browserConn) {
browserConn.manualPause = false;
browserConn.resume();
} else {
Server.sendTunnelMsg(srv, tun, {message: 'no matching connection', code: 'E_NO_CONN'}, 'error');
}
}
, onend: function (tun) {
var cid = Packer.addrToId(tun);
console.log('[TunnelEnd]', cid);
Server.closeBrowserConn(state, srv, cid);
}
, onerror: function (tun) {
var cid = Packer.addrToId(tun);
console.warn('[TunnelError]', cid, tun.message);
Server.closeBrowserConn(state, srv, cid);
}
};
srv._packerHandlers = packerHandlers;
srv.unpacker = Packer.create(srv._packerHandlers);
}
, _initSocketHandlers: function (state, srv) {
function refreshTimeout() {
srv.lastActivity = Date.now();
Devices.touchDevice(state.deviceLists, srv);
}
function checkTimeout() {
// Determine how long the connection has been "silent", ie no activity.
var silent = Date.now() - srv.lastActivity;
// If we have had activity within the last activityTimeout then all we need to do is
// call this function again at the soonest time when the connection could be timed out.
if (silent < state.activityTimeout) {
srv.timeoutId = setTimeout(checkTimeout, state.activityTimeout - silent);
}
// Otherwise we check to see if the pong has also timed out, and if not we send a ping
// and call this function again when the pong will have timed out.
else if (silent < state.activityTimeout + state.pongTimeout) {
if (state.debug) { console.log('pinging', Server.logName(state, srv)); }
try {
srv.ws.ping();
} catch (err) {
console.warn('failed to ping home cloud', Server.logName(state, srv));
}
srv.timeoutId = setTimeout(checkTimeout, state.pongTimeout);
}
// Last case means the ping we sent before didn't get a response soon enough, so we
// need to close the websocket connection.
else {
console.warn('home cloud', Server.logName(state, srv), 'connection timed out');
srv.ws.close(1013, 'connection timeout');
}
}
function forwardMessage(chunk) {
refreshTimeout();
if (state.debug) { console.log('[ws] device => client : demultiplexing message ', chunk.byteLength, 'bytes'); }
//console.log(chunk.toString());
srv.unpacker.fns.addChunk(chunk);
}
function hangup() {
clearTimeout(srv.timeoutId);
console.log('[ws] device hangup', Server.logName(state, srv), 'connection closing');
// remove the allowed domains from the list (but leave the socket)
Object.keys(srv.grants).forEach(function (jwtoken) {
Server.removeToken(state, srv, jwtoken);
});
srv.ws.terminate();
// remove the socket from the list, period
Devices.close(state.deviceLists, srv);
}
srv.lastActivity = Date.now();
srv.timeoutId = null;
srv.timeoutId = setTimeout(checkTimeout, state.activityTimeout);
// Note that our websocket library automatically handles pong responses on ping requests
// before it even emits the event.
srv.ws.on('ping', refreshTimeout);
srv.ws.on('pong', refreshTimeout);
srv.ws.on('message', forwardMessage);
srv.ws.on('close', hangup);
srv.ws.on('error', hangup);
}
, init: function init(state, srv) {
Server._initCommandHandlers(state, srv);
Server._initPackerHandlers(state, srv);
Server._initSocketHandlers(state, srv);
// Status Code '1' for Status 'hello'
Server.sendTunnelMsg(srv, null, [1, 'hello', [srv.unpacker._version], Object.keys(srv._commandHandlers)], 'control');
}
, sendTunnelMsg: function sendTunnelMsg(srv, addr, data, service) {
if (data && !Buffer.isBuffer()) {
data = Buffer.from(JSON.stringify(data));
}
srv.ws.send(Packer.packHeader(addr, data, service), {binary: true});
srv.ws.send(data, {binary: true});
}
, logName: function logName(state, srv) {
var result = Object.keys(srv.grants).map(function (jwtoken) {
return srv.grants[jwtoken].currentDesc;
}).join(';');
return result || srv.socketId;
}
, onAuth: function onAuth(state, srv, rawAuth, grant) {
console.log('\n[relay.js] onAuth');
console.log(rawAuth);
//console.log(grant);
//var stringauth;
var err;
if (!grant || 'object' !== typeof grant) {
console.log('[relay.js] invalid token', grant);
err = new Error("invalid access token");
err.code = "E_INVALID_TOKEN";
return state.Promise.reject(err);
}
// deprecated (for json object on connect)
if ('string' !== typeof rawAuth) {
rawAuth = JSON.stringify(rawAuth);
}
// TODO don't fire the onAuth event on non-authz updates
if (!grant.jwt && !(grant.domains||[]).length && !(grant.ports||[]).length) {
console.log("[onAuth] nothing to offer at all");
return null;
}
console.log('[onAuth] check for upgrade token');
//console.log(grant);
if (grant.jwt) {
if (rawAuth !== grant.jwt) {
console.log('[onAuth] token is new');
}
// TODO only send token when new
if (true) {
// Access Token
console.log('[onAuth] sending back token');
Server.sendTunnelMsg(
srv
, null
, [ 3
, 'access_token'
, { jwt: grant.jwt }
]
, 'control'
);
// these aren't needed internally once they're sent
grant.jwt = null;
}
}
/*
if (!Array.isArray(grant.domains) || !grant.domains.length) {
err = new Error("invalid domains array");
err.code = "E_INVALID_NAME";
return state.Promise.reject(err);
}
*/
if (grant.domains.some(function (name) { return typeof name !== 'string'; })) {
console.log('bad domain names');
err = new Error("invalid domain name(s)");
err.code = "E_INVALID_NAME";
return state.Promise.reject(err);
}
console.log('[onAuth] strolling through pleasantries');
// Add the custom properties we need to manage this remote, then add it to all the relevant
// domains and the list of all this websocket's grants.
grant.domains.forEach(function (domainname) {
console.log('add', domainname, 'to device lists');
srv.domainsMap[domainname] = true;
Devices.add(state.deviceLists, domainname, srv);
// TODO allow subs to go to individual devices
Devices.alias(state.deviceLists, domainname, '*.' + domainname);
});
srv.domains = Object.keys(srv.domainsMap);
srv.currentDesc = (grant.device && (grant.device.id || grant.device.hostname)) || srv.domains.join(',');
grant.currentDesc = (grant.device && (grant.device.id || grant.device.hostname)) || grant.domains.join(',');
//grant.srv = srv;
//grant.ws = srv.ws;
//grant.upgradeReq = srv.upgradeReq;
grant.clients = {};
if (!grant.ports) { grant.ports = []; }
function openPort(serviceport) {
function tcpListener(conn) {
Server.onDynTcpConn(state, srv, srv.portsMap[serviceport], conn);
}
serviceport = parseInt(serviceport, 10) || 0;
if (!serviceport) {
// TODO error message about bad port
return;
}
if (PortServers[serviceport]) {
console.log('reuse', serviceport, 'for this connection');
//grant.ports = [];
srv.portsMap[serviceport] = PortServers[serviceport];
srv.portsMap[serviceport].on('connection', tcpListener);
srv.portsMap[serviceport].tcpListener = tcpListener;
Devices.addPort(state.deviceLists, serviceport, srv);
} else {
try {
console.log('use new', serviceport, 'for this connection');
srv.portsMap[serviceport] = PortServers[serviceport] = require('net').createServer(tcpListener);
srv.portsMap[serviceport].tcpListener = tcpListener;
srv.portsMap[serviceport].listen(serviceport, function () {
console.info('[DynTcpConn] Port', serviceport, 'now open for', grant.currentDesc);
Devices.addPort(state.deviceLists, serviceport, srv);
});
srv.portsMap[serviceport].on('error', function (e) {
// TODO try again with random port
console.error("Server Error assigning a dynamic port to a new connection:", e);
});
} catch(e) {
// what a wonderful problem it will be the day that this bug needs to be fixed
// (i.e. there are enough users to run out of ports)
console.error("Error assigning a dynamic port to a new connection:", e);
}
}
}
grant.ports.forEach(openPort);
console.info("[ws] authorized", srv.socketId, "for", grant.currentDesc);
console.log('notify of grants', grant.domains, grant.ports);
srv.grants[rawAuth] = grant;
Server.sendTunnelMsg(
srv
, null
, [ 2
, 'grant'
, [ ['ssh+https', grant.domains[0], 443 ]
// TODO the shared domain should be token specific
, ['ssh', 'ssh.' + state.config.sharedDomain, [grant.ports[0]] ]
, ['tcp', 'tcp.' + state.config.sharedDomain, [grant.ports[0]] ]
, ['https', grant.domains[0] ]
]
]
, 'control'
);
return null;
}
, onDynTcpConn: function onDynTcpConn(state, srv, server, conn) {
var serviceport = server.address().port;
console.log('[DynTcpConn] new connection on', serviceport);
var nextDevice = Devices.next(state.deviceLists, serviceport);
if (!nextDevice) {
conn.write("[Sanity Error] I've got a blank space baby, but nowhere to write your name.");
conn.end();
try {
server.close();
} catch(e) {
console.error("[DynTcpConn] failed to close server:", e);
}
return;
}
// When using raw TCP we're already paired to the client by port
// and we can begin connecting right away, but we'll wait just a sec
// to reject known bad connections
var sendConnection = setTimeout(function () {
conn.removeListener('data', peekFirstPacket);
console.log("[debug tcp conn] Connecting possible telnet client to device...");
pipeWs(null, 'tcp', nextDevice, conn, serviceport);
}, 350);
function peekFirstPacket(firstChunk) {
clearTimeout(sendConnection);
if (state.debug) { console.log("[DynTcp]", serviceport, "examining firstChunk from", Packer.socketToId(conn)); }
conn.pause();
//conn.unshift(firstChunk);
conn._handle.onread(firstChunk.length, firstChunk);
var servername;
var hostname;
var str;
var m;
if (22 === firstChunk[0]) {
servername = (sni(firstChunk)||'').toLowerCase();
} else if (firstChunk[0] > 32 && firstChunk[0] < 127) {
str = firstChunk.toString();
m = str.match(/(?:^|[\r\n])Host: ([^\r\n]+)[\r\n]*/im);
hostname = (m && m[1].toLowerCase() || '').split(':')[0];
}
if (servername || hostname) {
if (servername) {
conn.write("TLS with sni is allowed only on standard ports. If you've registered '" + servername + "' use port 443.");
} else {
conn.write("HTTP with Host headers is not allowed on dynamic ports. If you've registered '" + hostname + "' use port 80.");
}
conn.end();
return;
}
// pipeWs(servername, servicename, srv, client, serviceport)
// remote.clients is managed as part of the piping process
if (state.debug) { console.log("[DynTcp]", serviceport, "piping to srv (via loadbal)"); }
pipeWs(null, 'tcp', nextDevice, conn, serviceport);
process.nextTick(function () { conn.resume(); });
}
conn.once('data', peekFirstPacket);
}
, addToken: function addToken(state, srv, rawAuth) {
console.log("[addToken]", rawAuth);
if (srv.grants[rawAuth]) {
console.log("addToken - duplicate");
// return { message: "token sent multiple times", code: "E_TOKEN_REPEAT" };
return state.Promise.resolve(null);
}
// [Extension] [Auth] This is where authentication is either handed off to
// an extension or the default authencitation handler.
return state.authenticate({ auth: rawAuth }).then(function (validatedTokenData) {
console.log('\n[relay.js] rawAuth');
console.log(rawAuth);
console.log('\n[relay.js] authnToken');
console.log(validatedTokenData);
// For tracking state between token exchanges
// and tacking on extra attributes (i.e. for extensions)
// TODO close on delete
if (!state.srvs[validatedTokenData.id]) {
state.srvs[validatedTokenData.id] = {};
}
if (!state.srvs[validatedTokenData.id].updateAuth) {
// be sure to always pass latest srv since the connection may change
// and reuse the same token
state.srvs[validatedTokenData.id].updateAuth = function (srv, validatedTokenData) {
return Server.onAuth(state, srv, rawAuth, validatedTokenData);
};
}
state.srvs[validatedTokenData.id].updateAuth(srv, validatedTokenData);
});
}
, removeToken: function removeToken(state, srv, jwtoken) {
var grant = srv.grants[jwtoken];
if (!grant) {
return { message: 'specified token not present', code: 'E_INVALID_TOKEN'};
}
// Prevent any more browser connections for this grant being sent to this srv,
// and any existing connections from trying to send more data across the connection.
grant.domains.forEach(function (domainname) {
Devices.remove(state.deviceLists, domainname, srv);
});
grant.ports.forEach(function (portnumber) {
Devices.remove(state.deviceLists, portnumber, srv);
if (!srv.portsMap[portnumber]) { return; }
try {
srv.portsMap[portnumber].close(function () {
console.log("[DynTcpConn] closing server for ", portnumber);
delete srv.portsMap[portnumber];
delete PortServers[portnumber];
});
} catch(e) { /*ignore*/ }
});
// Close all of the existing browser connections associated with this websocket connection.
Object.keys(grant.clients).forEach(function (cid) {
Server.closeBrowserConn(state, srv, cid);
});
delete srv.grants[jwtoken];
console.log("[ws] removed token '" + grant.currentDesc + "' from", srv.socketId);
return null;
}
, getBrowserConn: function getBrowserConn(state, srv, cid) {
return srv.clients[cid];
}
, closeBrowserConn: function closeBrowserConn(state, srv, cid) {
if (!srv.clients[cid]) {
return;
}
PromiseA.resolve().then(function () {
var conn = srv.clients[cid];
conn.tunnelClosing = true;
conn.end();
// If no data is buffered for writing then we don't need to wait for it to drain.
if (!conn.bufferSize) {
return timeoutPromise(500);
}
// Otherwise we want the connection to be able to finish, but we also want to impose
// a time limit for it to drain, since it shouldn't have more than 1MB buffered.
return new PromiseA(function (resolve) {
var timeoutId = setTimeout(resolve, 60*1000);
conn.once('drain', function () {
clearTimeout(timeoutId);
setTimeout(resolve, 500);
});
});
}).then(function () {
if (srv.clients[cid]) {
console.warn(cid, 'browser connection still present after calling `end`');
srv.clients[cid].destroy();
return timeoutPromise(500);
}
}).then(function () {
if (srv.clients[cid]) {
console.error(cid, 'browser connection still present after calling `destroy`');
delete srv.clients[cid];
}
}).catch(function (err) {
console.warn('failed to close browser connection', cid, err);
});
}
, parseAuth: function parseAuth(state, srv) {
var authn = (srv.upgradeReq.headers.authorization||'').split(/\s+/);
if (authn[0] && 'basic' === authn[0].toLowerCase()) {
try {
authn = new Buffer(authn[1], 'base64').toString('ascii').split(':');
return authn[1];
} catch (err) { }
}
return url.parse(srv.upgradeReq.url, true).query.access_token;
}
};
module.exports = Server;

View File

@ -2,6 +2,16 @@
var sni = require('sni');
var pipeWs = require('./pipe-ws.js');
var ago = require('./ago.js').AGO;
var up = Date.now();
function fromUptime(ms) {
if (ms) {
return ago(Date.now() - ms);
} else {
return "Not seen since relay restarted, " + ago(Date.now() - up);
}
}
module.exports.createTcpConnectionHandler = function (state) {
var Devices = state.Devices;
@ -18,13 +28,26 @@ module.exports.createTcpConnectionHandler = function (state) {
//});
//return;
conn.once('data', function (firstChunk) {
//conn.once('data', function (firstChunk) {
//});
conn.once('readable', function () {
var firstChunk = conn.read();
var service = 'tcp';
var servername;
var str;
var m;
conn.pause();
if (!firstChunk) {
try {
conn.end();
} catch(e) {
console.error("[lib/unwrap-tls.js] Error:", e);
conn.destroy();
}
return;
}
//conn.pause();
conn.unshift(firstChunk);
// BUG XXX: this assumes that the packet won't be chunked smaller
@ -35,41 +58,98 @@ module.exports.createTcpConnectionHandler = function (state) {
// defer after return (instead of being in many places)
function deferData(fn) {
if (fn) {
if ('httpsInvalid' === fn) {
state[fn]({
servername: servername
, ago: fromUptime(Devices.lastSeen(state.deviceLists, servername))
}, conn);
} else if (fn) {
state[fn](servername, conn);
} else {
console.error("[SANITY ERROR] '" + fn + "' doesn't have a state handler");
}
/*
process.nextTick(function () {
conn.resume();
});
*/
}
function tryTls() {
var vhost;
if (!state.servernames.length) {
console.info("[Setup] https => admin => setup => (needs bogus tls certs to start?)");
deferData('httpsSetupServer');
return;
var httpOutcomes = {
missingServername: function () {
console.log("[debug] [http] missing servername");
// TODO use a more specific error page
deferData('handleInsecureHttp');
}
if (-1 !== state.servernames.indexOf(servername)) {
if (state.debug) { console.log("[Admin]", servername); }
deferData('httpsTunnel');
return;
, requiresSetup: function () {
console.log("[debug] [http] requires setup");
// TODO Insecure connections for setup will not work on secure domains (i.e. .app)
state.httpSetupServer.emit('connection', conn);
}
if (state.config.nowww && /^www\./i.test(servername)) {
console.log("TODO: use www bare redirect");
, isInternal: function () {
console.log("[debug] [http] is known internally (admin)");
if (/well-known/.test(str)) {
deferData('handleHttp');
} else {
deferData('handleInsecureHttp');
}
}
, isVhost: function () {
console.log("[debug] [http] is vhost (normal server)");
if (/well-known/.test(str)) {
deferData('handleHttp');
} else {
deferData('handleInsecureHttp');
}
}
, assumeExternal: function () {
console.log("[debug] [http] assume external");
var service = 'http';
if (!servername) {
if (!Devices.exist(state.deviceLists, servername)) {
// It would be better to just re-read the host header rather
// than creating a whole server object, but this is a "rare"
// case and I'm feeling lazy right now.
console.log("[debug] [http] no device connected");
state.createHttpInvalid({
servername: servername
, ago: fromUptime(Devices.lastSeen(state.deviceLists, servername))
}).emit('connection', conn);
return;
}
// TODO make https redirect configurable on a per-domain basis
// /^\/\.well-known\/acme-challenge\//.test(str)
if (/well-known/.test(str)) {
// HTTP
console.log("[debug] [http] passthru");
pipeWs(servername, service, Devices.next(state.deviceLists, servername), conn, serviceport);
return;
} else {
console.log("[debug] [http] redirect to https");
deferData('handleInsecureHttp');
}
}
};
var tlsOutcomes = {
missingServername: function () {
if (state.debug) { console.log("No SNI was given, so there's nothing we can do here"); }
deferData('httpsInvalid');
return;
}
function run() {
var nextDevice = Devices.next(state.deviceLists, servername);
, requiresSetup: function () {
console.info("[Setup] https => admin => setup => (needs bogus tls certs to start?)");
deferData('httpsSetupServer');
}
, isInternal: function () {
if (state.debug) { console.log("[Admin]", servername); }
deferData('httpsTunnel');
}
, isVhost: function (vhost) {
if (state.debug) { console.log("[tcp] [vhost]", state.config.vhost, "=>", vhost); }
deferData('httpsVhost');
}
, assumeExternal: function () {
var nextDevice = Devices.next(state.deviceLists, servername);
if (!nextDevice) {
if (state.debug) { console.log("No devices match the given servername"); }
deferData('httpsInvalid');
@ -77,27 +157,33 @@ module.exports.createTcpConnectionHandler = function (state) {
}
if (state.debug) { console.log("pipeWs(servername, service, deviceLists['" + servername + "'], socket)"); }
deferData();
pipeWs(servername, service, nextDevice, conn, serviceport);
}
};
function handleConnection(outcomes) {
var vhost;
// No routing information available
if (!servername) { outcomes.missingServername(); return; }
// Server needs to be set up
if (!state.servernames.length) { outcomes.requiresSetup(); return; }
// This is one of the admin domains
if (-1 !== state.servernames.indexOf(servername)) { outcomes.isInternal(); return; }
// TODO don't run an fs check if we already know this is working elsewhere
//if (!state.validHosts) { state.validHosts = {}; }
if (state.config.vhost) {
vhost = state.config.vhost.replace(/:hostname/, (servername||'reallydoesntexist'));
if (state.debug) { console.log("[tcp] [vhost]", state.config.vhost, "=>", vhost); }
//state.httpsVhost(servername, conn);
//return;
vhost = state.config.vhost.replace(/:hostname/, servername);
require('fs').readdir(vhost, function (err, nodes) {
if (state.debug && err) { console.log("VHOST error", err); }
if (err || !nodes) { run(); return; }
//if (nodes) { deferData('httpsVhost'); return; }
deferData('httpsVhost');
if (err || !nodes) { outcomes.assumeExternal(); return; }
outcomes.isVhost(vhost);
});
return;
}
run();
outcomes.assumeExternal();
}
// https://github.com/mscdex/httpolyglot/issues/3#issuecomment-173680155
@ -106,40 +192,19 @@ module.exports.createTcpConnectionHandler = function (state) {
service = 'https';
servername = (sni(firstChunk)||'').toLowerCase().trim();
if (state.debug) { console.log("[tcp] tls hello from '" + servername + "'"); }
tryTls();
handleConnection(tlsOutcomes);
return;
}
if (firstChunk[0] > 32 && firstChunk[0] < 127) {
// (probably) HTTP
str = firstChunk.toString();
m = str.match(/(?:^|[\r\n])Host: ([^\r\n]+)[\r\n]*/im);
servername = (m && m[1].toLowerCase() || '').split(':')[0];
if (state.debug) { console.log("[tcp] http hostname '" + servername + "'"); }
if (/HTTP\//i.test(str)) {
if (!state.servernames.length) {
console.info("[tcp] No admin servername. Entering setup mode.");
deferData();
state.httpSetupServer.emit('connection', conn);
return;
}
service = 'http';
// TODO make https redirect configurable
// /^\/\.well-known\/acme-challenge\//.test(str)
if (/well-known/.test(str)) {
// HTTP
if (Devices.exist(state.deviceLists, servername)) {
deferData();
pipeWs(servername, service, Devices.next(state.deviceLists, servername), conn, serviceport);
return;
}
deferData('handleHttp');
return;
}
// redirect to https
deferData('handleInsecureHttp');
handleConnection(httpOutcomes);
return;
}
}

View File

@ -1,6 +1,6 @@
{
"name": "telebit-relay",
"version": "0.13.1",
"version": "0.20.0",
"description": "Friends don't let friends localhost. Expose your bits with a secure connection even from behind NAT, Firewalls, in a box, with a fox, on a train or in a plane... or a Raspberry Pi in your closet. An attempt to create a better localtunnel.me server, a more open ngrok. Uses Automated HTTPS (Free SSL) via ServerName Indication (SNI). Can also tunnel tls and plain tcp.",
"main": "lib/relay.js",
"bin": {
@ -43,8 +43,8 @@
"greenlock": "^2.2.4",
"human-readable-ids": "^1.0.4",
"js-yaml": "^3.11.0",
"jsonwebtoken": "^8.2.1",
"proxy-packer": "^1.4.3",
"jsonwebtoken": "^8.3.0",
"proxy-packer": "^2.0.0",
"recase": "^1.0.4",
"redirect-https": "^1.1.5",
"serve-static": "^1.13.2",

24
snap/snapcraft.yaml Normal file
View File

@ -0,0 +1,24 @@
name: telebit-relay
version: '0.20.0'
summary: Because friends don't let friends localhost
description: |
A server that works in combination with Telebit Remote
to allow you to serve http and https from any computer,
anywhere through a secure tunnel.
grade: stable
confinement: strict
apps:
telebit-relay:
command: telebit-relay --config $SNAP_COMMON/config.yml
plugs: [network, network-bind]
daemon: simple
parts:
telebit-relay:
plugin: nodejs
node-engine: 10.13.0
source: .
override-build: |
snapcraftctl build