Browse Source
Aside from a few external process calls there are now zero external dependencies required as part of the node.js boot process. Yay!letsencrypt
AJ ONeal
9 years ago
11 changed files with 647 additions and 577 deletions
@ -0,0 +1,48 @@ |
|||
Small and Fast |
|||
============== |
|||
|
|||
We're targetting very tiny systems, so we have to |
|||
be really small and really fast. |
|||
|
|||
We want to get from 0 to a listening socket as quickly |
|||
as possible, so we have this little folder of boot |
|||
code that uses no external modules and as few internal |
|||
modules as reasonably possible. |
|||
|
|||
* fs.readFileSync is fast (< 1ms) |
|||
* v8's parser is pretty fast |
|||
* v8's fast compiler is slow |
|||
* v8's optimizer happens just-in-time |
|||
|
|||
Master |
|||
====== |
|||
|
|||
Master has a few jobs: |
|||
|
|||
* spin up the reverse proxy (caddy in this case) |
|||
* spin up the workers (as many as CPU cores) |
|||
* manage shared key/value store |
|||
* manage shared sqlite3 |
|||
* perform one-off processes once boot is complete |
|||
* SIGUSR1 (normally SIGHUP) to caddy |
|||
* watch and update ip address |
|||
* watch and update router unpn / pmp-nat |
|||
* watch and update Reverse VPN |
|||
|
|||
Worker |
|||
====== |
|||
|
|||
Workers are the ones that master spins up to do the hard |
|||
core stuff. They run the apis of the apps. |
|||
|
|||
Low Mem |
|||
======= |
|||
|
|||
We need to profile very low memory devices and see if |
|||
it is better to have just one process, or if master and |
|||
worker is still okay over time. |
|||
|
|||
The working suspision is that by occasionally starting |
|||
up a new worker and killing the old one when memory usage |
|||
starts to rise should fair pretty well and keeping |
|||
the system stable. |
@ -0,0 +1,68 @@ |
|||
'use strict'; |
|||
|
|||
function loadCerts(secureContexts, certPaths, domainname, prevdomainname) { |
|||
var PromiseA = require('bluebird'); |
|||
var fs = PromiseA.promisifyAll(require('fs')); |
|||
var path = require('path'); |
|||
|
|||
if (/(^|\.)proxyable\./.test(domainname)) { |
|||
// device-id-12345678.proxyable.myapp.mydomain.com => myapp.mydomain.com
|
|||
// proxyable.myapp.mydomain.com => myapp.mydomain.com
|
|||
// TODO myapp.mydomain.com.proxyable.com => myapp.mydomain.com
|
|||
domainname = domainname.replace(/.*\.?proxyable\./, ''); |
|||
} |
|||
|
|||
if (secureContexts[domainname]) { |
|||
return PromiseA.resolve(secureContexts[domainname]); |
|||
} |
|||
|
|||
return PromiseA.some(certPaths.map(function (pathname) { |
|||
return PromiseA.all([ |
|||
fs.readFileAsync(path.join(pathname, domainname, 'privkey.pem'), 'ascii') |
|||
, fs.readFileAsync(path.join(pathname, domainname, 'fullchain.pem'), 'ascii') |
|||
]); |
|||
}), 1).then(function (some) { |
|||
var one = some[0]; |
|||
secureContexts[domainname] = require('tls').createSecureContext({ |
|||
key: one[0] |
|||
, cert: one[1] |
|||
// https://hynek.me/articles/hardening-your-web-servers-ssl-ciphers/
|
|||
// https://nodejs.org/api/tls.html
|
|||
// removed :ECDH+AES256:DH+AES256 and added :!AES256 because AES-256 wastes CPU
|
|||
, ciphers: 'ECDH+AESGCM:DH+AESGCM:ECDH+AES128:DH+AES:ECDH+3DES:DH+3DES:RSA+AESGCM:RSA+AES:RSA+3DES:!aNULL:!MD5:!DSS:!AES256' |
|||
, honorCipherOrder: true |
|||
}); |
|||
|
|||
// guard against race condition on Promise.some
|
|||
if (prevdomainname && !secureContexts[prevdomainname]) { |
|||
// TODO XXX make sure that letsencrypt www. domains handle the bare domains also (and vice versa)
|
|||
secureContexts[prevdomainname] = secureContexts[domainname]; |
|||
} |
|||
|
|||
return secureContexts[domainname]; |
|||
}, function (/*err*/) { |
|||
// AggregateError means both promises failed
|
|||
// TODO check ENOENT
|
|||
|
|||
// test "is this server <<domainname>>?"
|
|||
// try letsencrypt
|
|||
// fail with www.example.com
|
|||
if (/^www\./i.test(domainname)) { |
|||
return loadCerts(secureContexts, certPaths, domainname.replace(/^www\./i, ''), domainname); |
|||
} |
|||
|
|||
return (secureContexts['www.example.com'] || secureContexts['example.com']); |
|||
}).then(function (ctx) { |
|||
// TODO generate some self-signed certs?
|
|||
if (!ctx) { |
|||
console.error("[loadCerts()] Could not load default HTTPS certificates!!!"); |
|||
return PromiseA.reject({ |
|||
message: "No default certificates for https" |
|||
, code: 'E_NO_DEFAULT_CERTS' |
|||
}); |
|||
} |
|||
|
|||
return ctx; |
|||
}); |
|||
} |
|||
module.exports.load = loadCerts; |
@ -1,30 +1,43 @@ |
|||
'use strict'; |
|||
|
|||
module.exports.create = function (port, promiseApp) { |
|||
var PromiseA = require('bluebird'); |
|||
// Note the odd use of callbacks (instead of promises) here
|
|||
// It's to avoid loading bluebird yet (see sni-server.js for explanation)
|
|||
module.exports.create = function (certPaths, port, serverCallback) { |
|||
function initServer(err, server) { |
|||
var app; |
|||
var promiseApp; |
|||
|
|||
return new PromiseA(function (resolve, reject) { |
|||
var server = require('http').createServer(); |
|||
if (err) { |
|||
serverCallback(err); |
|||
return; |
|||
} |
|||
|
|||
server.on('error', reject); |
|||
server.listen(port, 'localhost', function () { |
|||
console.log("Listening", server.address()); |
|||
resolve(server); |
|||
server.on('error', serverCallback); |
|||
server.listen(port, function () { |
|||
// is it even theoritically possible for
|
|||
// a request to come in before this callback has fired?
|
|||
// I'm assuming this event must fire before any request event
|
|||
promiseApp = serverCallback(null, server); |
|||
}); |
|||
|
|||
// Get up and listening as absolutely quickly as possible
|
|||
server.on('request', function (req, res) { |
|||
// TODO move to caddy parser?
|
|||
if (/(^|\.)proxyable\./.test(req.headers.host)) { |
|||
// device-id-12345678.proxyable.myapp.mydomain.com => myapp.mydomain.com
|
|||
// proxyable.myapp.mydomain.com => myapp.mydomain.com
|
|||
// TODO myapp.mydomain.com.proxyable.com => myapp.mydomain.com
|
|||
req.headers.host = req.headers.host.replace(/.*\.?proxyable\./, ''); |
|||
// this is a hot piece of code, so we cache the result
|
|||
if (app) { |
|||
app(req, res); |
|||
return; |
|||
} |
|||
|
|||
promiseApp().then(function (app) { |
|||
promiseApp.then(function (_app) { |
|||
app = _app; |
|||
app(req, res); |
|||
}); |
|||
}); |
|||
}); |
|||
} |
|||
|
|||
if (certPaths) { |
|||
require('./sni-server').create(certPaths, port, initServer); |
|||
} else { |
|||
initServer(null, require('http').createServer()); |
|||
} |
|||
}; |
|||
|
@ -0,0 +1,138 @@ |
|||
'use strict'; |
|||
|
|||
var cluster = require('cluster'); |
|||
var PromiseA = require('bluebird'); |
|||
var memstore; |
|||
// TODO
|
|||
// var rootMasterKey;
|
|||
|
|||
function updateIps() { |
|||
console.log('[UPDATE IP]'); |
|||
require('./ddns-updater').update().then(function (results) { |
|||
results.forEach(function (result) { |
|||
if (result.error) { |
|||
console.error(result); |
|||
} else { |
|||
console.log('[SUCCESS]', result.service.hostname); |
|||
} |
|||
}); |
|||
}).error(function (err) { |
|||
console.error('[UPDATE IP] ERROR'); |
|||
console.error(err); |
|||
}); |
|||
} |
|||
|
|||
function init(conf/*, state*/) { |
|||
if (!conf.ipcKey) { |
|||
conf.ipcKey = require('crypto').randomBytes(16).toString('base64'); |
|||
} |
|||
|
|||
var memstoreOpts = { |
|||
sock: conf.memstoreSock || '/tmp/memstore.sock' |
|||
|
|||
// If left 'null' or 'undefined' this defaults to a similar memstore
|
|||
// with no special logic for 'cookie' or 'expires'
|
|||
, store: cluster.isMaster && null //new require('express-session/session/memory')()
|
|||
|
|||
// a good default to use for instances where you might want
|
|||
// to cluster or to run standalone, but with the same API
|
|||
, serve: cluster.isMaster |
|||
, connect: cluster.isWorker |
|||
//, standalone: (1 === numCores) // overrides serve and connect
|
|||
// TODO implement
|
|||
, key: conf.ipcKey |
|||
}; |
|||
try { |
|||
require('fs').unlinkSync(memstoreOpts.sock); |
|||
} catch(e) { |
|||
if ('ENOENT' !== e.code) { |
|||
console.error(e.stack); |
|||
console.error(JSON.stringify(e)); |
|||
} |
|||
// ignore
|
|||
} |
|||
|
|||
var cstore = require('cluster-store'); |
|||
var memstorePromise = cstore.create(memstoreOpts).then(function (_memstore) { |
|||
memstore = _memstore; |
|||
}); |
|||
|
|||
// TODO check the IP every 5 minutes and update it every hour
|
|||
setInterval(updateIps, 60 * 60 * 1000); |
|||
// we don't want this to load right away (extra procesing time)
|
|||
setTimeout(updateIps, 1); |
|||
|
|||
return memstorePromise; |
|||
} |
|||
|
|||
function touch(conf, state) { |
|||
if (!state.initialize) { |
|||
state.initialize = init(conf, state); |
|||
} |
|||
|
|||
// TODO if no xyz worker, start on xyz worker (unlock, for example)
|
|||
return state.initialize.then(function () { |
|||
// TODO conf.locked = true|false;
|
|||
conf.initialized = true; |
|||
return conf; |
|||
}); |
|||
|
|||
/* |
|||
setInterval(function () { |
|||
console.log('SIGUSR1 to caddy'); |
|||
return caddy.update(caddyConf); |
|||
}, 10 * 60 * 1000); |
|||
*/ |
|||
} |
|||
|
|||
//var config = require('./device.json');
|
|||
|
|||
// require('ssl-root-cas').inject();
|
|||
|
|||
/* |
|||
function phoneHome() { |
|||
var holepunch = require('./holepunch/beacon'); |
|||
var ports; |
|||
|
|||
ports = [ |
|||
{ private: 65022 |
|||
, public: 65022 |
|||
, protocol: 'tcp' |
|||
, ttl: 0 |
|||
, test: { service: 'ssh' } |
|||
, testable: false |
|||
} |
|||
, { private: 650443 |
|||
, public: 650443 |
|||
, protocol: 'tcp' |
|||
, ttl: 0 |
|||
, test: { service: 'https' } |
|||
} |
|||
, { private: 65080 |
|||
, public: 65080 |
|||
, protocol: 'tcp' |
|||
, ttl: 0 |
|||
, test: { service: 'http' } |
|||
} |
|||
]; |
|||
|
|||
// TODO return a middleware
|
|||
holepunch.run(require('./redirects.json').reduce(function (all, redirect) { |
|||
if (!all[redirect.from.hostname]) { |
|||
all[redirect.from.hostname] = true; |
|||
all.push(redirect.from.hostname); |
|||
} |
|||
if (!all[redirect.to.hostname]) { |
|||
all[redirect.to.hostname] = true; |
|||
all.push(redirect.to.hostname); |
|||
} |
|||
|
|||
return all; |
|||
}, []), ports).catch(function () { |
|||
console.error("Couldn't phone home. Oh well"); |
|||
}); |
|||
} |
|||
*/ |
|||
|
|||
module.exports.init = init; |
|||
module.exports.touch = touch; |
@ -1,116 +1,44 @@ |
|||
'use strict'; |
|||
|
|||
module.exports.create = function (certPaths, securePort, promiseApp) { |
|||
var https = require('https'); |
|||
// there are a few things that must exist on every core anyway
|
|||
// Note the odd use of callbacks here.
|
|||
// We're targetting low-power platforms and so we're trying to
|
|||
// require everything as lazily as possible until our server
|
|||
// is actually listening on the socket. Bluebird is heavy.
|
|||
// Even the built-in modules can take dozens of milliseconds to require
|
|||
module.exports.create = function (certPaths, serverCallback) { |
|||
// Recognize that this secureContexts cache is local to this CPU core
|
|||
var secureContexts = {}; |
|||
|
|||
function loadCerts(domainname, prevdomainname) { |
|||
var PromiseA = require('bluebird'); |
|||
var fs = PromiseA.promisifyAll(require('fs')); |
|||
var path = require('path'); |
|||
|
|||
if (secureContexts[domainname]) { |
|||
return PromiseA.resolve(secureContexts[domainname]); |
|||
} |
|||
|
|||
return PromiseA.some(certPaths.map(function (pathname) { |
|||
return PromiseA.all([ |
|||
fs.readFileAsync(path.join(pathname, domainname, 'privkey.pem'), 'ascii') |
|||
, fs.readFileAsync(path.join(pathname, domainname, 'fullchain.pem'), 'ascii') |
|||
]); |
|||
}), 1).then(function (some) { |
|||
var one = some[0]; |
|||
secureContexts[domainname] = require('tls').createSecureContext({ |
|||
key: one[0] |
|||
, cert: one[1] |
|||
// https://hynek.me/articles/hardening-your-web-servers-ssl-ciphers/
|
|||
// https://nodejs.org/api/tls.html
|
|||
// removed :ECDH+AES256:DH+AES256 and added :!AES256 because AES-256 wastes CPU
|
|||
, ciphers: 'ECDH+AESGCM:DH+AESGCM:ECDH+AES128:DH+AES:ECDH+3DES:DH+3DES:RSA+AESGCM:RSA+AES:RSA+3DES:!aNULL:!MD5:!DSS:!AES256' |
|||
, honorCipherOrder: true |
|||
}); |
|||
|
|||
// guard against race condition on Promise.some
|
|||
if (prevdomainname && !secureContexts[prevdomainname]) { |
|||
// TODO XXX make sure that letsencrypt www. domains handle the bare domains also (and vice versa)
|
|||
secureContexts[prevdomainname] = secureContexts[domainname]; |
|||
} |
|||
|
|||
return secureContexts[domainname]; |
|||
}, function (/*err*/) { |
|||
// AggregateError means both promises failed
|
|||
// TODO check ENOENT
|
|||
|
|||
// test "is this server <<domainname>>?"
|
|||
// try letsencrypt
|
|||
// fail with www.example.com
|
|||
if (/^www\./i.test(domainname)) { |
|||
return loadCerts(domainname.replace(/^www\./i, ''), domainname); |
|||
} |
|||
|
|||
return (secureContexts['www.example.com'] || secureContexts['example.com']); |
|||
}).then(function (ctx) { |
|||
// TODO generate some self-signed certs?
|
|||
if (!ctx) { |
|||
console.error("[loadCerts()] Could not load default HTTPS certificates!!!"); |
|||
return PromiseA.reject({ |
|||
message: "No default certificates for https" |
|||
, code: 'E_NO_DEFAULT_CERTS' |
|||
}); |
|||
} |
|||
|
|||
return ctx; |
|||
}); |
|||
} |
|||
|
|||
function createSecureServer() { |
|||
return loadCerts('www.example.com').then(function (secureOpts) { |
|||
|
|||
//SNICallback is passed the domain name, see NodeJS docs on TLS
|
|||
secureOpts.SNICallback = function (domainname, cb) { |
|||
if (/(^|\.)proxyable\./.test(domainname)) { |
|||
// device-id-12345678.proxyable.myapp.mydomain.com => myapp.mydomain.com
|
|||
// proxyable.myapp.mydomain.com => myapp.mydomain.com
|
|||
// TODO myapp.mydomain.com.proxyable.com => myapp.mydomain.com
|
|||
domainname = domainname.replace(/.*\.?proxyable\./, ''); |
|||
} |
|||
|
|||
loadCerts(domainname).then(function (context) { |
|||
cb(null, context); |
|||
}, function (err) { |
|||
console.error('[SNI Callback]'); |
|||
console.error(err.stack); |
|||
cb(err); |
|||
}); |
|||
}; |
|||
|
|||
return https.createServer(secureOpts); |
|||
}); |
|||
} |
|||
|
|||
return createSecureServer().then(function (secureServer) { |
|||
var PromiseA = require('bluebird'); |
|||
|
|||
return new PromiseA(function (resolve, reject) { |
|||
secureServer.on('error', reject); |
|||
secureServer.listen(securePort, function () { |
|||
resolve(secureServer); |
|||
var domainname = 'www.example.com'; |
|||
var fs = require('fs'); |
|||
var secureOpts = { |
|||
// TODO create backup file just in case this one is ever corrupted
|
|||
// NOTE synchronous is faster in this case of initialization
|
|||
// NOTE certsPath[0] must be the default (LE) directory (another may be used for OV and EV certs)
|
|||
key: fs.readFileSync(certPaths[0] + '/' + domainname + '/privkey.pem', 'ascii') |
|||
, cert: fs.readFileSync(certPaths[0] + '/' + domainname + '/fullchain.pem', 'ascii') |
|||
// https://hynek.me/articles/hardening-your-web-servers-ssl-ciphers/
|
|||
// https://nodejs.org/api/tls.html
|
|||
// removed :ECDH+AES256:DH+AES256 and added :!AES256 because AES-256 wastes CPU
|
|||
, ciphers: 'ECDH+AESGCM:DH+AESGCM:ECDH+AES128:DH+AES:ECDH+3DES:DH+3DES:RSA+AESGCM:RSA+AES:RSA+3DES:!aNULL:!MD5:!DSS:!AES256' |
|||
, honorCipherOrder: true |
|||
}; |
|||
|
|||
//SNICallback is passed the domain name, see NodeJS docs on TLS
|
|||
secureOpts.SNICallback = function (domainname, cb) { |
|||
// NOTE: '*.proxyable.*' domains will be truncated
|
|||
require('./load-certs').load(secureContexts, certPaths, domainname).then(function (context) { |
|||
cb(null, context); |
|||
}, function (err) { |
|||
console.error('[SNI Callback]'); |
|||
console.error(err.stack); |
|||
cb(err); |
|||
}); |
|||
}; |
|||
|
|||
// Get up and listening as absolutely quickly as possible
|
|||
secureServer.on('request', function (req, res) { |
|||
if (/(^|\.)proxyable\./.test(req.headers.host)) { |
|||
// device-id-12345678.proxyable.myapp.mydomain.com => myapp.mydomain.com
|
|||
// proxyable.myapp.mydomain.com => myapp.mydomain.com
|
|||
// TODO myapp.mydomain.com.proxyable.com => myapp.mydomain.com
|
|||
req.headers.host = req.headers.host.replace(/.*\.?proxyable\./, ''); |
|||
} |
|||
serverCallback(null, require('https').createServer(secureOpts)); |
|||
} |
|||
|
|||
promiseApp().then(function (app) { |
|||
app(req, res); |
|||
}); |
|||
}); |
|||
}); |
|||
}); |
|||
createSecureServer(); |
|||
}; |
|||
|
@ -0,0 +1,119 @@ |
|||
'use strict'; |
|||
|
|||
module.exports.create = function (webserver, info) { |
|||
var path = require('path'); |
|||
var vhostsdir = path.join(__dirname, 'vhosts'); |
|||
var app = require('express')(); |
|||
var apiHandler; |
|||
|
|||
/* |
|||
function unlockDevice(conf, state) { |
|||
return require('./lib/unlock-device').create().then(function (result) { |
|||
result.promise.then(function (_rootMasterKey) { |
|||
process.send({ |
|||
type: 'com.daplie.walnut.keys.root' |
|||
conf: { |
|||
rootMasterKey: _rootMasterkey |
|||
} |
|||
}); |
|||
conf.locked = false; |
|||
if (state.caddy) { |
|||
state.caddy.update(conf); |
|||
} |
|||
conf.rootMasterKey = _rootMasterKey; |
|||
}); |
|||
|
|||
return result.app; |
|||
}); |
|||
} |
|||
*/ |
|||
|
|||
function scrubTheDubHelper(req, res/*, next*/) { |
|||
// hack for bricked app-cache
|
|||
if (/\.appcache\b/.test(req.url)) { |
|||
res.setHeader('Content-Type', 'text/cache-manifest'); |
|||
res.end('CACHE MANIFEST\n\n# v0__DELETE__CACHE__MANIFEST__\n\nNETWORK:\n*'); |
|||
return; |
|||
} |
|||
|
|||
// TODO port number for non-443
|
|||
var escapeHtml = require('escape-html'); |
|||
var newLocation = 'https://' + req.hostname.replace(/^www\./, '') + req.url; |
|||
var safeLocation = escapeHtml(newLocation); |
|||
|
|||
var metaRedirect = '' |
|||
+ '<html>\n' |
|||
+ '<head>\n' |
|||
+ ' <style>* { background-color: white; color: white; text-decoration: none; }</style>\n' |
|||
+ ' <META http-equiv="refresh" content="0;URL=' + safeLocation + '">\n' |
|||
+ '</head>\n' |
|||
+ '<body style="display: none;">\n' |
|||
+ ' <p>You requested an old resource. Please use this instead: \n' |
|||
+ ' <a href="' + safeLocation + '">' + safeLocation + '</a></p>\n' |
|||
+ '</body>\n' |
|||
+ '</html>\n' |
|||
; |
|||
|
|||
// 301 redirects will not work for appcache
|
|||
res.end(metaRedirect); |
|||
} |
|||
|
|||
function scrubTheDub(req, res, next) { |
|||
var host = req.hostname; |
|||
|
|||
if (!host || 'string' !== typeof host) { |
|||
next(); |
|||
return; |
|||
} |
|||
host = host.toLowerCase(); |
|||
|
|||
if (/^www\./.test(host)) { |
|||
scrubTheDubHelper(req, res, next); |
|||
return; |
|||
} |
|||
} |
|||
|
|||
function handleApi(req, res, next) { |
|||
if (!/^\/api/.test(req.url)) { |
|||
next(); |
|||
return; |
|||
} |
|||
|
|||
// TODO move to caddy parser?
|
|||
if (/(^|\.)proxyable\./.test(req.hostname)) { |
|||
// device-id-12345678.proxyable.myapp.mydomain.com => myapp.mydomain.com
|
|||
// proxyable.myapp.mydomain.com => myapp.mydomain.com
|
|||
// TODO myapp.mydomain.com.daplieproxyable.com => myapp.mydomain.com
|
|||
req.hostname = req.hostname.replace(/.*\.?proxyable\./, ''); |
|||
} |
|||
|
|||
if (apiHandler) { |
|||
if (apiHandler.then) { |
|||
apiHandler.then(function (app) { |
|||
app(req, res, next); |
|||
}); |
|||
return; |
|||
} |
|||
|
|||
apiHandler(req, res, next); |
|||
return; |
|||
} |
|||
|
|||
apiHandler = require('./vhost-server').create(info.localPort, vhostsdir).create(webserver, app).then(function (app) { |
|||
// X-Forwarded-For
|
|||
// X-Forwarded-Proto
|
|||
console.log('api server', req.hostname, req.secure, req.ip); |
|||
apiHandler = app; |
|||
app(req, res, next); |
|||
}); |
|||
} |
|||
|
|||
if (info.trustProxy) { |
|||
app.set('trust proxy', ['loopback']); |
|||
//app.set('trust proxy', function (ip) { ... });
|
|||
} |
|||
app.use('/', scrubTheDub); |
|||
app.use('/', handleApi); |
|||
|
|||
return app; |
|||
}; |
Loading…
Reference in new issue