-
-
Save mmalone/f3c33a2381ffa3d67e86c6d5ad3042c9 to your computer and use it in GitHub Desktop.
Example HTTPS server using ACME to obtain & renew its certificate
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
const acme = require('acme-client'); | |
const http = require('http'); | |
const https = require('https'); | |
const tls = require('tls'); | |
const fs = require('fs'); | |
const port = 11443; | |
const email = "you@yours.com"; | |
const domain = "bar.internal"; | |
const acmeDirectoryUrl = "https://acme.internal/acme/acme/directory"; | |
const rootCertificate = "/home/mmalone/.step/certs/root_ca.crt"; | |
const renewFrequency = 15000; | |
var clientOptions = { | |
ca: fs.readFileSync(rootCertificate), | |
ciphers: 'ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-ECDSA-AES128-GCM-SHA256', | |
minVersion: 'TLSv1.2', | |
}; | |
acme.axios.defaults.httpsAgent = new https.Agent(clientOptions); | |
function log(m) { | |
process.stdout.write(`${m}\n`); | |
} | |
/** | |
* A map of challenge paths => responses for the standalone ACME server | |
*/ | |
var challenges = {}; | |
/** | |
* Function used to satisfy an ACME challenge | |
*/ | |
async function challengeCreateFn(authz, challenge, keyAuthorization) { | |
if (challenge.type !== 'http-01') { | |
throw new Error(`Unsupported challenge type ${challenge.type}`); | |
} | |
log(`Creating challenge response for ${authz.identifier.value}`); | |
challenges[`/.well-known/acme-challenge/${challenge.token}`] = keyAuthorization; | |
} | |
/** | |
* Function used to remove an ACME challenge response | |
*/ | |
async function challengeRemoveFn(authz, challenge, keyAuthorization) { | |
if (challenge.type !== 'http-01') { | |
throw new Error(`Unsupported challenge type ${challenge.type}`); | |
} | |
log(`Removing challenge response for ${authz.identifier.value}`); | |
delete challenges[`/.well-known/acme-challenge/${challenge.token}`]; | |
} | |
/** | |
* Obtain a certificate using ACME. Optionally provide a key for "renewal". | |
*/ | |
async function obtainCertificate(client, existingKey = null) { | |
/* Create CSR */ | |
const [key, csr] = await acme.forge.createCsr({ | |
commonName: domain, | |
altNames: [domain] | |
}, existingKey); | |
/* Certificate */ | |
const cert = await client.auto({ | |
csr, | |
email: email, | |
termsOfServiceAgreed: true, | |
challengeCreateFn, | |
challengeRemoveFn | |
}); | |
log(`CSR:\n${csr.toString()}`); | |
log(`Private key:\n${key.toString()}`); | |
log(`Certificate:\n${cert.toString()}`); | |
return [cert, key] | |
}; | |
var config = { | |
ca: fs.readFileSync(rootCertificate), | |
cert: undefined, | |
key: undefined, | |
ciphers: 'ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256', | |
minVersion: 'TLSv1.2', | |
}; | |
async function startServer() { | |
/* Initialize ACME client */ | |
const client = new acme.Client({ | |
directoryUrl: acmeDirectoryUrl, | |
accountKey: await acme.forge.createPrivateKey() | |
}); | |
// Start standalone ACME challenge server on port 80 | |
var acmeServer = http.createServer(function(req, res) { | |
log(`Challenge server received request for ${req.url}`); | |
if (req.url in challenges) { | |
res.write(challenges[req.url]); | |
} else { | |
res.statusCode = 404; | |
} | |
res.end(); | |
}).listen(80); | |
// Obtain certificates and configure a server security context | |
[config.cert, config.key] = await obtainCertificate(client); | |
var ctx = tls.createSecureContext(config); | |
// Start our HTTPS server | |
var httpsServer = https.createServer({ | |
requestCert: true, // Request client certificates, | |
rejectUnauthorized: false, // but make them optional. | |
// Use the SNI callback to supply a fresh TLS context, with | |
// renewed certificates, for every connection. | |
SNICallback: (servername, cb) => { | |
cb(null, ctx); | |
} | |
}, (req, res) => { | |
res.writeHead(200); | |
const clientCert = req.socket.getPeerCertificate(); | |
if (clientCert !== undefined && clientCert.subject !== undefined) { | |
// `tlsSocket.authorized` returns `true` if the client certificate | |
// is valid (not expired & signed by our CA). | |
if (req.socket.authorized === true) { | |
res.end(`Hello, ${clientCert.subject.CN}\n`); | |
} else { | |
log(`Connection had authorization error: ${req.socket.authorizationError}`); | |
res.end("Hello, invalid client certificate\n"); | |
} | |
} else { | |
res.end('Hello, TLS\n'); | |
} | |
}).listen(port); | |
console.log(`Listening on :${port} ...`); | |
// Renew periodically | |
async function renewCertificate() { | |
const cert = await acme.forge.readCertificateInfo(config.cert); | |
// Renew certificate if it's 2/3 expired. | |
const renewAfter = Math.floor((cert.notAfter.getTime() - cert.notBefore.getTime()) * 2/3); | |
const renewAt = new Date(cert.notBefore.getTime() + renewAfter); | |
if (renewAt <= Date.now()) { | |
log("Renewing certificate"); | |
[config.cert, config.key] = await obtainCertificate(client, config.key); | |
ctx = tls.createSecureContext(config); | |
} else { | |
log(`Waiting to renew at: ${renewAt}`); | |
} | |
setTimeout(renewCertificate, renewFrequency); | |
} | |
setTimeout(renewCertificate, renewFrequency); | |
} | |
startServer(); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment