serve-https
Version:
Serves HTTPS using TLS (SSL) certs for localhost.daplie.me - great for testing and development.
613 lines (528 loc) • 19.1 kB
JavaScript
;
//var PromiseA = global.Promise;
var PromiseA = require('bluebird');
var tls = require('tls');
var https = require('httpolyglot');
var http = require('http');
var proxyaddr = require('proxy-addr');
var fs = require('fs');
var path = require('path');
var DDNS = require('ddns-cli');
var enableDestroy = require('server-destroy');
var httpPort = 80;
var httpsPort = 443;
var lrPort = 35729;
var portFallback = 8443;
var insecurePortFallback = 4080;
function showError(err, port) {
if ('EACCES' === err.code) {
console.error(err);
console.warn("You do not have permission to use '" + port + "'.");
console.warn("You can probably fix that by running as Administrator or root.");
}
else if ('EADDRINUSE' === err.code) {
console.warn("Another server is already running on '" + port + "'.");
console.warn("You can probably fix that by rebooting your computer (or stopping it if you know what it is).");
}
}
function createInsecureServer(port, _delete_me_, opts, onRequest) {
return new PromiseA(function (realResolve) {
var server = http.createServer();
enableDestroy(server);
function resolve() {
realResolve(server);
}
server.on('error', function (err) {
if (opts.errorInsecurePort || opts.manualInsecurePort) {
showError(err, port);
process.exit(1);
return;
}
opts.errorInsecurePort = err.toString();
return createInsecureServer(insecurePortFallback, null, opts, onRequest).then(realResolve);
});
server.on('request', onRequest);
server.listen(port, function () {
opts.insecurePort = port;
resolve();
});
});
}
function createServerHelper(port, content, opts, lex) {
var insecureServer;
return new PromiseA(function (realResolve) {
function resolve() {
realResolve({
plainServer: insecureServer
, server: server
});
}
function proxySaysSecure(req) {
if (!proxyaddr(req, opts.isProxyTrusted)) {
return false;
}
return 'https' === req.headers['x-forwarded-proto']
|| -1 !== (req.headers.forwarded || '').toLowerCase().indexOf(' proto=https')
|| 'on' === req.headers['x-forwarded-ssl']
//|| 'https' === req.headers['x-forwarded-protocol']
//|| 'on' === req.headers['front-end-https']
;
/*
// Standard
Forwarded: by=<identifier>; for=<identifier>; host=<host>; proto=<http|https>
// Microsoft
Front-End-Https: on
// Misc
X-Forwarded-Protocol: https
X-Forwarded-Ssl: on
X-Url-Scheme: https
*/
}
function onRequest(req, res) {
console.log('onRequest [' + req.method + '] ' + req.url);
if (!req.socket.encrypted && !proxySaysSecure(req) && !/\/\.well-known\/acme-challenge\//.test(req.url)) {
opts.redirectApp(req, res);
return;
}
lex.middleware(function (req, res) {
if ('function' === typeof opts._app) {
opts._app(req, res);
return;
}
res.end('app not loaded');
})(req, res);
}
function onListen() {
opts.port = port;
opts.redirectOptions.port = port;
if (opts.livereload) {
opts.lrPort = opts.lrPort || lrPort;
var livereload = require('livereload');
var server2 = livereload.createServer({
https: opts.httpsOptions
, port: opts.lrPort
, exclusions: [ 'node_modules' ]
});
console.info("[livereload] watching " + opts.pubdir);
console.warn("WARNING: If CPU usage spikes to 100% it's because too many files are being watched");
// TODO create map of directories to watch from opts.sites and iterate over it
server2.watch(opts.pubdir);
server.on('close', function () {
server2.close();
});
}
// if we haven't disabled insecure port, and the insecure and secure ports are different
if ('false' !== opts.insecurePort && opts.port !== opts.insecurePort) {
// Only fire up the insecure server if the user specified neither or both ports
if (opts.manualInsecurePort || !opts.manualPort) {
return createInsecureServer(opts.insecurePort, null, opts, onRequest).then(function (_server) {
insecureServer = _server;
resolve();
});
}
}
opts.insecurePort = opts.port;
resolve();
return;
}
function onError(err) {
if (opts.errorPort || opts.manualPort) {
showError(err, port);
process.exit(1);
return;
}
opts.errorPort = err.toString();
return createServerHelper(portFallback, content, opts, lex).then(realResolve);
}
var server = https.createServer(opts.httpsOptions);
enableDestroy(server);
server.on('error', onError);
server.listen(port, onListen);
server.on('request', onRequest);
});
}
function createServer(port, _delete_me_, content, opts) {
function approveDomains(params, certs, cb) {
// This is where you check your database and associated
// email addresses with domains and agreements and such
var domains = params.domains;
//var p;
console.log('approveDomains');
console.log(domains);
// The domains being approved for the first time are listed in opts.domains
// Certs being renewed are listed in certs.altnames
if (certs) {
params.domains = certs.altnames;
//p = PromiseA.resolve();
}
else {
//params.email = opts.email;
if (!opts.agreeTos) {
console.error("You have not previously registered '" + domains + "' so you must specify --agree-tos to agree to both the Let's Encrypt and Daplie DNS terms of service.");
process.exit(1);
return;
}
params.agreeTos = opts.agreeTos;
}
// ddns.token(params.email, domains[0])
params.email = opts.email;
params.refreshToken = opts.refreshToken;
//params.challengeType = 'http-01';
//params.challengeType = 'dns-01';
params.cli = opts.argv;
cb(null, { options: params, certs: certs });
}
var app = require('../lib/app.js');
var directive = {
content: content
, livereload: opts.livereload
, sites: opts.sites
, expressApp: opts.expressApp
};
// returns an instance of node-letsencrypt with additional helper methods
var webrootPath = require('os').tmpdir();
var leChallengeFs = require('le-challenge-fs').create({ webrootPath: webrootPath });
//var leChallengeSni = require('le-challenge-sni').create({ webrootPath: webrootPath });
var leChallengeDdns = require('le-challenge-ddns').create({ ttl: 1 });
var oldConfigDir = path.join((opts.homedir || '~'), 'letsencrypt', 'etc');
var defaultConfigDir = path.join((opts.homedir || '~'), 'acme', 'etc');
if (fs.existsSync(oldConfigDir)) {
defaultConfigDir = oldConfigDir;
}
opts.configDir = opts.configDir || defaultConfigDir;
var lex = require('greenlock-express').create({
// set to https://acme-v01.api.letsencrypt.org/directory in production
server: opts.test ? 'staging' : 'https://acme-v01.api.letsencrypt.org/directory'
// If you wish to replace the default plugins, you may do so here
//
, challenges: {
'http-01': leChallengeFs
, 'tls-sni-01': leChallengeFs // leChallengeSni
, 'dns-01': leChallengeDdns
}
, challengeType: opts.challengeType || (opts.tunnel ? 'http-01' : 'dns-01')
//, challengeType: 'http-01'
//, challengeType: 'dns-01'
, store: require('le-store-certbot').create({
webrootPath: webrootPath
, configDir: opts.configDir
, homedir: opts.homedir
})
, webrootPath: webrootPath
// You probably wouldn't need to replace the default sni handler
// See https://git.daplie.com/Daplie/le-sni-auto if you think you do
//, sni: require('le-sni-auto').create({})
, approveDomains: approveDomains
});
var secureContexts = {
'localhost.daplie.me': null
};
opts.httpsOptions.SNICallback = function (sni, cb ) {
var tlsOptions;
console.log('[https] sni', sni);
// Static Certs
if (/.*localhost.*\.daplie\.me/.test(sni.toLowerCase())) {
// TODO implement
if (!secureContexts[sni]) {
tlsOptions = require('localhost.daplie.me-certificates').mergeTlsOptions(sni, {});
}
if (tlsOptions) {
secureContexts[sni] = tls.createSecureContext(tlsOptions);
}
cb(null, secureContexts[sni]);
return;
}
// Dynamic Certs
lex.httpsOptions.SNICallback(sni, cb);
};
if ('function' === typeof app) {
app = app(directive);
} else if ('function' === typeof app.create) {
app = app.create(directive);
}
opts._app = app;
return createServerHelper(port, content, opts, lex);
}
module.exports.createServer = createServer;
function run() {
var defaultServername = 'localhost.daplie.me';
var minimist = require('minimist');
var argv = minimist(process.argv.slice(2));
var port = parseInt(argv.p || argv.port || argv._[0], 10) || httpsPort;
var defaultWebRoot = path.resolve(argv['default-web-root'] || argv.d || argv._[1] || process.cwd());
var content = argv.c;
var letsencryptHost = argv['letsencrypt-certs'];
if (argv.V || argv.version || argv.v) {
if (argv.v) {
console.warn("flag -v is reserved for future use. Use -V or --version for version information.");
}
console.info('v' + require('../package.json').version);
return;
}
if (argv.servername && argv.sites) {
throw new Error('specify only --sites, not --servername');
}
argv.sites = argv.sites || argv.servername;
// letsencrypt
var httpsOptions = require('localhost.daplie.me-certificates').merge({});
var secureContext;
var opts = {
agreeTos: argv.agreeTos || argv['agree-tos']
, challengeType: argv['challenge-type']
, debug: argv.debug
, test: argv.debug || argv.test || argv['dry-run'] || argv.dryrun
, device: argv.device
, provider: (argv.provider && 'false' !== argv.provider) ? argv.provider : 'oauth3.org'
, email: argv.email
, httpsOptions: {
key: httpsOptions.key
, cert: httpsOptions.cert
//, ca: httpsOptions.ca
}
, homedir: argv.homedir
, trustProxy: (argv['trust-proxy'] || argv['trust-proxies'] || 'loopback,linklocal,uniquelocal').split(/,/g)
, argv: argv
};
var peerCa;
var p;
opts.PromiseA = PromiseA;
opts.httpsOptions.SNICallback = function (sni, cb) {
if (!secureContext) {
secureContext = tls.createSecureContext(opts.httpsOptions);
}
cb(null, secureContext);
return;
};
if (letsencryptHost) {
// TODO remove in v3.x (aka goldilocks)
argv.key = argv.key || '/etc/letsencrypt/live/' + letsencryptHost + '/privkey.pem';
argv.cert = argv.cert || '/etc/letsencrypt/live/' + letsencryptHost + '/fullchain.pem';
argv.root = argv.root || argv.chain || '';
argv.sites = argv.sites || letsencryptHost;
argv['serve-root'] = argv['serve-root'] || argv['serve-chain'];
// argv[express-app]
}
if (argv['serve-root'] && !argv.root) {
console.error("You must specify bath --root to use --serve-root");
return;
}
if (argv.key || argv.cert || argv.root) {
if (!argv.key || !argv.cert) {
console.error("You must specify bath --key and --cert, and optionally --root (required with serve-root)");
return;
}
if (!Array.isArray(argv.root)) {
argv.root = [argv.root];
}
opts.httpsOptions.key = fs.readFileSync(argv.key);
opts.httpsOptions.cert = fs.readFileSync(argv.cert);
// turn multiple-cert pemfile into array of cert strings
peerCa = argv.root.reduce(function (roots, fullpath) {
if (!fs.existsSync(fullpath)) {
return roots;
}
return roots.concat(fs.readFileSync(fullpath, 'ascii')
.split('-----END CERTIFICATE-----')
.filter(function (ca) {
return ca.trim();
}).map(function (ca) {
return (ca + '-----END CERTIFICATE-----').trim();
}));
}, []);
// TODO * `--verify /path/to/root.pem` require peers to present certificates from said authority
if (argv.verify) {
opts.httpsOptions.ca = peerCa;
opts.httpsOptions.requestCert = true;
opts.httpsOptions.rejectUnauthorized = true;
}
if (argv['serve-root']) {
content = peerCa.join('\r\n');
}
}
opts.sites = [ { name: defaultServername , path: '.' } ];
if (argv.sites) {
opts._externalHost = false;
opts.sites = argv.sites.split(',').map(function (name) {
var nameparts = name.split('|');
var servername = nameparts.shift();
opts._externalHost = opts._externalHost || !/(^|\.)localhost\./.test(servername);
// TODO allow reverse proxy
return {
name: servername
// there should always be a path
, paths: nameparts.length && nameparts || [
defaultWebRoot.replace(/(:hostname|:servername)/g, servername)
]
// TODO check for existing custom path before issuing with greenlock
, _hasCustomPath: !!nameparts.length
};
});
}
// TODO use arrays in all things
opts._old_server_name = opts.sites[0].name;
opts.pubdir = defaultWebRoot.replace(/(:hostname|:servername).*/, '');
if (argv.p || argv.port || argv._[0]) {
opts.manualPort = true;
}
if (argv.t || argv.tunnel) {
opts.tunnel = true;
}
if (argv.i || argv['insecure-port']) {
opts.manualInsecurePort = true;
}
opts.insecurePort = parseInt(argv.i || argv['insecure-port'], 10)
|| argv.i || argv['insecure-port']
|| httpPort
;
opts.livereload = argv.livereload;
if (argv['express-app']) {
opts.expressApp = require(path.resolve(process.cwd(), argv['express-app']));
}
if (opts.email || opts._externalHost) {
if (!opts.agreeTos) {
console.warn("You may need to specify --agree-tos to agree to both the Let's Encrypt and Daplie DNS terms of service.");
}
if (!opts.email) {
// TODO store email in .ddnsrc.json
console.warn("You may need to specify --email to register with both the Let's Encrypt and Daplie DNS.");
}
p = DDNS.refreshToken({
email: opts.email
, providerUrl: opts.provider
, silent: true
, homedir: opts.homedir
}, {
debug: false
, email: opts.argv.email
}).then(function (refreshToken) {
opts.refreshToken = refreshToken;
});
}
else {
p = PromiseA.resolve();
}
return p.then(function () {
// can be changed to tunnel external port
opts.redirectOptions = { port: opts.port };
opts.isProxyTrusted = proxyaddr.compile(opts.trustProxy || []);
opts.redirectApp = require('redirect-https')(opts.redirectOptions);
return createServer(port, null, content, opts).then(function (servers) {
var httpsUrl;
var httpUrl;
var promise;
// TODO show all sites
console.info('');
console.info('Serving ' + opts.pubdir + ' at ');
console.info('');
// Port
httpsUrl = 'https://' + opts._old_server_name;
if (httpsPort !== opts.port) {
httpsUrl += ':' + opts.port;
}
console.info('\t' + httpsUrl);
// Insecure Port
httpUrl = 'http://' + opts._old_server_name;
if (httpPort !== opts.insecurePort) {
httpUrl += ':' + opts.insecurePort;
}
console.info('\t' + httpUrl + ' (redirecting to https)');
console.info('');
if (!(argv.sites && (defaultServername !== argv.sites) && !(argv.key && argv.cert))) {
// TODO what is this condition actually intending to test again?
// (I think it can be replaced with if (!opts._externalHost) { ... }
// ifaces
opts.ifaces = require('../lib/local-ip.js').find();
promise = PromiseA.resolve();
} else {
console.info("Attempting to resolve external connection for '" + opts._old_server_name + "'");
try {
promise = require('../lib/match-ips.js').match(opts._old_server_name, opts);
} catch(e) {
console.warn("Upgrade to version 2.x to use automatic certificate issuance for '" + opts._old_server_name + "'");
promise = PromiseA.resolve();
}
}
return promise.then(function (matchingIps) {
if (matchingIps) {
if (!matchingIps.length) {
console.info("Neither the attached nor external interfaces match '" + opts._old_server_name + "'");
}
}
opts.matchingIps = matchingIps || [];
if (opts.matchingIps.length) {
console.info('');
console.info('External IPs:');
console.info('');
opts.matchingIps.forEach(function (ip) {
if ('IPv4' === ip.family) {
httpsUrl = 'https://' + ip.address;
if (httpsPort !== opts.port) {
httpsUrl += ':' + opts.port;
}
console.info('\t' + httpsUrl);
}
else {
httpsUrl = 'https://[' + ip.address + ']';
if (httpsPort !== opts.port) {
httpsUrl += ':' + opts.port;
}
console.info('\t' + httpsUrl);
}
});
}
else if (!opts.tunnel) {
console.info("External IP address does not match local IP address.");
console.info("Use --tunnel to allow the people of the Internet to access your server.");
console.info("(or, if you know that you're using something like caddy or nginx to reverse proxy: keep calm and proxy on)");
}
if (opts.tunnel) {
require('../lib/tunnel.js').create(opts).then(function (tunnel) {
servers.tunnel = tunnel;
});
}
else if (opts.ddns) {
require('../lib/ddns.js').create(opts);
}
function sigHandler() {
console.log('SIGINT');
// 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);
if (servers.tunnel) {
servers.tunnel.end();
}
if (servers.plainServer) {
servers.plainServer.destroy();
}
servers.server.destroy();
}
process.on('SIGINT', sigHandler);
Object.keys(opts.ifaces).forEach(function (iname) {
var iface = opts.ifaces[iname];
if (iface.ipv4.length) {
console.info('');
console.info(iname + ':');
httpsUrl = 'https://' + iface.ipv4[0].address;
if (httpsPort !== opts.port) {
httpsUrl += ':' + opts.port;
}
console.info('\t' + httpsUrl);
if (iface.ipv6.length) {
httpsUrl = 'https://[' + iface.ipv6[0].address + ']';
if (httpsPort !== opts.port) {
httpsUrl += ':' + opts.port;
}
console.info('\t' + httpsUrl);
}
}
});
console.info('');
});
});
});
}
if (require.main === module) {
run();
}