diff --git a/.gitignore b/.gitignore index bc7fc55..9b5d38b 100644 --- a/.gitignore +++ b/.gitignore @@ -30,6 +30,7 @@ build/Release # Dependency directories node_modules jspm_packages +bower_components # Optional npm cache directory .npm @@ -42,3 +43,7 @@ jspm_packages # Output of 'npm pack' *.tgz + +# Dependency directory +# https://www.npmjs.org/doc/misc/npm-faq.html#should-i-check-my-node_modules-folder-into-git +node_modules diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..8f71f43 --- /dev/null +++ b/LICENSE @@ -0,0 +1,202 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "{}" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright {yyyy} {name of copyright owner} + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + diff --git a/README.md b/README.md index 799cfd6..74313de 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,161 @@ + + +About Daplie: We're taking back the Internet! +-------------- + +Down with Google, Apple, and Facebook! + +We're re-decentralizing the web and making it read-write again - one home cloud system at a time. + +Tired of serving the Empire? Come join the Rebel Alliance: + +jobs@daplie.com | [Invest in Daplie on Wefunder](https://daplie.com/invest/) | [Pre-order Cloud](https://daplie.com/preorder/), The World's First Home Server for Everyone + + + Goldilocks ========== -The webserver that's just right. \ No newline at end of file +The node.js webserver that's just right. + + +A simple HTTPS static file server with valid TLS (SSL) certs. + +Comes bundled a valid certificate for localhost.daplie.me, +which is great for testing and development, and you can specify your own. + +Also great for testing ACME certs from letsencrypt.org. + +Install +------- + +```bash +# v2 in npm +npm install -g goldilocks + +# master in git (via ssh) +npm install -g git+ssh://git@git.daplie.com:Daplie/goldilocks.js + +# master in git (unauthenticated) +npm install -g git+https://git@git.daplie.com:Daplie/goldilocks.js +``` + +```bash +goldilocks +``` + +```bash +Serving /Users/foo/ at https://localhost.daplie.me:8443 +``` + +Usage +----- + +Examples: + +``` +# Install +npm install -g git+https://git@git.daplie.com:Daplie/goldilocks.js + +# Use tunnel +goldilocks --sites jane.daplie.me --agree-tos --email jane@example.com --tunnel + +# BEFORE you access in a browser for the first time, use curl +# (because there's a concurrency bug in the greenlock setup) +curl https://jane.daplie.me +``` + +Options: + +* `-p ` - i.e. `sudo goldilocks -p 443` (defaults to 80+443 or 8443) +* `-d ` - i.e. `goldilocks -d /tmp/` (defaults to `pwd`) + * you can use `:hostname` as a template for multiple directories + * Example A: `goldilocks -d /srv/www/:hostname --sites localhost.foo.daplie.me,localhost.bar.daplie.me` + * Example B: `goldilocks -d ./:hostname/public/ --sites localhost.foo.daplie.me,localhost.bar.daplie.me` +* `-c ` - i.e. `server-https -c 'Hello, World! '` (defaults to directory index) +* `--express-app ` - path to a file the exports an express-style app (`function (req, res, next) { ... }`) +* `--livereload` - inject livereload into all html pages (see also: [fswatch](http://stackoverflow.com/a/13807906/151312)), but be careful if `` has thousands of files it will spike your CPU usage to 100% + +* `--email ` - email to use for Let's Encrypt, Daplie DNS, Daplie Tunnel +* `--agree-tos` - agree to terms for Let's Encrypt, Daplie DNS +* `--sites ` comma-separated list of domains to respond to (default is `localhost.daplie.me`) + * optionally you may include the path to serve with `|` such as `example.com|/tmp,example.net/srv/www` +* `--tunnel` - make world-visible (must use `--sites`) + +Specifying a custom HTTPS certificate: + +* `--key /path/to/privkey.pem` specifies the server private key +* `--cert /path/to/fullchain.pem` specifies the bundle of server certificate and all intermediate certificates +* `--root /path/to/root.pem` specifies the certificate authority(ies) + +Note: `--root` may specify single cert or a bundle, and may be used multiple times like so: + +``` +--root /path/to/primary-root.pem --root /path/to/cross-root.pem +``` + +Other options: + +* `--serve-root true` alias for `-c` with the contents of root.pem +* `--sites example.com` changes the servername logged to the console +* `--letsencrypt-certs example.com` sets and key, fullchain, and root to standard letsencrypt locations + +Examples +-------- + +```bash +goldilocks -p 1443 -c 'Hello from 1443' & +goldilocks -p 2443 -c 'Hello from 2443' & +goldilocks -p 3443 -d /tmp & + +curl https://localhost.daplie.me:1443 +> Hello from 1443 + +curl --insecure https://localhost:2443 +> Hello from 2443 + +curl https://localhost.daplie.me:3443 +> [html index listing of /tmp] +``` + +And if you tested in a browser, +it would redirect to (on the same port). + +(in curl it would just show an error message) + +### Testing ACME Let's Encrypt certs + +In case you didn't know, you can get free https certificates from +[letsencrypt.org](https://letsencrypt.org) +(ACME letsencrypt) +and even a free subdomain from . + +If you want to quickly test the certificates you installed, +you can do so like this: + +```bash +goldilocks -p 8443 \ + --letsencrypt-certs test.mooo.com \ + --serve-root true +``` + +which is equilavent to + +```bash +goldilocks -p 8443 \ + --sites test.mooo.com + --key /etc/letsencrypt/live/test.mooo.com/privkey.pem \ + --cert /etc/letsencrypt/live/test.mooo.com/fullchain.pem \ + --root /etc/letsencrypt/live/test.mooo.com/root.pem \ + -c "$(cat 'sudo /etc/letsencrypt/live/test.mooo.com/root.pem')" +``` + +and can be tested like so + +```bash +curl --insecure https://test.mooo.com:8443 > ./root.pem +curl https://test.mooo.com:8843 --cacert ./root.pem +``` + +* [QuickStart Guide for Let's Encrypt](https://coolaj86.com/articles/lets-encrypt-on-raspberry-pi/) +* [QuickStart Guide for FreeDNS](https://coolaj86.com/articles/free-dns-hosting-with-freedns-afraid-org.html) diff --git a/bin/goldilocks.js b/bin/goldilocks.js new file mode 100755 index 0000000..b5adef5 --- /dev/null +++ b/bin/goldilocks.js @@ -0,0 +1,543 @@ +#!/usr/bin/env node +'use strict'; + +//var PromiseA = global.Promise; +var PromiseA = require('bluebird'); +var tls = require('tls'); +var https = require('httpolyglot'); +var http = require('http'); +var fs = require('fs'); +var path = require('path'); +var DDNS = require('ddns-cli'); +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) { + return new PromiseA(function (realResolve) { + var server = http.createServer(); + + 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).then(resolve); + }); + + server.on('request', opts.redirectApp); + + server.listen(port, function () { + opts.insecurePort = port; + resolve(); + }); + }); +} + +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 = 'dns-01'; + params.cli = opts.argv; + + cb(null, { options: params, certs: certs }); + } + + return new PromiseA(function (realResolve) { + var app = require('../lib/app.js'); + + var directive = { content: content, livereload: opts.livereload + , sites: opts.sites + , expressApp: opts.expressApp }; + var insecureServer; + + function resolve() { + realResolve({ + plainServer: insecureServer + , server: server + }); + } + + // 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 lex = require('greenlock-express').create({ + // set to https://acme-v01.api.letsencrypt.org/directory in production + server: opts.debug ? '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.tunnel ? 'http-01' : 'dns-01') + , store: require('le-store-certbot').create({ + webrootPath: webrootPath + , configDir: path.join((opts.homedir || '~'), 'letsencrypt', 'etc') + , 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); + }; + var server = https.createServer(opts.httpsOptions); + + server.on('error', function (err) { + if (opts.errorPort || opts.manualPort) { + showError(err, port); + process.exit(1); + return; + } + + opts.errorPort = err.toString(); + + return createServer(portFallback, null, content, opts).then(resolve); + }); + + server.listen(port, function () { + 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); + } + + // if we haven't disabled insecure port + if ('false' !== opts.insecurePort) { + // and both ports are the default + if ((httpsPort === opts.port && httpPort === opts.insecurePort) + // or other case + || (httpPort !== opts.insecurePort && opts.port !== opts.insecurePort) + ) { + return createInsecureServer(opts.insecurePort, null, opts).then(function (_server) { + insecureServer = _server; + resolve(); + }); + } + } + + opts.insecurePort = opts.port; + resolve(); + return; + }); + + if ('function' === typeof app) { + app = app(directive); + } else if ('function' === typeof app.create) { + app = app.create(directive); + } + + server.on('request', function (req, res) { + console.log('[' + req.method + '] ' + req.url); + if (!req.socket.encrypted && !/\/\.well-known\/acme-challenge\//.test(req.url)) { + opts.redirectApp(req, res); + return; + } + + if ('function' === typeof app) { + app(req, res); + return; + } + + res.end('not ready'); + }); + + return PromiseA.resolve(app).then(function (_app) { + app = _app; + }); + }); +} + +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 livereload = argv.livereload; + 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'] + , debug: argv.debug + , 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 + , 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 = 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.redirectApp = require('redirect-https')(opts.redirectOptions); + + return createServer(port, null, content, opts).then(function (servers) { + var p; + 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; + p = opts.port; + if (httpsPort !== p) { + httpsUrl += ':' + p; + } + console.info('\t' + httpsUrl); + + // Insecure Port + httpUrl = 'http://' + opts._old_server_name; + p = opts.insecurePort; + if (httpPort !== p) { + httpUrl += ':' + p; + } + 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."); + } + + if (opts.tunnel) { + require('../lib/tunnel.js').create(opts, servers); + } + else if (opts.ddns) { + require('../lib/ddns.js').create(opts, servers); + } + + 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(); +} diff --git a/lib/app.js b/lib/app.js new file mode 100644 index 0000000..9169c95 --- /dev/null +++ b/lib/app.js @@ -0,0 +1,108 @@ +'use strict'; + +module.exports = function (opts) { + var finalhandler = require('finalhandler'); + var serveStatic = require('serve-static'); + var serveIndex = require('serve-index'); + + var hostsMap = {}; + var pathsMap = {}; + var content = opts.content; + var server; + + function addServer(hostname) { + + if (hostsMap[hostname]) { + return hostsMap[hostname]; + } + + opts.sites.forEach(function (site) { + if (hostname !== site.name) { + return; + } + + // path should exist before it gets to this point + site.path = site.path || site.paths[0]; + + if (!pathsMap[site.path]) { + pathsMap[site.path] = { + serve: serveStatic(site.path) + // TODO option for dotfiles + , index: serveIndex(site.path) + }; + } + + hostsMap[hostname] = { + serve: pathsMap[site.path].serve + , index: pathsMap[site.path].index + , app: site.app + }; + + }); + + } + + function _reloadWrite(data, enc, cb) { + /*jshint validthis: true */ + if (this.headersSent) { + this.__write(data, enc, cb); + return; + } + + if (!/html/i.test(this.getHeader('Content-Type'))) { + this.__write(data, enc, cb); + return; + } + + if (this.getHeader('Content-Length')) { + this.setHeader('Content-Length', this.getHeader('Content-Length') + this.__my_addLen); + } + + this.__write(this.__my_livereload); + this.__write(data, enc, cb); + } + + + addServer(opts.sites[0].name); + + return function (req, res) { + if (content && '/' === req.url) { + // res.setHeader('Content-Type', 'application/octet-stream'); + res.end(content); + return; + } + var done = finalhandler(req, res); + var host = req.headers.host; + var hostname = (host||'').split(':')[0] || opts.sites[0].name; + + function serveStatic(server) { + if (server.expressApp) { + server.expressApp(req, res, serveStatic); + return; + } + + server.serve(req, res, function (err) { + if (err) { return done(err); } + server.index(req, res, done); + }); + } + + if (opts.livereload) { + res.__my_livereload = ''; + res.__my_addLen = res.__my_livereload.length; + + // TODO modify prototype instead of each instance? + res.__write = res.write; + res.write = _reloadWrite; + } + + console.log('hostname:', hostname); + + addServer(hostname); + server = hostsMap[hostname] || hostsMap[opts.sites[0].name]; + serveStatic(server); + + }; +}; diff --git a/lib/ddns.js b/lib/ddns.js new file mode 100644 index 0000000..2ed1cca --- /dev/null +++ b/lib/ddns.js @@ -0,0 +1,88 @@ +'use strict'; + +module.exports.create = function (opts/*, servers*/) { + var PromiseA = opts.PromiseA; + var dns = PromiseA.promisifyAll(require('dns')); + + return PromiseA.all([ + dns.resolve4Async(opts._old_server_name).then(function (results) { + return results; + }, function () {}) + , dns.resolve6Async(opts._old_server_name).then(function (results) { + return results; + }, function () {}) + ]).then(function (results) { + var ipv4 = results[0] || []; + var ipv6 = results[1] || []; + var record; + + opts.dnsRecords = { + A: ipv4 + , AAAA: ipv6 + }; + + Object.keys(opts.ifaces).some(function (ifacename) { + var iface = opts.ifaces[ifacename]; + + return iface.ipv4.some(function (localIp) { + return ipv4.some(function (remoteIp) { + if (localIp.address === remoteIp) { + record = localIp; + return record; + } + }); + }) || iface.ipv6.some(function (localIp) { + return ipv6.forEach(function (remoteIp) { + if (localIp.address === remoteIp) { + record = localIp; + return record; + } + }); + }); + }); + + if (!record) { + console.info("DNS Record '" + ipv4.concat(ipv6).join(',') + "' does not match any local IP address."); + console.info("Use --ddns to allow the people of the Internet to access your server."); + } + + opts.externalIps.ipv4.some(function (localIp) { + return ipv4.some(function (remoteIp) { + if (localIp.address === remoteIp) { + record = localIp; + return record; + } + }); + }); + + opts.externalIps.ipv6.some(function (localIp) { + return ipv6.some(function (remoteIp) { + if (localIp.address === remoteIp) { + record = localIp; + return record; + } + }); + }); + + if (!record) { + console.info("DNS Record '" + ipv4.concat(ipv6).join(',') + "' does not match any local IP address."); + console.info("Use --ddns to allow the people of the Internet to access your server."); + } + }); +}; + +if (require.main === module) { + var opts = { + _old_server_name: 'aj.daplie.me' + , PromiseA: require('bluebird') + }; + // ifaces + opts.ifaces = require('./local-ip.js').find(); + console.log('opts.ifaces'); + console.log(opts.ifaces); + require('./match-ips.js').match(opts._old_server_name, opts).then(function (ips) { + opts.matchingIps = ips.matchingIps || []; + opts.externalIps = ips.externalIps; + module.exports.create(opts); + }); +} diff --git a/lib/local-ip.js b/lib/local-ip.js new file mode 100644 index 0000000..6b19a47 --- /dev/null +++ b/lib/local-ip.js @@ -0,0 +1,53 @@ +'use strict'; + +var os = require('os'); + +module.exports.find = function (opts) { + opts = opts || {}; + opts.externals = opts.externals || []; + + var ifaceMap = os.networkInterfaces(); + var newMap = {}; + + Object.keys(ifaceMap).forEach(function (iname) { + var ifaces = ifaceMap[iname]; + + ifaces = ifaces.filter(function (iface) { + return opts.externals.some(function (ip) { + if (ip.address === iface.address) { + ip.external = true; + return true; + } + }) || (!iface.internal && !/^fe80/.test(iface.address) && !/^[0:]+$/.test(iface.mac)); + }); + + if (!ifaces.length) { + return; + } + + newMap[iname] = newMap[iname] || { ipv4: [], ipv6: [] }; + + ifaces.forEach(function (addr) { + addr.iface = iname; + if ('IPv4' === addr.family) { + newMap[iname].ipv4.push(addr); + } + else if ('IPv6' === addr.family) { + newMap[iname].ipv6.push(addr); + } + }); + }); + + return newMap; + + /* +https://[2601:681:300:92c0:2477:d58a:d69e:51a0]:8443 + + console.log(''); + + console.log(''); + console.log(iname); + console.log(ifaces); + console.log(''); + */ +}; diff --git a/lib/match-ips.js b/lib/match-ips.js new file mode 100644 index 0000000..dbb3ff1 --- /dev/null +++ b/lib/match-ips.js @@ -0,0 +1,117 @@ +'use strict'; + +var PromiseA = require('bluebird'); + +module.exports.match = function (servername, opts) { + return PromiseA.promisify(require('ipify'))().then(function (externalIp) { + var dns = PromiseA.promisifyAll(require('dns')); + + opts.externalIps = [ { address: externalIp, family: 'IPv4' } ]; + opts.ifaces = require('./local-ip.js').find({ externals: opts.externalIps }); + opts.externalIfaces = Object.keys(opts.ifaces).reduce(function (all, iname) { + var iface = opts.ifaces[iname]; + + iface.ipv4.forEach(function (addr) { + if (addr.external) { + addr.iface = iname; + all.push(addr); + } + }); + iface.ipv6.forEach(function (addr) { + if (addr.external) { + addr.iface = iname; + all.push(addr); + } + }); + + return all; + }, []).filter(Boolean); + + function resolveIps(hostname) { + var allIps = []; + + return PromiseA.all([ + dns.resolve4Async(hostname).then(function (records) { + records.forEach(function (ip) { + allIps.push({ + address: ip + , family: 'IPv4' + }); + }); + }, function () {}) + , dns.resolve6Async(hostname).then(function (records) { + records.forEach(function (ip) { + allIps.push({ + address: ip + , family: 'IPv6' + }); + }); + }, function () {}) + ]).then(function () { + return allIps; + }); + } + + function resolveIpsAndCnames(hostname) { + return PromiseA.all([ + resolveIps(hostname) + , dns.resolveCnameAsync(hostname).then(function (records) { + return PromiseA.all(records.map(function (hostname) { + return resolveIps(hostname); + })).then(function (allIps) { + return allIps.reduce(function (all, ips) { + return all.concat(ips); + }, []); + }); + }, function () { + return []; + }) + ]).then(function (ips) { + return ips.reduce(function (all, set) { + return all.concat(set); + }, []); + }); + } + + return resolveIpsAndCnames(servername).then(function (allIps) { + var matchingIps = []; + + if (!allIps.length) { + console.warn("Could not resolve '" + servername + "'"); + } + + // { address, family } + allIps.some(function (ip) { + function match(addr) { + if (ip.address === addr.address) { + matchingIps.push(addr); + } + } + + opts.externalIps.forEach(match); + // opts.externalIfaces.forEach(match); + + Object.keys(opts.ifaces).forEach(function (iname) { + var iface = opts.ifaces[iname]; + + iface.ipv4.forEach(match); + iface.ipv6.forEach(match); + }); + + return matchingIps.length; + }); + + matchingIps.externalIps = { + ipv4: [ + { address: externalIp + , family: 'IPv4' + } + ] + , ipv6: [ + ] + }; + matchingIps.matchingIps = matchingIps; + return matchingIps; + }); + }); +}; diff --git a/lib/tunnel.js b/lib/tunnel.js new file mode 100644 index 0000000..a4ea58c --- /dev/null +++ b/lib/tunnel.js @@ -0,0 +1,144 @@ +'use strict'; + +module.exports.create = function (opts, servers) { + // servers = { plainserver, server } + var Oauth3 = require('oauth3-cli'); + var Tunnel = require('daplie-tunnel').create({ + Oauth3: Oauth3 + , PromiseA: opts.PromiseA + , CLI: { + init: function (rs, ws/*, state, options*/) { + // noop + return ws; + } + } + }).Tunnel; + var stunnel = require('stunnel'); + var killcount = 0; + + /* + var Dup = { + write: function (chunk, encoding, cb) { + this.__my_socket.push(chunk, encoding); + cb(); + } + , read: function (size) { + var x = this.__my_socket.read(size); + if (x) { this.push(x); } + } + , setTimeout: function () { + console.log('TODO implement setTimeout on Duplex'); + } + }; + + var httpServer = require('http').createServer(function (req, res) { + console.log('req.socket.encrypted', req.socket.encrypted); + res.end('Hello, tunneled World!'); + }); + + var tlsServer = require('tls').createServer(opts.httpsOptions, function (tlsSocket) { + console.log('tls connection'); + // things get a little messed up here + httpServer.emit('connection', tlsSocket); + + // try again + //servers.server.emit('connection', tlsSocket); + }); + */ + + process.on('SIGINT', function () { + killcount += 1; + console.log('[quit] closing http and https servers'); + if (killcount >= 3) { + process.exit(1); + } + if (servers.server) { + servers.server.close(); + } + if (servers.insecureServer) { + servers.insecureServer.close(); + } + }); + + return Tunnel.token({ + refreshToken: opts.refreshToken + , email: opts.email + , domains: opts.sites.map(function (site) { + return site.name; + }) + , device: { hostname: opts.devicename || opts.device } + }).then(function (result) { + // { jwt, tunnelUrl } + var locals = []; + opts.sites.map(function (site) { + locals.push({ + protocol: 'https' + , hostname: site.name + , port: opts.port + }); + locals.push({ + protocol: 'http' + , hostname: site.name + , port: opts.insecurePort || opts.port + }); + }); + return stunnel.connect({ + token: result.jwt + , stunneld: result.tunnelUrl + // XXX TODO BUG // this is just for testing + , insecure: /*opts.insecure*/ true + , locals: locals + // a simple passthru is proving to not be so simple + , net: require('net') /* + { + createConnection: function (info, cb) { + // data is the hello packet / first chunk + // info = { data, servername, port, host, remoteAddress: { family, address, port } } + + var myDuplex = new (require('stream').Duplex)(); + var myDuplex2 = new (require('stream').Duplex)(); + // duplex = { write, push, end, events: [ 'readable', 'data', 'error', 'end' ] }; + + myDuplex2.__my_socket = myDuplex; + myDuplex.__my_socket = myDuplex2; + + myDuplex2._write = Dup.write; + myDuplex2._read = Dup.read; + + myDuplex._write = Dup.write; + myDuplex._read = Dup.read; + + myDuplex.remoteFamily = info.remoteFamily; + myDuplex.remoteAddress = info.remoteAddress; + myDuplex.remotePort = info.remotePort; + + // socket.local{Family,Address,Port} + myDuplex.localFamily = 'IPv4'; + myDuplex.localAddress = '127.0.01'; + myDuplex.localPort = info.port; + + myDuplex.setTimeout = Dup.setTimeout; + + // this doesn't seem to work so well + //servers.server.emit('connection', myDuplex); + + // try a little more manual wrapping / unwrapping + var firstByte = info.data[0]; + if (firstByte < 32 || firstByte >= 127) { + tlsServer.emit('connection', myDuplex); + } + else { + httpServer.emit('connection', myDuplex); + } + + if (cb) { + process.nextTick(cb); + } + + return myDuplex2; + } + } + //*/ + }); + }); +}; diff --git a/package.json b/package.json index ef272df..d60e3a8 100644 --- a/package.json +++ b/package.json @@ -1,21 +1,55 @@ { "name": "goldilocks", - "version": "1.0.0-placeholder", - "description": "The webserver that's just right.", - "keywords": [ - "greenlock" - ], - "main": "index.js", - "scripts": { - "test": "echo \"Error: no test specified\" && exit 1" - }, + "version": "2.2.0", + "description": "The node.js webserver that's just right, Greenlock (HTTPS/TLS/SSL via ACME/Let's Encrypt) and tunneling (RVPN) included.", + "main": "bin/goldilocks.js", "repository": { "type": "git", - "url": "git@git.daplie.com:Daplie/goldilocks.git" + "url": "git@git.daplie.com:Daplie/goldilocks.js.git" }, - "author": "AJ ONeal (https://coolaj86.com/)", - "license": "(MIT OR Apache-2.0)", + "author": "AJ ONeal (https://daplie.com/)", + "license": "SEE LICENSE IN LICENSE.txt", + "scripts": { "test": "node bin/goldilocks.js -p 8443 -d /tmp/" }, + "bin": { "goldilocks": "./bin/goldilocks.js" }, + "keywords": [ + "https", + "local", + "localhost", + "development", + "dev", + "tls", + "ssl", + "cert", + "certs", + "certificate", + "certificates", + "http", + "express", + "connect", + "serve", + "server" + ], + "bugs": { "url": "https://git.daplie.com/Daplie/server-https/issues" }, + "homepage": "https://git.daplie.com/Daplie/goldilocks.js#readme", "dependencies": { - "greenlock": "^2.1.11" + "bluebird": "^3.4.6", + "daplie-tunnel": "git+https://git.daplie.com/Daplie/daplie-cli-tunnel.git#master", + "ddns-cli": "git+https://git.daplie.com/Daplie/node-ddns-client.git#master", + "finalhandler": "^0.4.0", + "httpolyglot": "^0.1.1", + "ipify": "^1.1.0", + "le-challenge-ddns": "git+https://git.daplie.com/Daplie/le-challenge-ddns.git#master", + "le-challenge-fs": "git+https://git.daplie.com/Daplie/le-challenge-webroot.git#master", + "le-challenge-sni": "^2.0.1", + "greenlock-express": "git+https://git.daplie.com/Daplie/greenlock-express.git#master", + "greenlock": "git+https://git.daplie.com/Daplie/node-greenlock.git#master", + "livereload": "^0.6.0", + "localhost.daplie.me-certificates": "^1.3.0", + "minimist": "^1.1.1", + "oauth3-cli": "git+https://git.daplie.com/OAuth3/oauth3-cli.git#master", + "redirect-https": "^1.1.0", + "serve-index": "^1.7.0", + "serve-static": "^1.10.0", + "stunnel": "git+https://git.daplie.com/Daplie/node-tunnel-client.git#master" } } diff --git a/stages/01-serve.js b/stages/01-serve.js new file mode 100644 index 0000000..8f92791 --- /dev/null +++ b/stages/01-serve.js @@ -0,0 +1,23 @@ +'use strict'; + +var https = require('httpolyglot'); +var httpsOptions = require('localhost.daplie.me-certificates').merge({}); +var httpsPort = 8443; +var redirectApp = require('redirect-https')({ + port: httpsPort +}); + +var server = https.createServer(httpsOptions); + +server.on('request', function (req, res) { + if (!req.socket.encrypted) { + redirectApp(req, res); + return; + } + + res.end("Hello, Encrypted World!"); +}); + +server.listen(httpsPort, function () { + console.log('https://' + 'localhost.daplie.me' + (443 === httpsPort ? ':' : ':' + httpsPort)); +}); diff --git a/test-chain.sh b/test-chain.sh new file mode 100755 index 0000000..396d286 --- /dev/null +++ b/test-chain.sh @@ -0,0 +1,17 @@ +#!/bin/bash + +node serve.js \ + --port 8443 \ + --key node_modules/localhost.daplie.me-certificates/privkey.pem \ + --cert node_modules/localhost.daplie.me-certificates/fullchain.pem \ + --root node_modules/localhost.daplie.me-certificates/root.pem \ + -c "$(cat node_modules/localhost.daplie.me-certificates/root.pem)" & + +PID=$! + +sleep 1 +curl -s --insecure http://localhost.daplie.me:8443 > ./root.pem +curl -s https://localhost.daplie.me:8443 --cacert ./root.pem + +rm ./root.pem +kill $PID 2>/dev/null