DEV Community

AlbertMarashi for Promatia

Posted on

1 1

Letsencrypt https generator for Koa Apps

While researching options for automatic https certificate generation, I couldn't find any good options that satisfied my needs for my website.

I opted to use a library called acme-client on npm which exposes a letsencrypt acme API that is super simple to use (async/await)

const acme = require('acme-client')
const fs = require('fs')
const path = require('path')
const os = require('os')
const forge = require('node-forge')

const directoryUrl = acme.directory.letsencrypt[ENV.ssl.mode] // mode: 'staging' || 'production'
const sslDataPath = path.resolve(os.homedir(), './ssl/') //SSL path where certificates will be stored

// function to write the SSL data that is persisted across server restart (prevents letsencrypt rate-limiting)
function writeSSLObject(obj){
    fs.mkdirSync(sslDataPath, { recursive: true })
    fs.writeFileSync(`${sslDataPath}/${ENV.ssl.mode}.json`, JSON.stringify(obj), 'utf8')
}

/**
 * read sslObject data 
 * { 
 *   accountKey: 'Private Key for Letsencrypt API Communication',
 *   accountUrl: 'Letsencrypt API Account URL', 
 *   key: 'RSA Private Key',
 *   cert: 'Full chain certificate',
 * }
**/
function readSSLObject(){
    try {
        return JSON.parse(fs.readFileSync(`${sslDataPath}/${ENV.ssl.mode}.json`), 'utf8')
    } catch (error) {
        return {}
    }
}

async function getClient(){
    let opts = {
        directoryUrl
    }

    const sslObject = readSSLObject()

    if(sslObject.accountKey) opts.accountKey = sslObject.accountKey
    if(sslObject.accountUrl) opts.accountUrl = sslObject.accountUrl

    if(!opts.accountKey) opts.accountKey = String((await acme.forge.createPrivateKey()))

    const client = new acme.Client(opts)

    try {
        client.getAccountUrl() //check if account exists
    } catch (error) {
        await client.createAccount({
            email: ENV.ssl.email,
            termsOfServiceAgreed: true
        })

        writeSSLObject({...sslObject, accountUrl: client.getAccountUrl(), accountKey: opts.accountKey})
    }

    return { 
        client,
        cert: sslObject.cert,
        key: sslObject.key
    }
}

function getExpiry(cert){
    if(cert) return forge.pki.certificateFromPem(cert).validity.notAfter
}

module.exports = async function ssl(httpServer, http2server){
    let challengeFilePaths = {}
    let renewingCertPromise = null

    let { client, cert, key } = await getClient()
    let expires = getExpiry(cert)

    async function newCert(){
        const [privateKey, csr] = await acme.forge.createCsr({
            commonName: ENV.ssl.domains[0],
            altNames: ENV.ssl.domains
        })

        key = String(privateKey)
        cert = await client.auto({
            csr,
            challengePriority: ['http-01'],
            async challengeCreateFn(authz, challenge, challengeContents) {
                if (challenge.type === 'http-01') { //save the path in memory, with challenge contents which will be used by middleware
                    challengeFilePaths[`/.well-known/acme-challenge/${challenge.token}`] = challengeContents
                }
            },
            async challengeRemoveFn(auths, challenge){
                delete challengeFilePaths[`/.well-known/acme-challenge/${challenge.token}`]
            }
        })

        writeSSLObject({...readSSLObject(), key, cert})
        expires = getExpiry(cert)

        http2server.setSecureContext({
            key,
            cert
        })
    }

    function shouldRenewCert(){
        //if there is no renewal date, generate a new cert
        if(!expires) return true

        //check last renewal, and if more than 2 months, renew certificate
        //letsnecrypt certificates expire every 3 months
        let renewAfter = new Date(expires).setMonth(expires.getMonth() - 1)
        let now = new Date()

        return now > renewAfter
    }

    httpServer.on('listening', async () => { //wait for httpserver to be listening (done elsewhere in app)
        try {
            if(shouldRenewCert()) renewingCertPromise = newCert()

            if(renewingCertPromise){
                await renewingCertPromise
            }else{
                http2server.setSecureContext({
                    key,
                    cert
                })
            }

            http2server.listen(443)
        } catch (error) {
            console.error(error)
        }
    })

    /**
     * Return letsencrypt challenge middleware
     */
    return async function middleware(ctx, next){
        if(challengeFilePaths[ctx.url]){ //serve challenge contents if it matches URL
            return ctx.body = challengeFilePaths[ctx.url]
        }

        if(shouldRenewCert() && !renewingCertPromise){
            renewingCertPromise = newCert()
            await renewingCertPromise
            renewingCertPromise = null
        }

        if(renewingCertPromise) await renewingCertPromise

        await next()
    }
}

This code is used in production on Promatia

SurveyJS custom survey software

JavaScript UI Libraries for Surveys and Forms

SurveyJS lets you build a JSON-based form management system that integrates with any backend, giving you full control over your data and no user limits. Includes support for custom question types, skip logic, integrated CCS editor, PDF export, real-time analytics & more.

Learn more

Top comments (0)

Billboard image

The Next Generation Developer Platform

Coherence is the first Platform-as-a-Service you can control. Unlike "black-box" platforms that are opinionated about the infra you can deploy, Coherence is powered by CNC, the open-source IaC framework, which offers limitless customization.

Learn more