diff --git a/bin/telebit.js b/bin/telebit.js index d282782..2846dfe 100755 --- a/bin/telebit.js +++ b/bin/telebit.js @@ -3,12 +3,12 @@ 'use strict'; var pkg = require('../package.json'); -console.log(pkg.name, pkg.version); +console.info(pkg.name, pkg.version); var url = require('url'); var path = require('path'); -var remote = require('../'); -var state = {}; +var http = require('http'); +var state = { servernames: {}, ports: {} }; var argv = process.argv.slice(2); @@ -29,11 +29,26 @@ function help() { console.info(''); console.info('Usage:'); console.info(''); - console.info('\ttelebit --config '); + console.info('\ttelebit [--config ] '); console.info(''); - console.info('Example:'); + console.info('Examples:'); console.info(''); - console.info('\ttelebit --config /etc/telebit/telebit.yml'); + console.info('\ttelebit --config ~/.config/telebit/telebit.yml status'); + console.info(''); + console.info('\ttelebit status'); + console.info('\ttelebit enable'); + console.info('\ttelebit disable'); + console.info(''); + console.info('\ttelebit list'); + console.info(''); + console.info('\ttelebit http 3000'); + console.info('\ttelebit tcp 5050'); + console.info(''); + console.info('\ttelebit http default'); + console.info('\ttelebit tcp default'); + console.info(''); + console.info('\ttelebit http /path/to/module'); + console.info('\ttelebit tcp /path/to/module'); console.info(''); console.info('Config:'); console.info(''); @@ -63,11 +78,13 @@ try { } catch(e) { // ignore } +var controlServer; require('fs').readFile(confpath, 'utf8', function (err, text) { var config; var recase = require('recase').create({}); var camelCopy = recase.camelCopy.bind(recase); + var snakeCopy = recase.snakeCopy.bind(recase); if (err) { console.error("\nCouldn't load config:\n\n\t" + err.message + "\n"); @@ -100,21 +117,49 @@ require('fs').readFile(confpath, 'utf8', function (err, text) { console.warn("Choosing the first."); console.warn(); } - state.config.token = token; + state.token = token; - function restartCmd() { + if (!state.config.servernames) { + state.config.servernames = {}; + } + if (!state.config.ports) { + state.config.ports = {}; + } + state.servernames = JSON.parse(JSON.stringify(state.config.servernames)); + state.ports = JSON.parse(JSON.stringify(state.config.ports)); + + function putConfig(service, args) { var http = require('http'); var req = http.get({ socketPath: state.config.sock || defaultSockname , method: 'POST' - , path: '/rpc/restart' + , path: '/rpc/' + service + '?_body=' + JSON.stringify(args) }, function (resp) { - console.log('statusCode', resp.statusCode); - if (200 !== resp.statusCode) { - console.warn("May not have restarted." - + " Consider peaking at the logs either with 'journalctl -xeu telebit' or /opt/telebit/var/log/error.log"); + + function finish() { + if (200 !== resp.statusCode) { + console.warn("'" + service + "' may have failed." + + " Consider peaking at the logs either with 'journalctl -xeu telebit' or /opt/telebit/var/log/error.log"); + } else { + if (body) { + console.info('Response'); + console.info(body); + } else { + console.info("👌"); + } + } + } + + var body = ''; + if (resp.headers['content-length']) { + resp.on('data', function (chunk) { + body += chunk.toString(); + }); + resp.on('end', function () { + finish(); + }); } else { - console.log("restarted"); + finish(); } }); req.on('error', function (err) { @@ -124,30 +169,136 @@ require('fs').readFile(confpath, 'utf8', function (err, text) { }); } - function controlServer() { - var http = require('http'); - var server = http.createServer(function (req, res) { + var tun; + function serveControls() { + if (!state.config.disable) { + tun = rawTunnel(); + } + controlServer = http.createServer(function (req, res) { + var opts = url.parse(req.url, true); + if (opts.query._body) { + try { + opts.body = JSON.parse(opts.query._body, true); + } catch(e) { + res.statusCode = 500; + res.end('{"error":{"message":"?_body={{bad_format}}"}}'); + return; + } + } - if (/restart/.test(req.url)) { - res.end('{"success":true}'); + if (/enable/.test(opts.path)) { + state.config.disable = undefined; + if (!tun) { tun = rawTunnel(); } + fs.writeFile(confpath, require('js-yaml').safeDump(snakeCopy(state.config)), function () { + if (err) { + res.statusCode = 500; + res.end('{"error":{"message":"Could not save config file. Perhaps you\'re not running as root?"}}'); + return; + } + res.end('{"success":true}'); + }); + return; + } + + if (/disable/.test(opts.path)) { + state.config.disable = true; + if (tun) { tun.end(); tun = null; } + fs.writeFile(confpath, require('js-yaml').safeDump(snakeCopy(state.config)), function () { + if (err) { + res.statusCode = 500; + res.end('{"error":{"message":"Could not save config file. Perhaps you\'re not running as root?"}}'); + return; + } + res.end('{"success":true}'); + }); + return; + } + + if (/status/.test(opts.path)) { + res.end('{"status":' + (state.config.disable ? 'disabled' : 'enabled') + '}'); + return; + } + + if (/restart/.test(opts.path)) { tun.end(); - process.nextTick(function () { - server.close(function () { - // TODO closeAll other things + res.end('{"success":true}'); + controlServer.close(function () { + // TODO closeAll other things + process.nextTick(function () { + // system daemon will restart the process process.exit(); }); }); return; } + if (/list/.test(opts.path)) { + res.end(JSON.stringify({ + servernames: state.servernames + , ports: state.ports + })); + return; + } + + if (/http/.test(opts.path)) { + if (!opts.body) { + res.statusCode = 422; + res.end('{"error":{"message":"needs more arguments"}}'); + return; + } + if (opts.body[1]) { + if (!state.servernames[opts.body[1]]) { + res.statusCode = 400; + res.end('{"error":{"message":"bad servername \'' + opts.body[1] + '\'"'); + return; + } + state.servernames[opts.body[1]].handler = opts.body[0]; + } else { + Object.keys(state.servernames).forEach(function (key) { + state.servernames[key].handler = opts.body[0]; + }); + } + res.end('{"success":true}'); + return; + } + + if (/tcp/.test(opts.path)) { + if (!opts.body) { + res.statusCode = 422; + res.end('{"error":{"message":"needs more arguments"}}'); + return; + } + + if (opts.body[1]) { + if (!state.servernames[opts.body[1]]) { + res.statusCode = 400; + res.end('{"error":{"message":"bad servername \'' + opts.body[1] + '\'"'); + return; + } + state.servernames[opts.body[1]].handler = opts.body[0]; + } else { + Object.keys(state.servernames).forEach(function (key) { + state.servernames[key].handler = opts.body[0]; + }); + } + res.end('{"success":true}'); + return; + } + res.end('{"error":{"message":"unrecognized rpc"}}'); }); var pipename = (state.config.sock || defaultSockname); + var fs = require('fs'); + if (fs.existsSync(pipename)) { + fs.unlinkSync(pipename); + } if (/^win/i.test(require('os').platform())) { pipename = '\\\\?\\pipe' + pipename.replace(/\//, '\\'); } + // mask is so that processes owned by other users + // can speak to this process, which is probably root-owned var oldUmask = process.umask(0x0000); - server.listen({ + controlServer.listen({ path: pipename , writableAll: true , readableAll: true @@ -157,18 +308,46 @@ require('fs').readFile(confpath, 'utf8', function (err, text) { }); } - console.log('argv', argv); - if (-1 !== argv.indexOf('restart')) { - restartCmd(); - return; + // Two styles: + // http 3000 + // http modulename + function makeRpc(key) { + var cmdIndex = argv.indexOf(key); + if (-1 !== cmdIndex) { + putConfig(argv[cmdIndex], argv.slice(1)); + return true; + } } - controlServer(); - var tun = rawTunnel(); + if ([ 'status', 'enable', 'disable', 'restart', 'list' ].some(makeRpc)) { + return; + } + if ([ 'http', 'tcp' ].some(function (key) { + var cmdIndex = argv.indexOf(key); + if (-1 !== cmdIndex && argv[cmdIndex + 1]) { + putConfig(argv[cmdIndex], argv.slice(1)); + return true; + } + })) { + return true; + } + + serveControls(); }); function connectTunnel() { - state.net = { + function sigHandler() { + console.info('Received kill signal. Attempting to exit cleanly...'); + + // We want to handle cleanup properly unless something is broken in our cleanup process + // that prevents us from exitting, in which case we want the user to be able to send + // the signal again and exit the way it normally would. + process.removeListener('SIGINT', sigHandler); + tun.end(); + controlServer.close(); + } + process.on('SIGINT', sigHandler); + state.net = state.net || { createConnection: function (info, cb) { // data is the hello packet / first chunk // info = { data, servername, port, host, remoteFamily, remoteAddress, remotePort } @@ -180,9 +359,8 @@ function connectTunnel() { }; state.greenlock = state.config.greenlock || {}; - if (!state.config.sortingHat) { - state.config.sortingHat = path.resolve(__dirname, '..', 'lib/sorting-hat.js'); - } + state.sortingHat = state.config.sortingHat || path.resolve(__dirname, '..', 'lib/sorting-hat.js'); + // TODO sortingHat.print(); ? if (state.config.email && !state.token) { @@ -199,95 +377,103 @@ function connectTunnel() { console.info(); } // TODO Check undefined vs false for greenlock config + var remote = require('../'); + state.handlers = { + grant: function (grants) { + console.info(""); + console.info("Connect to your device by any of the following means:"); + console.info(""); + grants.forEach(function (arr) { + if ('https' === arr[0]) { + if (!state.servernames[arr[1]]) { + state.servernames[arr[1]] = {}; + } + } else if ('tcp' === arr[0]) { + if (!state.ports[arr[2]]) { + state.ports[arr[2]] = {}; + } + } + + if ('ssh+https' === arr[0]) { + console.info("SSH+HTTPS"); + } else if ('ssh' === arr[0]) { + console.info("SSH"); + } else if ('tcp' === arr[0]) { + console.info("TCP"); + } else if ('https' === arr[0]) { + console.info("HTTPS"); + } + console.info('\t' + arr[0] + '://' + arr[1] + (arr[2] ? (':' + arr[2]) : '')); + if ('ssh+https' === arr[0]) { + console.info("\tex: ssh -o ProxyCommand='openssl s_client -connect %h:%p -quiet' " + arr[1] + " -p 443\n"); + } else if ('ssh' === arr[0]) { + console.info("\tex: ssh " + arr[1] + " -p " + arr[2] + "\n"); + } else if ('tcp' === arr[0]) { + console.info("\tex: netcat " + arr[1] + " " + arr[2] + "\n"); + } else if ('https' === arr[0]) { + console.info("\tex: curl https://" + arr[1] + "\n"); + } + }); + } + , access_token: function (opts) { + console.info("Updating '" + tokenpath + "' with new token:"); + try { + require('fs').writeFileSync(tokenpath, opts.jwt); + } catch (e) { + console.error("Token not saved:"); + console.error(e); + } + } + }; + state.greenlockConfig = { + version: state.greenlock.version || 'draft-11' + , server: state.greenlock.server || 'https://acme-v02.api.letsencrypt.org/directory' + , communityMember: state.greenlock.communityMember || state.config.communityMember + , telemetry: state.greenlock.telemetry || state.config.telemetry + , configDir: state.greenlock.configDir || path.resolve(__dirname, '..', '/etc/acme/') + // TODO, store: require(state.greenlock.store.name || 'le-store-certbot').create(state.greenlock.store.options || {}) + , approveDomains: function (opts, certs, cb) { + // Certs being renewed are listed in certs.altnames + if (certs) { + opts.domains = certs.altnames; + cb(null, { options: opts, certs: certs }); + return; + } + + // by virtue of the fact that it's being tunneled through a + // trusted source that is already checking, we're good + //if (-1 !== state.config.servernames.indexOf(opts.domains[0])) { + opts.email = state.greenlock.email || state.config.email; + opts.agreeTos = state.greenlock.agree || state.config.agreeTos; + cb(null, { options: opts, certs: certs }); + return; + //} + + //cb(new Error("servername not found in allowed list")); + } + }; + state.insecure = state.config.relay_ignore_invalid_certificates; + // { relay, config, servernames, ports, sortingHat, net, insecure, token, handlers, greenlockConfig } + var tun = remote.connect({ - relay: state.config.relay + relay: state.relay , config: state.config - , _confpath: confpath - , sortingHat: state.config.sortingHat + , sortingHat: state.sortingHat , net: state.net - , insecure: state.config.relay_ignore_invalid_certificates + , insecure: state.insecure , token: state.token - , handlers: { - grant: function (grants) { - console.info(""); - console.info("Connect to your device by any of the following means:"); - console.info(""); - grants.forEach(function (arr) { - if ('ssh+https' === arr[0]) { - console.info("SSH+HTTPS"); - } else if ('ssh' === arr[0]) { - console.info("SSH"); - } else if ('tcp' === arr[0]) { - console.info("TCP"); - } else if ('https' === arr[0]) { - console.info("HTTPS"); - } - console.log('\t' + arr[0] + '://' + arr[1] + (arr[2] ? (':' + arr[2]) : '')); - if ('ssh+https' === arr[0]) { - console.info("\tex: ssh -o ProxyCommand='openssl s_client -connect %h:%p -quiet' " + arr[1] + " -p 443\n"); - } else if ('ssh' === arr[0]) { - console.info("\tex: ssh " + arr[1] + " -p " + arr[2] + "\n"); - } else if ('tcp' === arr[0]) { - console.info("\tex: netcat " + arr[1] + " " + arr[2] + "\n"); - } else if ('https' === arr[0]) { - console.info("\tex: curl https://" + arr[1] + "\n"); - } - }); - } - , access_token: function (opts) { - console.info("Updating '" + tokenpath + "' with new token:"); - try { - require('fs').writeFileSync(tokenpath, opts.jwt); - } catch (e) { - console.error("Token not saved:"); - console.error(e); - } - } - } - , greenlockConfig: { - version: state.greenlock.version || 'draft-11' - , server: state.greenlock.server || 'https://acme-v02.api.letsencrypt.org/directory' - , communityMember: state.greenlock.communityMember || state.config.communityMember - , telemetry: state.greenlock.telemetry || state.config.telemetry - , configDir: state.greenlock.configDir || path.resolve(__dirname, '..', '/etc/acme/') - // TODO, store: require(state.greenlock.store.name || 'le-store-certbot').create(state.greenlock.store.options || {}) - , approveDomains: function (opts, certs, cb) { - // Certs being renewed are listed in certs.altnames - if (certs) { - opts.domains = certs.altnames; - cb(null, { options: opts, certs: certs }); - return; - } - - // by virtue of the fact that it's being tunneled through a - // trusted source that is already checking, we're good - //if (-1 !== state.config.servernames.indexOf(opts.domains[0])) { - opts.email = state.greenlock.email || state.config.email; - opts.agreeTos = state.greenlock.agree || state.config.agreeTos; - cb(null, { options: opts, certs: certs }); - return; - //} - - //cb(new Error("servername not found in allowed list")); - } - } + , servernames: state.servernames + , ports: state.ports + , handlers: state.handlers + , greenlockConfig: state.greenlockConfig }); - function sigHandler() { - console.info('Received kill signal. Attempting to exit cleanly...'); - - // We want to handle cleanup properly unless something is broken in our cleanup process - // that prevents us from exitting, in which case we want the user to be able to send - // the signal again and exit the way it normally would. - process.removeListener('SIGINT', sigHandler); - tun.end(); - } - process.on('SIGINT', sigHandler); return tun; } function rawTunnel() { - if (!state.config.relay) { + state.relay = state.config.relay; + if (!state.relay) { throw new Error("'" + state._confpath + "' is missing 'relay'"); } @@ -299,13 +485,13 @@ function rawTunnel() { } */ - var location = url.parse(state.config.relay); + var location = url.parse(state.relay); if (!location.protocol || /\./.test(location.protocol)) { - state.config.relay = 'wss://' + state.config.relay; - location = url.parse(state.config.relay); + state.relay = 'wss://' + state.relay; + location = url.parse(state.relay); } var aud = location.hostname + (location.port ? ':' + location.port : ''); - state.config.relay = location.protocol + '//' + aud; + state.relay = location.protocol + '//' + aud; if (!state.config.token && state.config.secret) { var jwt = require('jsonwebtoken'); diff --git a/examples/telebit.yml b/examples/telebit.yml index db59af0..4ff8ac0 100644 --- a/examples/telebit.yml +++ b/examples/telebit.yml @@ -7,8 +7,13 @@ secret: '' # Shared Secret with Telebit Relay for authoriza #token: '' # Token created by Telebit Relay for authorization ssh_auto: 22 # forward ssh-looking packets, from any connection, to port 22 servernames: # hostnames that direct to the Telebit Relay admin console - example.com: {} - example.net: {} + example.com: + handler: 3000 + example.net: + handler: /path/to/module +ports: + 5050: + handler: 54321 greenlock: version: 'draft-11' server: 'https://acme-staging-v02.api.letsencrypt.org/directory' diff --git a/lib/html/index.html b/lib/html/index.html new file mode 100644 index 0000000..39cddf4 --- /dev/null +++ b/lib/html/index.html @@ -0,0 +1,22 @@ + + + + Telebit + + + + + +

Welcome Home

+ +

You've claimed {{servername}}

+

Here's same ways you can use it:

+
telebit http 3000
+ +

You've claimed {{serviceport}}

+

Here's same ways you can use it:

+
#telebit tcp 3000
+ + + + diff --git a/lib/html/js/app.js b/lib/html/js/app.js new file mode 100644 index 0000000..3bda909 --- /dev/null +++ b/lib/html/js/app.js @@ -0,0 +1,36 @@ +(function () { +'use strict'; + +document.body.hidden = false; + +var hash = window.location.hash.slice(1); + +function parseQuery(search) { + var args = search.substring(1).split('&'); + var argsParsed = {}; + var i, arg, kvp, key, value; + + for (i=0; i < args.length; i++) { + + arg = args[i]; + + if (-1 === arg.indexOf('=')) { + + argsParsed[decodeURIComponent(arg).trim()] = true; + + } else { + + kvp = arg.split('='); + key = decodeURIComponent(kvp[0]).trim(); + value = decodeURIComponent(kvp[1]).trim(); + argsParsed[key] = value; + + } + } + + return argsParsed; +} + +console.log(parseQuery(hash)); + +}()); diff --git a/lib/sorting-hat.js b/lib/sorting-hat.js index c6a6d75..21ee94d 100644 --- a/lib/sorting-hat.js +++ b/lib/sorting-hat.js @@ -1,3 +1,7 @@ +'use strict'; +var os = require('os'); +var path = require('path'); + module.exports.print = function (config) { var services = { https: {}, http: {}, tcp: {} }; // Note: the remote needs to know: @@ -78,11 +82,13 @@ module.exports.assign = function (state, tun, cb) { state.httpRedirectServer.emit('connection', socket); }; handlers.https = function (tlsSocket) { - console.log('Enccrypted', tlsSocket.encrypted, tlsSocket.remoteAddress, tlsSocket.remotePort); + console.log('Encrypted', tlsSocket.encrypted, tlsSocket.remoteAddress, tlsSocket.remotePort); if (!state.defaultHttpServer) { + state._finalHandler = require('finalhandler'); + state._serveStatic = require('serve-static'); + state._defaultServe = state._serveStatic(path.join(__dirname, 'html')); state.defaultHttpServer = require('http').createServer(function (req, res) { - console.log('[hit http/s server]'); - res.end('Hello, Encrypted Tunnel World!'); + state._defaultServe(req, res, state._finalHandler(req, res)); }); } state.defaultHttpServer.emit('connection', tlsSocket); @@ -183,26 +189,27 @@ module.exports.assign = function (state, tun, cb) { } var handle = tun.name || tun.port; var handler; + var handlerpath = conf.handler; var path = require('path'); - var homedir = require('os').homedir(); + var homedir = os.homedir(); var localshare = path.join(homedir, '.local/share/telebit/apps'); - if (/^~/.test(conf.handler)) { - conf.handler = require('path').join(require('os').homedir(), conf.handler.replace(/^~(\/?)/, '')); + if (/^~/.test(handlerpath)) { + handlerpath = path.join(homedir, handlerpath.replace(/^~(\/?)/, '')); } try { - handler = require(conf.handler); - console.info("Handling '" + handle + ":" + id + "' with '" + conf.handler + "'"); + handler = require(handlerpath); + console.info("Handling '" + handle + ":" + id + "' with '" + handlerpath + "'"); handler(tlsSocket, tun, id); } catch(e1) { try { - handler = require(path.join(localshare, conf.handler)); - console.info("Handling '" + handle + ":" + id + "' with '" + conf.handler + "'"); + handler = require(path.join(localshare, handlerpath)); + console.info("Handling '" + handle + ":" + id + "' with '" + handlerpath + "'"); handler(tlsSocket, tun, id); } catch(e2) { - console.error("Failed to load '" + conf.handler + "':", e1.message); - console.error("Failed to load '" + path.join(localshare, conf.handler) + "':", e2.message); + console.error("Failed to load '" + handlerpath + "':", e1.message); + console.error("Failed to load '" + path.join(localshare, handlerpath) + "':", e2.message); console.warn("Using default handler for '" + handle + ":" + id + "'"); handlers.https(tlsSocket, tun, id); } @@ -231,7 +238,7 @@ module.exports.assign = function (state, tun, cb) { defineProps(tlsSocket, addr); //console.log('[hit tls server]', tlsSocket.remoteFamily, tlsSocket.remoteAddress, tlsSocket.remotePort, tlsSocket.localPort); //console.log(addr); - var conf = state.config.servernames[tlsSocket.servername]; + var conf = state.servernames[tlsSocket.servername]; tlsSocket.once('data', function (firstChunk) { tlsSocket.pause(); //tlsSocket.unshift(firstChunk); @@ -246,7 +253,7 @@ module.exports.assign = function (state, tun, cb) { return; } - if (!conf || !conf.handler) { + if (!conf || !conf.handler || 'none' === conf.handler) { console.log('https default handler'); handlers.https(tlsSocket); return; @@ -276,11 +283,6 @@ module.exports.assign = function (state, tun, cb) { return; } - if (!state.config.servernames) { - state.config.servernames = {}; - } - - if ('http' === tun.service || 'https' === tun.service) { if (!tun.name) { cb(new Error("No routing information for ':tun_id'. Service '" + tun.service + "' is missing 'name'.")); @@ -290,13 +292,13 @@ module.exports.assign = function (state, tun, cb) { if ('http' === tun.service) { // TODO match *.example.com - handled = Object.keys(state.config.servernames).some(function (sn) { + handled = Object.keys(state.servernames).some(function (sn) { if (sn !== tun.name) { return; } console.log('Found config match for PLAIN', tun.name); - if (!state.config.servernames[sn]) { return; } + if (!state.servernames[sn]) { return; } - if (false === state.config.servernames[sn].terminate) { + if (false === state.servernames[sn].terminate) { cb(new Error("insecure http not supported yet")); return true; } @@ -313,13 +315,13 @@ module.exports.assign = function (state, tun, cb) { if ('https' === tun.service) { // TODO match *.example.com - handled = Object.keys(state.config.servernames).some(function (sn) { + handled = Object.keys(state.servernames).some(function (sn) { if (sn !== tun.name) { return; } console.log('Found config match for TLS', tun.name); - if (!state.config.servernames[sn]) { return; } + if (!state.servernames[sn]) { return; } - if (false === state.config.servernames[sn].terminate) { + if (false === state.servernames[sn].terminate) { cb(new Error("insecure http not supported yet")); return true; } @@ -339,7 +341,15 @@ module.exports.assign = function (state, tun, cb) { if (conn) { cb(null, conn); return; } // TODO add TCP handlers console.log('Using echo server for tcp'); - echoTcp(cb); + var conf = state.ports[tun.serviceport]; + if (!conf || !conf.handler || 'none' === conf.handler) { + echoTcp(cb); + } + + var Packer = require('proxy-packer'); + //var addr = Packer.socketToAddr(conn); + var id = Packer.addrToId(tun); + invokeHandler(conf, conn, tun, id); }); return; } diff --git a/package.json b/package.json index 32cbdd1..45eb800 100644 --- a/package.json +++ b/package.json @@ -49,12 +49,14 @@ "dependencies": { "bluebird": "^3.5.1", "commander": "^2.9.0", + "finalhandler": "^1.1.1", "greenlock": "^2.2.19", "js-yaml": "^3.11.0", "jsonwebtoken": "^7.1.9", "proxy-packer": "^1.4.3", "recase": "^1.0.4", "redirect-https": "^1.1.5", + "serve-static": "^1.13.2", "sni": "^1.0.0", "socket-pair": "^1.0.3", "ws": "^2.2.3"