WIP de-nest and restructure

This commit is contained in:
AJ ONeal 2019-05-14 02:16:45 -06:00
parent 2a28dca257
commit 2301966f9f
5 changed files with 671 additions and 535 deletions

View File

@ -1,12 +1,14 @@
#!/usr/bin/env node
(function () {
'use strict';
/*global Promise*/
var pkg = require('../package.json');
var os = require('os');
//var url = require('url');
var fs = require('fs');
var util = require('util');
var path = require('path');
//var https = require('https');
var YAML = require('js-yaml');
@ -104,7 +106,9 @@ if (!confpath || /^--/.test(confpath)) {
process.exit(1);
}
function askForConfig(state, mainCb) {
var Console = {};
Console.setup = function (state) {
if (Console.rl) { return; }
var fs = require('fs');
var ttyname = '/dev/tty';
var stdin = useTty ? fs.createReadStream(ttyname, {
@ -119,6 +123,34 @@ function askForConfig(state, mainCb) {
, terminal: !/^win/i.test(os.platform()) && !useTty
});
state._useTty = useTty;
Console.rl = rl;
};
Console.teardown = function () {
// https://github.com/nodejs/node/issues/21319
if (useTty) { try { Console.stdin.push(null); } catch(e) { /*ignore*/ } }
Console.rl.close();
if (useTty) { try { Console.stdin.close(); } catch(e) { /*ignore*/ } }
Console.rl = null;
};
function askEmail(cb) {
Console.setup();
if (state.config.email) { cb(); return; }
console.info(TPLS.remote.setup.email);
// TODO attempt to read email from npmrc or the like?
Console.rl.question('email: ', function (email) {
// TODO validate email domain
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 askForConfig(state, mainCb) {
Console.setup(state);
// NOTE: Use of setTimeout
// We're using setTimeout just to make the user experience a little
@ -128,19 +160,7 @@ function askForConfig(state, mainCb) {
// <= 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);
});
}
askEmail
, function askRelay(cb) {
function checkRelay(relay) {
// TODO parse and check https://{{relay}}/.well-known/telebit.cloud/directives.json
@ -173,7 +193,7 @@ function askForConfig(state, mainCb) {
console.info("");
console.info("What relay will you be using? (press enter for default)");
console.info("");
rl.question('relay [default: telebit.cloud]: ', checkRelay);
Console.rl.question('relay [default: telebit.cloud]: ', checkRelay);
}
, function checkRelay(cb) {
nextSet = [];
@ -201,7 +221,7 @@ function askForConfig(state, mainCb) {
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) {
Console.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...");
@ -219,7 +239,7 @@ function askForConfig(state, mainCb) {
console.info("");
console.info("What updates would you like to receive? (" + options.join(',') + ")");
console.info("");
rl.question('messages (default: important): ', function (updates) {
Console.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; }
@ -240,7 +260,7 @@ function askForConfig(state, mainCb) {
console.info("");
console.info("Contribute project telemetry data? (press enter for default [yes])");
console.info("");
rl.question('telemetry [Y/n]: ', function (telemetry) {
Console.rl.question('telemetry [Y/n]: ', function (telemetry) {
if (!telemetry || /^y(es)?$/i.test(telemetry)) {
state.config.telemetry = true;
}
@ -263,7 +283,7 @@ function askForConfig(state, mainCb) {
console.info("\tShared Secret (HMAC hex)");
//console.info("\tPrivate key (hex)");
console.info("");
rl.question('auth: ', function (resp) {
Console.rl.question('auth: ', function (resp) {
resp = (resp || '').trim();
try {
JWT.decode(resp);
@ -291,7 +311,7 @@ function askForConfig(state, mainCb) {
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) {
Console.rl.question('domain(s): ', function (resp) {
resp = (resp || '').trim().split(/,/g);
if (!resp.length) { askServernames(); return; }
// TODO validate the domains
@ -306,7 +326,7 @@ function askForConfig(state, mainCb) {
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) {
Console.rl.question('port(s) [default:none]: ', function (resp) {
resp = (resp || '').trim().split(/,/g);
if (!resp.length) { askPorts(); return; }
// TODO validate the domains
@ -320,10 +340,7 @@ function askForConfig(state, mainCb) {
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*/ } }
Console.teardown();
mainCb(null, state);
return;
}
@ -335,8 +352,76 @@ function askForConfig(state, mainCb) {
var RC;
function parseConfig(err, text) {
function handleConfig(config) {
function bootstrap(opts) {
state.key = opts.key;
// 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');
var contact = [];
if (opts.email) {
contact.push("mailto:" + opts.email);
}
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: contact // I don't think we have email yet...
, onlyReturnExisting: opts.onlyReturnExisting || !opts.email
//, 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 resp;
});
});
}).catch(RC.createRelauncher(bootstrap._replay(opts), bootstrap._bootstate)).catch(function (err) {
console.error(err);
process.exit(17);
});
}
bootstrap._bootstate = {};
bootstrap._replay = function (_opts) {
return function (opts) {
// supply opts to match reverse signature (.length checking)
opts = _opts;
return bootstrap(opts);
};
};
function handleConfig(config) {
var _config = state.config || {};
state.config = config;
var verstrd = [ pkg.name + ' daemon v' + state.config.version ];
if (state.config.version && state.config.version !== pkg.version) {
@ -345,6 +430,10 @@ function parseConfig(err, text) {
console.info(verstr.join(' '));
}
if (!state.config.email && _config) {
state.config.email = _config.email;
}
//
// check for init first, before anything else
// because it has arguments that may help in
@ -408,9 +497,9 @@ function parseConfig(err, text) {
//console.log("no questioning:");
parseCli(state);
}
}
function parseCli(/*state*/) {
function parseCli(/*state*/) {
var special = [
'false', 'none', 'off', 'disable'
, 'true', 'auto', 'on', 'enable'
@ -462,42 +551,9 @@ function parseConfig(err, text) {
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) {
function handleRemoteRequest(service, fn) {
return function (err, body) {
if ('function' === typeof fn) {
fn(err, body); // XXX was resp
@ -567,9 +623,9 @@ function parseConfig(err, text) {
console.info();
}
};
}
}
function getToken(fn) {
function getToken(fn) {
state.relay = state.config.relay;
// { _otp, config: {} }
@ -671,77 +727,28 @@ function parseConfig(err, text) {
}));
}
});
}
}
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);
function parseConfig(text) {
var _clientConfig;
try {
_clientConfig = JSON.parse(text || '{}');
} catch(e1) {
try {
_clientConfig = YAML.safeLoad(text || '{}');
} catch(e2) {
try {
_clientConfig = TOML.parse(text || '');
} catch(e3) {
console.error(e1.message);
console.error(e2.message);
process.exit(1);
return;
}
}).then(handleConfig);
});
});
}).catch(RC.createErrorHandler(bootstrap, bootState, function (err) {
console.error(err);
process.exit(17);
}));
}
}
bootstrap();
return camelCopy(_clientConfig || {}) || {};
}
var parsers = {
@ -821,12 +828,30 @@ var parsers = {
}
};
var keystore = require('../lib/keystore.js').create(state);
state.keystore = keystore;
state.keystoreSecure = !keystore.insecure;
keystore.all().then(function (list) {
//
// Start by reading the config file, before all else
//
util.promisify(fs.readFile)(confpath, 'utf8').catch(function (err) {
if (err && 'ENOENT' !== err.code) {
console.warn("Couldn't load config:\n\n\t" + err.message + "\n");
}
}).then(function (text) {
state._clientConfig = parseConfig(text);
RC = require('../lib/rc/index.js').create(state); // adds state._ipc
if (!Object.keys(state._clientConfig).length) {
console.info('(' + state._ipc.comment + ": " + state._ipc.path + ')');
console.info("");
}
RC.requestAsync = require('util').promisify(RC.request);
}).then(function () {
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;
var p;
// 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) {
@ -838,21 +863,65 @@ keystore.all().then(function (list) {
});
if (key) {
state.key = key;
state.pub = keypairs.neuter({ jwk: key });
fs.readFile(confpath, 'utf8', parseConfig);
return;
}
return keypairs.generate().then(function (pair) {
p = Promise.resolve(key);
} else {
p = 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);
jwk.kid = kid;
console.info("Generated new %s %s private key with thumbprint %s", jwk.kty, size, kid);
state.key = jwk;
fs.readFile(confpath, 'utf8', parseConfig);
return keystore.set(kid + keyext, jwk).then(function () {
return jwk;
});
});
});
}
return p.then(function (key) {
state.key = key;
state.pub = keypairs.neuter({ jwk: key });
// we don't have config yet
state.config = {};
return bootstrap({ key: state.key, onlyReturnExisting: true }).catch(function (err) {
console.error("[DEBUG] local account not created?");
console.error(err);
// Ask for email address. The prior email may have been bad
return require('util').promisify(askEmail).then(function (email) {
return bootstrap({ key: state.key, email: email });
});
}).catch(function (err) {
console.error(err);
console.error("You may need to go into the web interface and allow Telebit Client by ID '" + key.kid + "'");
process.exit(10);
}).then(function (result) {
//#console.log("Telebit Account Bootstrap result:");
//#console.log(result.body);
state.config.email = (result.body.contact[0]||'').replace(/mailto:/, '');
var p2;
if (state.key.sub === state.config.email) {
p2 = Promise.resolve(state.key);
} else {
state.key.sub = state.config.email;
p2 = keystore.set(state.key.kid + keyext, state.key);
}
return p2.then(function () {
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);
});
});
});
});

View File

@ -525,6 +525,27 @@ controllers.newAccount = function (req, res) {
});
});
};
controllers.acmeAccounts = function (req, res) {
if (!req.jws || !req.jws.verified) {
res.statusCode = 400;
res.send({"error":{"message": "this type of requests must be encoded as a jws payload"
+ " and signed by a known account holder"}});
return;
}
var account;
var accountId = req.params[0];
DB.accounts.some(function (acc) {
// TODO calculate thumbprint from jwk
// find a key with matching jwk
if (acc._id === accountId) {
account = acc;
return true;
}
});
// TODO check that the JWS matches the accountI
console.warn("[warn] account ID still acts as secret, should use JWS kid for verification");
res.send(account);
};
function jsonEggspress(req, res, next) {
/*
@ -1064,6 +1085,10 @@ function handleApi() {
next();
}
// TODO convert /acme/accounts/:account_id into a regex
app.get(/^\/acme\/accounts\/([\w]+)/, controllers.acmeAccounts);
// POST-as-GET
app.post(/^\/acme\/accounts\/([\w]+)/, controllers.acmeAccounts);
app.use(/\b(relay)\b/, mustTrust, controllers.relay);
app.get(/\b(config)\b/, mustTrust, getConfigOnly);
app.use(/\b(init|config)\b/, mustTrust, initOrConfig);

View File

@ -186,6 +186,7 @@ var telebitState = {};
var appMethods = {
initialize: function () {
console.log("call initialize");
return requestAccountHelper().then(function (/*key*/) {
if (!appData.init.relay) {
appData.init.relay = DEFAULT_RELAY;
}
@ -210,6 +211,7 @@ var appMethods = {
console.error(err);
window.alert("Error: [initialize] " + (err.message || JSON.stringify(err, null, 2)));
});
});
}
, advance: function () {
return doConfigure();
@ -473,14 +475,43 @@ new Vue({
, methods: appMethods
});
function run(key) {
function requestAccountHelper() {
function reset() {
changeState('setup');
setState();
}
return new Promise(function (resolve) {
appData.init.email = localStorage.getItem('email');
if (!appData.init.email) {
// don't resolve
reset();
return;
}
return requestAccount(appData.init.email).then(function (key) {
if (!key) { throw new Error("[SANITY] Error: completed without key"); }
resolve(key);
}).catch(function (err) {
appData.init.email = "";
localStorage.removeItem('email');
console.error(err);
window.alert("something went wrong");
// don't resolve
reset();
});
});
}
function run() {
return requestAccountHelper().then(function (key) {
api._key = key;
// TODO create session instance of Telebit
Telebit._key = key;
// 😁 1. Get ACME directory
// 😁 2. Fetch ACME account
// 3. Test if account has access
// 4. Show command line auth instructions to auth
// 5. Sign requests / use JWT
// 6. Enforce token required for config, status, etc
// 😁 5. Sign requests / use JWT
// 😁 6. Enforce token required for config, status, etc
// 7. Move admin interface to standard ports (admin.foo-bar-123.telebit.xyz)
api.config().then(function (config) {
telebitState.config = config;
@ -522,6 +553,7 @@ function run(key) {
}).catch(function (err) {
appData.views.flash.error = err.message || JSON.stringify(err, null, 2);
});
});
}
@ -543,19 +575,8 @@ function getKey() {
});
}
function getEmail() {
return Promise.resolve().then(function () {
var email = localStorage.getItem('email');
if (email) { return email; }
while (!email) {
email = window.prompt("Email address (device owner)?");
}
return email;
});
}
function requestAccount() {
function requestAccount(email) {
return getKey().then(function (jwk) {
return getEmail().then(function(email) {
// creates new or returns existing
var acme = ACME.create({});
var url = window.location.protocol + '//' + window.location.host + '/acme/directory';
@ -574,15 +595,14 @@ function requestAccount() {
});
});
});
});
}
window.api = api;
requestAccount().then(function (jwk) {
run(jwk);
setTimeout(function () {
run();
setTimeout(function () {
document.body.hidden = false;
}, 50);
});
}, 50);
// Debug
window.changeState = changeState;
}());

View File

@ -34,11 +34,12 @@ module.exports = function eggspress() {
}
var urlstr = (req.url.replace(/\/$/, '') + '/');
if (!urlstr.match(todo[0])) {
var match = urlstr.match(todo[0]);
if (!match) {
//console.log("[eggspress] pattern doesn't match", todo[0], req.url);
next();
return;
} else if ('string' === typeof todo[0] && 0 !== urlstr.match(todo[0]).index) {
} else if ('string' === typeof todo[0] && 0 !== match.index) {
//console.log("[eggspress] string pattern is not the start", todo[0], req.url);
next();
return;
@ -58,6 +59,7 @@ module.exports = function eggspress() {
}
var fns = todo[1].slice(0);
req.params = match.slice(1);
function nextTodo(err) {
if (err) { fail(err); return; }

View File

@ -93,26 +93,45 @@ module.exports.create = function (state) {
}
return reqOpts;
};
RC.createErrorHandler = function (replay, opts, cb) {
RC.createRelauncher = function (replay, opts, cb) {
return function (err) {
/*global Promise*/
var p = new Promise(function (resolve, reject) {
// ENOENT - never started, cleanly exited last start, or creating socket at a different path
// ECONNREFUSED - leftover socket just needs to be restarted
if ('ENOENT' === err.code || 'ECONNREFUSED' === err.code) {
if (opts._taketwo) {
cb(err);
return;
}
require('../../usr/share/install-launcher.js').install({ env: process.env }, function (err) {
if (err) { cb(err); return; }
opts._taketwo = true;
setTimeout(function () {
replay(opts, cb);
}, 2500);
});
if ('ENOENT' !== err.code && 'ECONNREFUSED' !== err.code) {
reject(err);
return;
}
cb(err);
// retried and failed again: quit
if (opts._taketwo) {
reject(err);
return;
}
require('../../usr/share/install-launcher.js').install({ env: process.env }, function (err) {
if (err) { reject(err); return; }
opts._taketwo = true;
setTimeout(function () {
if (replay.length <= 1) {
replay(opts).then(resolve).catch(reject);
return;
} else {
replay(opts, function (err, res) {
if (err) { reject(err); }
else { resolve(res); }
});
return;
}
}, 2500);
});
return;
});
if (cb) {
p.then(function () { cb(null); }).catch(function (err) { cb(err); });
}
return p;
};
};
RC.request = function request(opts, fn) {
@ -141,7 +160,8 @@ module.exports.create = function (state) {
makeResponder(service, resp, fn);
});
req.on('error', RC.createErrorHandler(RC.request, opts, fn));
var errHandler = RC.createRelauncher(RC.request, opts, fn);
req.on('error', errHandler);
// Simple GET
if ('POST' !== method || !opts.data) {