#!/usr/bin/env node (function () { 'use strict'; var pkg = require('../package.json'); var os = require('os'); //var url = require('url'); var fs = require('fs'); var path = require('path'); //var https = require('https'); var YAML = require('js-yaml'); var TOML = require('toml'); var TPLS = TOML.parse(fs.readFileSync(path.join(__dirname, "../lib/en-us.toml"), 'utf8')); var JWT = require('../lib/jwt.js'); var keypairs = require('keypairs'); /* if ('function' !== typeof TOML.stringify) { TOML.stringify = require('json2toml'); } */ var recase = require('recase').create({}); var camelCopy = recase.camelCopy.bind(recase); //var snakeCopy = recase.snakeCopy.bind(recase); var urequest = require('@root/request'); var urequestAsync = require('util').promisify(urequest); var common = require('../lib/cli-common.js'); var defaultConfPath = path.join(os.homedir(), '.config/telebit'); var defaultConfFile = path.join(defaultConfPath, 'telebit.yml'); var argv = process.argv.slice(2); var argIndex = argv.indexOf('--config'); if (-1 === argIndex) { argIndex = argv.indexOf('-c'); } var confpath; var useTty; var state = {}; if (-1 === argIndex) { argIndex = argv.indexOf('-c'); } if (-1 !== argIndex) { confpath = argv.splice(argIndex, 2)[1]; state.configArg = confpath; // shortname if (state.configArg) { if (/^[\w:\.\-]+$/.test(state.configArg)) { state.configDir = defaultConfPath; state.configFile = path.join(defaultConfPath, confpath + '.yml'); } else if (/[\/\\]$/.test(state.configArg)) { state.configDir = state.configArg; state.configFile = path.join(state.configDir, 'telebit.yml'); } else if (/[\/\\][^\.\/\\]\.[^\.\/\\]$/.test(state.configArg)) { state.configDir = path.pathname(state.configArg); state.configFile = state.configArg; } else { console.error(); console.error("Not a valid config path, file, or shortname: '%s'", state.configArg); console.error(); console.error("Valid config options look like this:"); console.error(" Full path: ~/.config/telebit/telebit.yml (full path)"); console.error(" Directory: ~/.config/telebit/ (directory)"); console.error(" Shortname: lucky-duck (shortname)"); process.exit(37); } confpath = state.configFile; } } argIndex = argv.indexOf('--tty'); if (-1 !== argIndex) { useTty = argv.splice(argIndex, 1); } function help() { var keys = Object.keys(TPLS.help).filter(function (key) { return 'remote' !== key; }); var key = keys.filter(function (key) { return -1 !== process.argv.indexOf(key); })[0] || 'remote'; console.info(TPLS.help[key].replace(/{version}/g, pkg.version)); } var verstr = [ pkg.name + ' remote v' + pkg.version ]; if (!confpath) { state.configDir = defaultConfPath; state.configFile = defaultConfFile; confpath = state.configFile; verstr.push('(--config \'' + confpath.replace(new RegExp('^' + os.homedir()), '~') + '\')'); } if ([ '-h', '--help', 'help' ].some(function (arg) { return -1 !== argv.indexOf(arg); })) { help(); process.exit(0); } if (!confpath || /^--/.test(confpath)) { help(); process.exit(1); } function askForConfig(state, mainCb) { var fs = require('fs'); var ttyname = '/dev/tty'; var stdin = useTty ? fs.createReadStream(ttyname, { fd: fs.openSync(ttyname, fs.constants.O_RDONLY | fs.constants.O_NOCTTY) }) : process.stdin; var readline = require('readline'); var rl = readline.createInterface({ input: stdin , output: process.stdout // https://github.com/nodejs/node/issues/21771 // https://github.com/nodejs/node/issues/21319 , terminal: !/^win/i.test(os.platform()) && !useTty }); state._useTty = useTty; // NOTE: Use of setTimeout // We're using setTimeout just to make the user experience a little // nicer, as if we're doing something inbetween steps, so that it // is a smooth rather than jerky experience. // >= 300ms is long enough to become distracted and change focus (a full blink, time for an idea to form as a thought) // <= 100ms is shorter than normal human reaction time (ability to place events chronologically, which happened first) // ~ 150-250ms is the sweet spot for most humans (long enough to notice change and not be jarred, but stay on task) var firstSet = [ function askEmail(cb) { if (state.config.email) { cb(); return; } console.info(TPLS.remote.setup.email); // TODO attempt to read email from npmrc or the like? rl.question('email: ', function (email) { email = /@/.test(email) && email.trim(); if (!email) { askEmail(cb); return; } state.config.email = email.trim(); state.config.agreeTos = true; console.info(""); setTimeout(cb, 250); }); } , function askRelay(cb) { function checkRelay(relay) { // TODO parse and check https://{{relay}}/.well-known/telebit.cloud/directives.json if (!relay) { relay = 'telebit.cloud'; } relay = relay.trim(); var urlstr = common.parseUrl(relay) + common.apiDirectory; urequest({ url: urlstr, json: true }, function (err, resp, body) { if (err) { console.error("[Network Error] Failed to retrieve '" + urlstr + "'"); console.error(err); askRelay(cb); return; } if (200 !== resp.statusCode || (Buffer.isBuffer(body) || 'object' !== typeof body) || !body.api_host) { console.warn(TPLS.remote.setup.fail_relay_check .replace(/{{\s*status_code\s*}}/, resp.statusCode) .replace(/{{\s*url\s*}}/, urlstr) ); console.warn(body); } else if (body && body.pair_request) { state._can_pair = true; } state.config.relay = relay; cb(); }); } if (state.config.relay) { checkRelay(state.config.relay); return; } console.info(""); console.info(""); console.info("What relay will you be using? (press enter for default)"); console.info(""); rl.question('relay [default: telebit.cloud]: ', checkRelay); } , function checkRelay(cb) { nextSet = []; if ('telebit.cloud' !== state.config.relay) { nextSet = nextSet.concat(standardSet); } if (!state._can_pair) { nextSet = nextSet.concat(fossSet); } cb(); } ]; var standardSet = [ // There are questions that we need to ask in the CLI // if we can't guarantee that they are being asked in the web interface function askAgree(cb) { if (state.config.agreeTos) { cb(); return; } console.info(""); console.info(""); console.info("Do you accept the terms of service for each and all of the following?"); console.info(""); console.info("\tTelebit - End-to-End Encrypted Relay"); console.info("\tGreenlock - Automated HTTPS"); console.info("\tLet's Encrypt - TLS Certificates"); console.info(""); console.info("Type 'y' or 'yes' to accept these Terms of Service."); console.info(""); rl.question('agree to all? [y/N]: ', function (resp) { resp = resp.trim(); if (!/^y(es)?$/i.test(resp) && 'true' !== resp) { throw new Error("You didn't accept the Terms of Service... not sure what to do..."); } state.config.agreeTos = true; console.info(""); setTimeout(cb, 250); }); } , function askUpdates(cb) { // required means transactional, security alerts, mandatory updates var options = [ 'newsletter', 'important', 'required' ]; if (-1 !== options.indexOf(state._updates)) { cb(); return; } console.info(""); console.info(""); console.info("What updates would you like to receive? (" + options.join(',') + ")"); console.info(""); rl.question('messages (default: important): ', function (updates) { state._updates = (updates || '').trim().toLowerCase(); if (!state._updates) { state._updates = 'important'; } if (-1 === options.indexOf(state._updates)) { askUpdates(cb); return; } if ('newsletter' === state._updates) { state.config.newsletter = true; state.config.communityMember = true; } else if ('important' === state._updates) { state.config.communityMember = true; } setTimeout(cb, 250); }); } , function askTelemetry(cb) { if (state.config.telemetry) { cb(); return; } console.info(""); console.info(""); console.info("Contribute project telemetry data? (press enter for default [yes])"); console.info(""); rl.question('telemetry [Y/n]: ', function (telemetry) { if (!telemetry || /^y(es)?$/i.test(telemetry)) { state.config.telemetry = true; } setTimeout(cb, 250); }); } ]; var fossSet = [ function askTokenOrSecret(cb) { if (state._can_pair || state.token || state.config.token || state.secret || state.config.secret) { cb(); return; } console.info(""); console.info(""); console.info("What's your authorization for '" + state.config.relay + "'?"); console.info(""); // TODO check .well-known to learn supported token types console.info("Currently supported:"); console.info(""); console.info("\tToken (JWT format)"); console.info("\tShared Secret (HMAC hex)"); //console.info("\tPrivate key (hex)"); console.info(""); rl.question('auth: ', function (resp) { resp = (resp || '').trim(); try { JWT.decode(resp); state.config.token = resp; } catch(e) { // is not jwt } if (!state.config.token) { resp = resp.toLowerCase(); if (resp === Buffer.from(resp, 'hex').toString('hex')) { state.config.secret = resp; } } if (!state.config.token && !state.config.secret) { askTokenOrSecret(cb); return; } setTimeout(cb, 250); }); } , function askServernames(cb) { if (!state.config.secret || state.config._servernames) { cb(); return; } console.info(""); console.info(""); console.info("What servername(s) will you be relaying here?"); console.info("(use a comma-separated list such as example.com,example.net)"); console.info(""); rl.question('domain(s): ', function (resp) { resp = (resp || '').trim().split(/,/g); if (!resp.length) { askServernames(); return; } // TODO validate the domains state.config._servernames = resp; setTimeout(cb, 250); }); } , function askPorts(cb) { if (!state.config.secret || state.config._ports) { cb(); return; } console.info(""); console.info(""); console.info("What tcp port(s) will you be relaying here?"); console.info("(use a comma-separated list such as 2222,5050)"); console.info(""); rl.question('port(s) [default:none]: ', function (resp) { resp = (resp || '').trim().split(/,/g); if (!resp.length) { askPorts(); return; } // TODO validate the domains state.config._ports = resp; setTimeout(cb, 250); }); } ]; var nextSet = firstSet; function next() { var q = nextSet.shift(); if (!q) { // https://github.com/nodejs/node/issues/21319 if (useTty) { try { stdin.push(null); } catch(e) { /*ignore*/ } } rl.close(); if (useTty) { try { stdin.close(); } catch(e) { /*ignore*/ } } mainCb(null, state); return; } q(next); } next(); } var RC; function parseConfig(err, text) { function handleConfig(config) { state.config = config; var verstrd = [ pkg.name + ' daemon v' + state.config.version ]; if (state.config.version && state.config.version !== pkg.version) { console.info(verstr.join(' '), verstrd.join(' ')); } else { console.info(verstr.join(' ')); } // // check for init first, before anything else // because it has arguments that may help in // the next steps // if (-1 !== argv.indexOf('init')) { parsers.init(argv, function (err) { if (err) { console.error("Error while initializing config [init]:"); throw err; } getToken(function (err) { if (err) { console.error("Error while getting token [init]:"); throw err; } parseCli(state); }); }); return; } if (!state.config.relay || !state.config.token) { if (!state.config.relay) { try { state.config.relay = 'telebit.cloud'; } catch(e) { console.error(state.config); throw e; } } //console.log("question the user?", Date.now()); askForConfig(state, function (err, state) { // no errors actually get passed, so this is just future-proofing if (err) { throw err; } if (!state.config.token && state._can_pair) { state.config._otp = common.otp(); } //console.log("done questioning:", Date.now()); if (!state.token && !state.config.token) { if (err) { console.error("Error while initializing config [init]:"); throw err; } getToken(function (err) { if (err) { console.error("Error while getting token [init]:"); throw err; } parseCli(state); }); } else { parseCli(state); } }); return; } //console.log("no questioning:"); parseCli(state); } function parseCli(/*state*/) { var special = [ 'false', 'none', 'off', 'disable' , 'true', 'auto', 'on', 'enable' ]; if (-1 !== argv.indexOf('init')) { RC.request({ service: 'list', method: 'POST', data: [] }, handleRemoteRequest('list')); return; } if ([ 'ssh', 'http', 'tcp' ].some(function (key) { if (key !== argv[0]) { return false; } if (argv[1]) { if (String(argv[1]) === String(parseInt(argv[1], 10))) { // looks like a port argv[1] = parseInt(argv[1], 10); } else if (/\/|\\/.test(argv[1])) { // looks like a path argv[1] = path.resolve(argv[1]); // TODO make a default assignment here } else if (-1 === special.indexOf(argv[1])) { console.error("Not sure what you meant by '" + argv[1] + "'."); console.error("Remember: paths should begin with ." + path.sep + ", like '." + path.sep + argv[1] + "'"); return true; } RC.request({ service: argv[0], method: 'POST', data: argv.slice(1) }, handleRemoteRequest(argv[0])); return true; } help(); return true; })) { return; } // Two styles: // http 3000 // http modulename function makeRpc(key) { if (key !== argv[0]) { return false; } RC.request({ service: argv[0], method: 'POST', data: argv.slice(1) }, handleRemoteRequest(argv[0])); return true; } if ([ 'status', 'enable', 'disable', 'restart', 'list', 'save' ].some(makeRpc)) { return; } help(); process.exit(11); } try { state._clientConfig = JSON.parse(text || '{}'); } catch(e1) { try { state._clientConfig = YAML.safeLoad(text || '{}'); } catch(e2) { try { state._clientConfig = TOML.parse(text || ''); } catch(e3) { console.error(e1.message); console.error(e2.message); process.exit(1); return; } } } state._clientConfig = camelCopy(state._clientConfig || {}) || {}; RC = require('../lib/rc/index.js').create(state); RC.requestAsync = require('util').promisify(RC.request); if (!Object.keys(state._clientConfig).length) { console.info('(' + state._ipc.comment + ": " + state._ipc.path + ')'); console.info(""); } if ((err && 'ENOENT' === err.code) || !Object.keys(state._clientConfig).length) { if (!err || 'ENOENT' === err.code) { //console.warn("Empty config file. Run 'telebit init' to configure.\n"); } else { console.warn("Couldn't load config:\n\n\t" + err.message + "\n"); } } function handleRemoteRequest(service, fn) { return function (err, body) { if ('function' === typeof fn) { fn(err, body); // XXX was resp return; } console.info(""); if (err) { console.warn("'" + service + "' may have failed." + " Consider peaking at the logs either with 'journalctl -xeu telebit' or /opt/telebit/var/log/error.log"); console.warn(err.statusCode, err.message); //cb(new Error("not okay"), body); return; } if (!body) { console.info("👌"); return; } try { body = JSON.parse(body); } catch(e) { // ignore } if ("AWAIT_AUTH" === body.code) { console.info(body.message); } else if ("CONFIG" === body.code) { delete body.code; //console.info(TOML.stringify(body)); console.info(YAML.safeDump(body)); } else { if ('http' === body.module) { // TODO we'll support slingshot-ing in the future if (body.local) { if (String(body.local) === String(parseInt(body.local, 10))) { console.info('> Forwarding https://' + body.remote + ' => localhost:' + body.local); } else { console.info('> Serving ' + body.local + ' as https://' + body.remote); } } else { console.info('> Rejecting End-to-End Encrypted HTTPS for now'); } } else if ('tcp' === body.module) { if (body.local) { console.info('> Forwarding ' + state.config.relay + ':' + body.remote + ' => localhost:' + body.local); } else { console.info('> Rejecting Legacy TCP'); } } else if ('ssh' === body.module) { //console.info('> Forwarding ' + state.config.relay + ' -p ' + JSON.stringify(body) + ' => localhost:' + body.local); if (body.local) { console.info('> Forwarding ssh+https (openssl proxy) => localhost:' + body.local); } else { console.info('> Rejecting SSH-over-HTTPS for now'); } } else if ('status' === body.module) { // TODO funny one this one if (body.port) { console.info('http://localhost:' + (body.port)); } console.info(JSON.stringify(body, null, 2)); } else { console.info(JSON.stringify(body, null, 2)); } console.info(); } }; } function getToken(fn) { state.relay = state.config.relay; // { _otp, config: {} } common.api.token(state, { error: function (err/*, next*/) { console.error("[Error] common.api.token:"); console.error(err); return; } , directory: function (dir, next) { //console.log('[directory] Telebit Relay Discovered:'); //console.log(dir); state._apiDirectory = dir; next(); } , tunnelUrl: function (tunnelUrl, next) { //console.log('[tunnelUrl] Telebit Relay Tunnel Socket:', tunnelUrl); state.wss = tunnelUrl; next(); } , requested: function (authReq, next) { //console.log("[requested] Pairing Requested"); state.config._otp = state.config._otp = authReq.otp; if (!state.config.token && state._can_pair) { console.info(TPLS.remote.code.replace(/0000/g, state.config._otp)); } next(); } , connect: function (pretoken, next) { //console.log("[connect] Enabling Pairing Locally..."); state.config.pretoken = pretoken; state._connecting = true; // TODO use php-style object querification RC.request({ service: 'config', method: 'POST', data: state.config }, handleRemoteRequest('config', function (err/*, body*/) { if (err) { state._error = err; console.error("Error while initializing config [connect]:"); console.error(err); return; } console.info("waiting..."); next(); })); } , offer: function (token, next) { //console.log("[offer] Pairing Enabled by Relay"); state.config.token = token; if (state._error) { return; } state._connecting = true; try { JWT.decode(token); //console.log(JWT.decode(token)); } catch(e) { console.warn("[warning] could not decode token"); } RC.request({ service: 'config', method: 'POST', data: state.config }, handleRemoteRequest('config', function (err/*, body*/) { if (err) { state._error = err; console.error("Error while initializing config [offer]:"); console.error(err); return; } //console.log("Pairing Enabled Locally"); next(); })); } , granted: function (_, next) { //console.log("[grant] Pairing complete!"); next(); } , end: function () { RC.request({ service: 'enable', method: 'POST', data: [] }, handleRemoteRequest('enable', function (err) { if (err) { console.error(err); return; } console.info("Success"); // workaround for https://github.com/nodejs/node/issues/21319 if (state._useTty) { setTimeout(function () { console.info("Some fun things to try first:\n"); console.info(" ~/telebit http ~/public"); console.info(" ~/telebit tcp 5050"); console.info(" ~/telebit ssh auto"); console.info(); console.info("Press any key to continue..."); console.info(); process.exit(0); }, 0.5 * 1000); return; } // end workaround //parseCli(state); fn(); })); } }); } var bootState = {}; function bootstrap() { // Create / retrieve account (sign-in, more or less) // TODO hit directory resource /.well-known/openid-configuration -> acme_uri (?) // Occassionally rotate the key just for the sake of testing the key rotation return urequestAsync({ method: 'HEAD' , url: RC.resolve('/acme/new-nonce') , headers: { "User-Agent": 'Telebit/' + pkg.version } }).then(function (resp) { var nonce = resp.headers['replay-nonce']; var newAccountUrl = RC.resolve('/acme/new-acct'); return keypairs.signJws({ jwk: state.key , protected: { // alg will be filled out automatically jwk: state.pub , kid: false , nonce: nonce , url: newAccountUrl } , payload: JSON.stringify({ // We can auto-agree here because the client is the user agent of the primary user termsOfServiceAgreed: true , contact: [] // I don't think we have email yet... //, externalAccountBinding: null }) }).then(function (jws) { return urequestAsync({ url: newAccountUrl , method: 'POST' , json: jws // TODO default to post when body is present , headers: { "Content-Type": 'application/jose+json' , "User-Agent": 'Telebit/' + pkg.version } }).then(function (resp) { //nonce = resp.headers['replay-nonce']; if (!resp.body || 'valid' !== resp.body.status) { console.error('request jws:', jws); console.error('response:'); console.error(resp.headers); console.error(resp.body); throw new Error("did not successfully create or restore account"); } return RC.requestAsync({ service: 'config', method: 'GET' }).catch(function (err) { if (err) { if ('ENOENT' === err.code || 'ECONNREFUSED' === err.code) { console.error("Either the telebit service was not already (and could not be started) or its socket could not be written to."); console.error(err); } else if ('ENOTSOCK' === err.code) { console.error(err); return; } else { console.error(err); } process.exit(101); return; } }).then(handleConfig); }); }); }).catch(RC.createErrorHandler(bootstrap, bootState, function (err) { console.error(err); process.exit(17); })); } bootstrap(); } var parsers = { init: function (argv, parseCb) { var answers = {}; var boolish = [ '--advanced' ]; if ('init' !== argv[0]) { throw new Error("init must be the first argument"); } argv.shift(); // init --foo bar argv.forEach(function (arg, i) { if (!/^--/.test(arg)) { return; } if (-1 !== boolish.indexOf(arg)) { answers['_' + arg.replace(/^--/, '')] = true; } if (/^-/.test(argv[i + 1])) { throw new Error(argv[i + 1] + ' requires an argument'); } answers[arg] = argv[i + 1]; }); // init foo:bar argv.forEach(function (arg) { if (/^--/.test(arg)) { return; } var parts = arg.split(/:/g); if (2 !== parts.length) { throw new Error("bad option to init: '" + arg + "'"); } if (answers[parts[0]]) { throw new Error("duplicate key to init '" + parts[0] + "'"); } answers[parts[0]] = parts[1]; }); if (answers.relay) { console.info("using --relay " + answers.relay); } // things that aren't straight-forward copy-over if (!answers.advanced && !answers.relay) { answers.relay = 'telebit.cloud'; } if (Array.isArray(common._NOTIFICATIONS[answers.update])) { common._NOTIFICATIONS[answers.update].forEach(function (name) { state.config[name] = true; }); } if (answers.servernames) { state.config._servernames = answers.servernames; } if (answers.ports) { state.config._ports = answers.ports; } // things that are straight-forward copy-over common.CONFIG_KEYS.forEach(function (key) { if ('true' === answers[key]) { answers[key] = true; } if ('false' === answers[key]) { answers[key] = false; } if ('null' === answers[key]) { answers[key] = null; } if ('undefined' === answers[key]) { delete answers[key]; } if ('undefined' !== typeof answers[key]) { state.config[key] = answers[key]; } }); askForConfig(state, function (err, state) { if (err) { parseCb(err); return; } if (!state.config.token && state._can_pair) { state.config._otp = common.otp(); } argv.unshift('init'); parseCb(null, state); }); } }; var keystore = require('../lib/keystore.js').create(state); state.keystore = keystore; state.keystoreSecure = !keystore.insecure; keystore.all().then(function (list) { var keyext = '.key.jwk.json'; var key; // TODO create map by account and index into that map to get the master key // and sort keys in the process list.some(function (el) { if (keyext === el.account.slice(-keyext.length) && el.password.kty && el.password.kid) { key = el.password; return true; } }); if (key) { state.key = key; state.pub = keypairs.neuter({ jwk: key }); fs.readFile(confpath, 'utf8', parseConfig); return; } return keypairs.generate().then(function (pair) { var jwk = pair.private; return keypairs.thumbprint({ jwk: jwk }).then(function (kid) { jwk.kid = kid; return keystore.set(kid + keyext, jwk).then(function () { var size = (jwk.crv || Buffer.from(jwk.n, 'base64').byteLength * 8); console.info("Generated new %s %s private key with thumbprint %s", jwk.kty, size, kid); state.key = jwk; fs.readFile(confpath, 'utf8', parseConfig); }); }); }); }); }());