@small-tech/auto-encrypt
Version:
Automatically provisions and renews Let’s Encrypt TLS certificates on Node.js https servers (including Kitten, Polka, Express.js, etc.)
166 lines (146 loc) • 5.54 kB
JavaScript
////////////////////////////////////////////////////////////////////////////////
//
// HttpServer
//
// (Singleton; please use HttpServer.getSharedInstance() to access.)
//
// A simple HTTP server that:
//
// A. While provisioning Let’s Encrypt certificates:
// =================================================
//
// Acts as a challenge server. See RFC 8555 § 8.3 (HTTP Challenge)
//
// Responds to http-01 challenges and forwards all other
// requests to an HTTPS server that it expects to be active at the same domain.
//
// B. At all other times:
// ======================
//
// Forwards http requests to https requests using a 307 redirect.
//
// Copyright © 2020 Aral Balkan, Small Technology Foundation.
// License: AGPLv3 or later.
//
////////////////////////////////////////////////////////////////////////////////
import http from 'http'
import encodeUrl from 'encodeurl'
import log from './util/log.js'
export default class HttpServer {
//
// Singleton access (async).
//
static instance = null
static isBeingInstantiatedViaSingletonFactoryMethod = false
// Is the HTTP server acting as a Let’s Encrypt challenge server?
#isChallengeServer = false
static async getSharedInstance () {
if (HttpServer.instance === null) {
HttpServer.isBeingInstantiatedViaSingletonFactoryMethod = true
HttpServer.instance = new HttpServer()
await HttpServer.instance.init()
}
return HttpServer.instance
}
static async destroySharedInstance () {
if (HttpServer.instance === null) {
log(' 🚮 ❨auto-encrypt❩ HTTP Server was never setup. Nothing to destroy.')
return
}
log(' 🚮 ❨auto-encrypt❩ Destroying HTTP Server…')
await HttpServer.instance.destroy()
HttpServer.instance = null
log(' 🚮 ❨auto-encrypt❩ HTTP Server is destroyed.')
}
addResponder (responder) {
this.responders.push(responder)
}
//
// Private.
//
constructor () {
// Ensure singleton access.
if (HttpServer.isBeingInstantiatedViaSingletonFactoryMethod === false) {
throw new Error('HttpServer is a singleton. Please instantiate using the HttpServer.getSharedInstance() method.')
}
HttpServer.isBeingInstantiatedViaSingletonFactoryMethod = false
this.responders = []
// Create the HTTP server to loop through responses until one handles the request or,
// if none of them do, forward any other insecure requests we receive to an HTTPS
// server that we expect to be running at the same domain.
this.server = http.createServer((request, response) => {
if (this.#isChallengeServer) {
// Act as a Let’s Encrypt challenge server.
let responded = false
for (let i = 0; i < this.responders.length; i++) {
const responder = this.responders[i]
responded = responder(request, response)
if (responded) break
}
// If this is not an ACME authorisation request, as nothing else should be using insecure HTTP,
// forward the request to HTTPS.
if (!responded) {
log(` ⚠ ❨auto-encrypt❩ Received non-ACME HTTP request for ${request.url}, not responding.`)
response.statusCode = 403
response.end('403: forbidden')
}
} else {
// Act as an HTTP to HTTPS forwarder.
// (This means that servers using Auto Encrypt will get automatic HTTP to HTTPS forwarding
// and will not fail if they are accessed over HTTP.)
let httpsUrl = null
try {
httpsUrl = new URL(`https://${request.headers.host}${request.url}`)
} catch (error) {
log(` ⚠ ❨auto-encrypt❩ Failed to redirect HTTP request: ${error}`)
response.statusCode = 403
response.end('403: forbidden')
return
}
// Redirect HTTP to HTTPS.
log(` 👉 ❨auto-encrypt❩ Redirecting HTTP request to HTTPS.`)
response.statusCode = 307
response.setHeader('Location', encodeUrl(httpsUrl))
response.end()
}
})
}
set challengeServer (state) {
if (state) {
log(` 🔒 ❨auto-encrypt❩ HTTP server is now only responding to Let’s Encrypt challenges.`)
} else {
log(` 🔒 ❨auto-encrypt❩ HTTP server is now forwarding HTTP requests to HTTPS (307).`)
}
this.#isChallengeServer = state
}
async init () {
// Note: the server is created on Port 80. On Linux, you must ensure that the Node.js process has
// ===== the correct privileges for this to work. Looking forward to removing this notice once Linux
// leaves the world of 1960s mainframe computers and catches up to other prominent operating systems
// that don’t have this archaic restriction which is security theatre at best and a security
// vulnerability at worst in the global digital network age.
await new Promise((resolve, reject) => {
try {
this.server.listen(80, () => {
log(` 🔒 ❨auto-encrypt❩ HTTP server is listening on port 80.`)
resolve()
})
} catch (error) {
reject(error)
}
})
}
async destroy () {
// Starts killing all connections and closes the server.
this.server.closeAllConnections()
await new Promise((resolve, reject) => {
this.server.close(error => {
if (error) {
console.error(error)
reject(error)
}
resolve()
})
})
}
}