Compare commits

...

8 Commits

9 changed files with 293 additions and 81 deletions

9
.gitignore vendored
View File

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

View File

@ -47,7 +47,13 @@ function applyConfig(config) {
} else { } else {
state.Promise = require('bluebird'); 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.config = config;
state.servernames = config.servernames || []; state.servernames = config.servernames || [];
state.secret = state.config.secret; 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,6 +1,7 @@
'use strict'; 'use strict';
var Devices = module.exports; var Devices = module.exports;
// TODO enumerate store's keys and device's keys for documentation
Devices.addPort = function (store, serverport, newDevice) { Devices.addPort = function (store, serverport, newDevice) {
// TODO make special // TODO make special
return Devices.add(store, serverport, newDevice, true); return Devices.add(store, serverport, newDevice, true);
@ -14,6 +15,7 @@ Devices.add = function (store, servername, newDevice, isPort) {
if (!store._domains) { store._domains = {}; } if (!store._domains) { store._domains = {}; }
if (!store._domains[servername]) { store._domains[servername] = []; } if (!store._domains[servername]) { store._domains[servername] = []; }
store._domains[servername].push(newDevice); store._domains[servername].push(newDevice);
Devices.touch(store, servername);
// add device // add device
// TODO only use a device id // TODO only use a device id
@ -126,7 +128,11 @@ Devices.active = function (store, id) {
}; };
*/ */
Devices.exist = function (store, servername) { 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) { Devices.next = function (store, servername) {
var devices = Devices.list(store, servername); var devices = Devices.list(store, servername);
@ -138,5 +144,20 @@ Devices.next = function (store, servername) {
device = devices[devices._index || 0]; device = devices[devices._index || 0];
devices._index = (devices._index || 0) + 1; devices._index = (devices._index || 0) + 1;
if (device) { Devices.touch(store, servername); }
return device; 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 + "'"); var err = new Error("[noSniCallback] no handler set for '" + tag + "':'" + servername + "'");
console.error(err.message); console.error(err.message);
cb(new Error(err)); cb(new Error(err));
} };
} }
module.exports.create = function (state) { module.exports.create = function (state) {
@ -72,38 +72,44 @@ module.exports.create = function (state) {
state.tlsInvalidSniServer.on('tlsClientError', function () { state.tlsInvalidSniServer.on('tlsClientError', function () {
console.error('tlsClientError InvalidSniServer'); 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: // none of these methods work:
// httpsServer.emit('connection', socket); // this didn't work // httpsServer.emit('connection', socket); // this didn't work
// tlsServer.emit('connection', socket); // this didn't work either // tlsServer.emit('connection', socket); // this didn't work either
//console.log('chunkLen', firstChunk.byteLength); //console.log('chunkLen', firstChunk.byteLength);
console.log('[httpsInvalid] servername', servername); console.log('[httpsInvalid] servername', opts.servername);
//state.tlsInvalidSniServer.emit('connection', wrapSocket(socket)); //state.tlsInvalidSniServer.emit('connection', wrapSocket(socket));
var tlsInvalidSniServer = tls.createServer(state.tlsOptions, function (tlsSocket) { var tlsInvalidSniServer = tls.createServer(state.tlsOptions, function (tlsSocket) {
console.log('[tlsInvalid] tls connection'); console.log('[tlsInvalid] tls connection');
// things get a little messed up here // We create an entire http server object because it's difficult to figure out
var httpInvalidSniServer = http.createServer(function (req, res) { // how to access the original tlsSocket to get the servername
if (!servername) { state.createHttpInvalid(opts).emit('connection', tlsSocket);
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);
}); });
tlsInvalidSniServer.on('tlsClientError', function () { tlsInvalidSniServer.on('tlsClientError', function () {
console.error('tlsClientError InvalidSniServer httpsInvalid'); console.error('tlsClientError InvalidSniServer httpsInvalid');

View File

@ -172,6 +172,7 @@ var Server = {
, _initSocketHandlers: function (state, srv) { , _initSocketHandlers: function (state, srv) {
function refreshTimeout() { function refreshTimeout() {
srv.lastActivity = Date.now(); srv.lastActivity = Date.now();
Devices.touchDevice(state.deviceLists, srv);
} }
function checkTimeout() { function checkTimeout() {

View File

@ -2,6 +2,16 @@
var sni = require('sni'); var sni = require('sni');
var pipeWs = require('./pipe-ws.js'); 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) { module.exports.createTcpConnectionHandler = function (state) {
var Devices = state.Devices; var Devices = state.Devices;
@ -27,6 +37,16 @@ module.exports.createTcpConnectionHandler = function (state) {
var str; var str;
var m; var m;
if (!firstChunk) {
try {
conn.end();
} catch(e) {
console.error("[lib/unwrap-tls.js] Error:", e);
conn.destroy();
}
return;
}
//conn.pause(); //conn.pause();
conn.unshift(firstChunk); conn.unshift(firstChunk);
@ -38,8 +58,15 @@ module.exports.createTcpConnectionHandler = function (state) {
// defer after return (instead of being in many places) // defer after return (instead of being in many places)
function deferData(fn) { 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); state[fn](servername, conn);
} else {
console.error("[SANITY ERROR] '" + fn + "' doesn't have a state handler");
} }
/* /*
process.nextTick(function () { process.nextTick(function () {
@ -48,33 +75,81 @@ module.exports.createTcpConnectionHandler = function (state) {
*/ */
} }
function tryTls() { var httpOutcomes = {
var vhost; missingServername: function () {
console.log("[debug] [http] missing servername");
if (!state.servernames.length) { // TODO use a more specific error page
console.info("[Setup] https => admin => setup => (needs bogus tls certs to start?)"); deferData('handleInsecureHttp');
deferData('httpsSetupServer');
return;
} }
, requiresSetup: function () {
if (-1 !== state.servernames.indexOf(servername)) { console.log("[debug] [http] requires setup");
if (state.debug) { console.log("[Admin]", servername); } // TODO Insecure connections for setup will not work on secure domains (i.e. .app)
deferData('httpsTunnel'); state.httpSetupServer.emit('connection', conn);
return;
} }
, isInternal: function () {
if (state.config.nowww && /^www\./i.test(servername)) { console.log("[debug] [http] is known internally (admin)");
console.log("TODO: use www bare redirect"); 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"); } if (state.debug) { console.log("No SNI was given, so there's nothing we can do here"); }
deferData('httpsInvalid'); deferData('httpsInvalid');
return;
} }
, requiresSetup: function () {
function run() { console.info("[Setup] https => admin => setup => (needs bogus tls certs to start?)");
var nextDevice = Devices.next(state.deviceLists, servername); 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 (!nextDevice) {
if (state.debug) { console.log("No devices match the given servername"); } if (state.debug) { console.log("No devices match the given servername"); }
deferData('httpsInvalid'); deferData('httpsInvalid');
@ -82,27 +157,33 @@ module.exports.createTcpConnectionHandler = function (state) {
} }
if (state.debug) { console.log("pipeWs(servername, service, deviceLists['" + servername + "'], socket)"); } if (state.debug) { console.log("pipeWs(servername, service, deviceLists['" + servername + "'], socket)"); }
deferData();
pipeWs(servername, service, nextDevice, conn, serviceport); 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 // TODO don't run an fs check if we already know this is working elsewhere
//if (!state.validHosts) { state.validHosts = {}; } //if (!state.validHosts) { state.validHosts = {}; }
if (state.config.vhost) { if (state.config.vhost) {
vhost = state.config.vhost.replace(/:hostname/, (servername||'reallydoesntexist')); vhost = state.config.vhost.replace(/:hostname/, servername);
if (state.debug) { console.log("[tcp] [vhost]", state.config.vhost, "=>", vhost); }
//state.httpsVhost(servername, conn);
//return;
require('fs').readdir(vhost, function (err, nodes) { require('fs').readdir(vhost, function (err, nodes) {
if (state.debug && err) { console.log("VHOST error", err); } if (state.debug && err) { console.log("VHOST error", err); }
if (err || !nodes) { run(); return; } if (err || !nodes) { outcomes.assumeExternal(); return; }
//if (nodes) { deferData('httpsVhost'); return; } outcomes.isVhost(vhost);
deferData('httpsVhost');
}); });
return; return;
} }
run(); outcomes.assumeExternal();
} }
// https://github.com/mscdex/httpolyglot/issues/3#issuecomment-173680155 // https://github.com/mscdex/httpolyglot/issues/3#issuecomment-173680155
@ -111,40 +192,19 @@ module.exports.createTcpConnectionHandler = function (state) {
service = 'https'; service = 'https';
servername = (sni(firstChunk)||'').toLowerCase().trim(); servername = (sni(firstChunk)||'').toLowerCase().trim();
if (state.debug) { console.log("[tcp] tls hello from '" + servername + "'"); } if (state.debug) { console.log("[tcp] tls hello from '" + servername + "'"); }
tryTls(); handleConnection(tlsOutcomes);
return; return;
} }
if (firstChunk[0] > 32 && firstChunk[0] < 127) { if (firstChunk[0] > 32 && firstChunk[0] < 127) {
// (probably) HTTP
str = firstChunk.toString(); str = firstChunk.toString();
m = str.match(/(?:^|[\r\n])Host: ([^\r\n]+)[\r\n]*/im); m = str.match(/(?:^|[\r\n])Host: ([^\r\n]+)[\r\n]*/im);
servername = (m && m[1].toLowerCase() || '').split(':')[0]; servername = (m && m[1].toLowerCase() || '').split(':')[0];
if (state.debug) { console.log("[tcp] http hostname '" + servername + "'"); } if (state.debug) { console.log("[tcp] http hostname '" + servername + "'"); }
if (/HTTP\//i.test(str)) { if (/HTTP\//i.test(str)) {
if (!state.servernames.length) { handleConnection(httpOutcomes);
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');
return; return;
} }
} }

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