From deca83c6453543e957cf2f2d0754949d37470d04 Mon Sep 17 00:00:00 2001 From: Graham Krizek Date: Wed, 16 Dec 2020 07:05:40 -0600 Subject: [PATCH] =?UTF-8?q?feat(server):=20=E2=9C=A8=20Add=20support=20for?= =?UTF-8?q?=20SSL=20Certificate=20provisioning=20via=20ZeroSSL=20(#186)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Anthony Potdevin <31413433+apotdevin@users.noreply.github.com> --- README.md | 24 ++++ package.json | 2 + server/utils/secure-server.js | 231 ++++++++++++++++++++++++++++++++++ 3 files changed, 257 insertions(+) create mode 100644 server/utils/secure-server.js diff --git a/README.md b/README.md index 5db70830..f11b7955 100644 --- a/README.md +++ b/README.md @@ -444,3 +444,27 @@ To get ThunderHub running with docker follow these steps: 2. `docker run --rm -it -p 3000:3000/tcp apotdevin/thunderhub:v0.5.5` You can now go to `localhost:3000` to see your running instance of ThunderHub + +## SSL Certificates + +Thunderhub has the ability to automatically provision SSL certificates for itself via [ZeroSSL](https://zerossl.com). In order to use this, you must configure the `SSL Config` section of the [`.env`](.env) file. To options are as follows: + +- `PUBLIC_URL` is the publicly reachable URL that Thunderhub would be servered from. + +- `SSL_PORT` is the port the Certificate Validation server will run on. This _must_ either be running on port `80` or you must proxy this port to port `80` with something like Nginx. + +- `SSL_SAVE` specifies whether you want Thunderhub to save the generate SSL private key and certificate to disk or not. + +You must also specify your ZeroSSL API key either in the [`.env`](.env) file or export it as an environment variable: + +``` +$ export ZEROSSL_API_KEY="a1b2c3d4e5f6g7h8i9" +``` + +Once you have Thunderhub configured you can start the secure server with: + +``` +$ npm run start:secure +``` + +This will request a certificate from ZeroSSL for the given `PUBLIC_URL` and serve the HTTP challenge via the Certificate Validation server. Once the certificate is verified and issued, Thunderhub downloads the certificate and shuts down the Certificate Validation server. Then it will bring up the Thunerhub web server and use the newly provisioned SSL certificates. \ No newline at end of file diff --git a/package.json b/package.json index 9763f9cc..30b665f3 100644 --- a/package.json +++ b/package.json @@ -10,6 +10,7 @@ "start": "next start", "start:two": "next start -p 3001", "start:cookie": "sh ./scripts/initCookie.sh", + "start:secure": "node server/utils/secure-server.js", "lint": "eslint . --ext ts --ext tsx --ext js", "format": "prettier --write \"**/*.{js,ts,tsx}\"", "release": "standard-version", @@ -183,3 +184,4 @@ } } } + diff --git a/server/utils/secure-server.js b/server/utils/secure-server.js new file mode 100644 index 00000000..5de8e7ad --- /dev/null +++ b/server/utils/secure-server.js @@ -0,0 +1,231 @@ +// server.js +const { createServer: createSecureServer } = require('https') +const { createServer } = require('http') +const { parse } = require('url') +const next = require('next') +const { existsSync, readFileSync, writeFile, mkdirSync } = require('fs') +const express = require('express') +const qs = require('qs') +const forge = require('node-forge'); +const dev = process.env.NODE_ENV !== 'production' +if (dev) { + throw new Error("Running a secure server can only be done in production") +} +const app = next({ dev }) +const publicUrl = app.nextConfig.serverRuntimeConfig.publicUrl +const sslPort = app.nextConfig.serverRuntimeConfig.sslPort || 80 +const sslSave = app.nextConfig.serverRuntimeConfig.sslSave +const logLevel = app.nextConfig.serverRuntimeConfig.logLevel +const apiUrl = "https://api.zerossl.com" +const handle = app.getRequestHandler() + +runServer() + +async function runServer() { + var certData = await getCertificate(publicUrl, sslPort, sslSave) + var credentials = { key: certData?.privateKey.toString(), ca: certData?.caBundle, cert: certData?.certificate }; + runRedirectServer() + app.prepare().then(() => { + createSecureServer(credentials, (req, res) => { + const parsedUrl = parse(req.url, true) + if (parsedUrl.path == "/health") { + res.writeHead(200, { + 'Content-Type': 'application/json' + }) + res.write('{"status":"ok"}') + res.end() + } else { + if (logLevel !== "info" && logLevel !== "warn" && logLevel !== "error") { + console.log(`${req.method} ${parsedUrl.path}`) + } + handle(req, res, parsedUrl) + } + }).listen(3000, (err) => { + if (err) throw err + console.log('ready - started server on http://localhost:3000') + }) + }) +} + +async function runRedirectServer() { + app.prepare().then(() => { + createServer((req, res) => { + res.writeHead(301, { "Location": "https://" + req.headers['host'].replace(80, 3000) + req.url }) + res.end() + }).listen(sslPort, (err) => { + if (err) throw err + console.log(`ready - started redirect server on http://localhost:${sslPort}`) + }) + }) +} + +function sleep(ms) { + return new Promise(resolve => setTimeout(resolve, ms)); +} + +function generateCsr(keys, endpoint) { + var csr = forge.pki.createCertificationRequest(); + csr.publicKey = keys.publicKey; + csr.setSubject([{ + name: 'commonName', + value: endpoint + }]); + csr.sign(keys.privateKey); + if (!csr.verify()) { + throw new Error('=> [ssl] Verification of CSR failed.'); + } + var csr = forge.pki.certificationRequestToPem(csr) + return csr.trim() +} + +async function requestCert(endpoint, csr, apiKey) { + let res = await fetch( + `${apiUrl}/certificates?access_key=${apiKey}`, + { + method: 'post', + body: qs.stringify({ + certificate_domains: endpoint, + certificate_validity_days: '90', + certificate_csr: csr + }), + headers: { + 'content-type': 'application/x-www-form-urlencoded' + } + } + ) + const json = await res.json() + if (json.success === false) { + console.log(json) + throw new Error("=> [ssl] Failed to provision ssl certificate") + } + return json +} + +async function validateCert(port, data, endpoint, apiKey) { + const app = express() + var validationObject = data.validation.other_methods[endpoint] + var replacement = new RegExp(`http://${endpoint}`, "g"); + var path = validationObject.file_validation_url_http.replace(replacement, "") + await app.get(path, (req, res) => { + res.set('Content-Type', 'text/plain'); + res.send(validationObject.file_validation_content.join('\n')) + }); + let server = await app.listen(port, () => { + console.log(`=> [ssl] validation server started at http://0.0.0.0:${port}`); + }); + await requestValidation(data.id, apiKey) + console.log("=> [ssl] waiting for certificate to be issued") + while (true) { + let certData = await getCert(data.id, apiKey) + if (certData.status === "issued") { + console.log("=> [ssl] certificate was issued") + break + } + console.log("=> [ssl] checking certificate again...") + await sleep(2000); + } + await server.close(() => { + console.log('=> [ssl] validation server stopped.') + }) + return +} + +async function requestValidation(id, apiKey) { + let res = await fetch( + `${apiUrl}/certificates/${id}/challenges?access_key=${apiKey}`, + { + method: 'post', + body: qs.stringify({ + validation_method: "HTTP_CSR_HASH" + }), + headers: { + 'content-type': 'application/x-www-form-urlencoded' + } + } + ) + const json = await res.json() + if (json.success === false) { + console.log("=> [ssl] Failed to request certificate validation") + console.log(json) + throw new Error("=> [ssl] Failing to provision ssl certificate") + } + return json +} + +async function getCert(id, apiKey) { + let res = await fetch( + `${apiUrl}/certificates/${id}?access_key=${apiKey}`, + { + method: 'get', + headers: { + 'content-type': 'application/x-www-form-urlencoded' + } + } + ) + return await res.json() +} + +async function downloadCert(id, apiKey) { + let res = await fetch( + `${apiUrl}/certificates/${id}/download/return?access_key=${apiKey}`, + { + method: 'get', + headers: { + 'content-type': 'application/x-www-form-urlencoded' + } + } + ) + return await res.json() +} + +async function getCertificate(endpoint, port, save_ssl) { + if (existsSync(__dirname + "/zerossl/tls.cert") && existsSync(__dirname + "/zerossl/tls.key")) { + var certificate = readFileSync(__dirname + '/zerossl/tls.cert', 'utf-8').toString(); + var caBundle = readFileSync(__dirname + '/zerossl/ca.cert', 'utf-8').toString(); + var privateKey = readFileSync(__dirname + '/zerossl/tls.key', 'utf-8').toString(); + return { + privateKey: privateKey, + certificate: certificate, + caBundle: caBundle + } + } + var apiKey = process.env.ZEROSSL_API_KEY + if (!apiKey) { + throw new Error("=> [ssl] ZEROSSL_API_KEY is not set") + } + var keys = forge.pki.rsa.generateKeyPair(2048) + var csr = generateCsr(keys, endpoint) + console.log("=> [ssl] Generated CSR") + var res = await requestCert(endpoint, csr, apiKey) + console.log("=> [ssl] Requested certificate") + await validateCert(port, res, endpoint, apiKey) + var certData = await downloadCert(res.id, apiKey) + if (save_ssl === true) { + if (!existsSync(__dirname + "/zerossl")) { + await mkdirSync(__dirname + "/zerossl"); + } + await writeFile(__dirname + "/zerossl/tls.cert", certData["certificate.crt"], function (err) { + if (err) { + return console.log(err); + } + console.log("=> [ssl] wrote tls certificate"); + }) + await writeFile(__dirname + "/zerossl/ca.cert", certData["ca_bundle.crt"], function (err) { + if (err) { + return console.log(err); + } + console.log("=> [ssl] wrote tls ca bundle"); + }) + await writeFile(__dirname + "/zerossl/tls.key", forge.pki.privateKeyToPem(keys.privateKey), function (err) { + if (err) { + return console.log(err); + } + console.log("=> [ssl] wrote tls key"); + }) + } + return { + privateKey: forge.pki.privateKeyToPem(keys.privateKey), + certificate: certData["certificate.crt"], + caBundle: certData["ca_bundle.crt"] + } +}