feat(server): Add support for SSL Certificate provisioning via ZeroSSL (#186)

Co-authored-by: Anthony Potdevin <31413433+apotdevin@users.noreply.github.com>
This commit is contained in:
Graham Krizek 2020-12-16 07:05:40 -06:00 committed by GitHub
parent e59aa2e11a
commit deca83c645
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
3 changed files with 257 additions and 0 deletions

View file

@ -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` 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 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.

View file

@ -10,6 +10,7 @@
"start": "next start", "start": "next start",
"start:two": "next start -p 3001", "start:two": "next start -p 3001",
"start:cookie": "sh ./scripts/initCookie.sh", "start:cookie": "sh ./scripts/initCookie.sh",
"start:secure": "node server/utils/secure-server.js",
"lint": "eslint . --ext ts --ext tsx --ext js", "lint": "eslint . --ext ts --ext tsx --ext js",
"format": "prettier --write \"**/*.{js,ts,tsx}\"", "format": "prettier --write \"**/*.{js,ts,tsx}\"",
"release": "standard-version", "release": "standard-version",
@ -183,3 +184,4 @@
} }
} }
} }

View file

@ -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"]
}
}