Skip to content

Instantly share code, notes, and snippets.

@ochafik
Last active August 11, 2021 01:23
Show Gist options
  • Save ochafik/bb2d2ab60a60233389f2e5f0b5770085 to your computer and use it in GitHub Desktop.
Save ochafik/bb2d2ab60a60233389f2e5f0b5770085 to your computer and use it in GitHub Desktop.

Get your free SSL server certificate semi-manually in 10 minutes w/ Letsencrypt

This script runs certbot in manual mode with a custom adhoc local DNS server (written in node.js using my fork of joyent/node-mname) to respond to its challenge.

The Let's Encrypt non-profit does an amazing job at helping hosting providers provide SSL certificates for free to their customers. This should be your first choice to get one, but maybe your provider prefers charging you for said certificates. That's where we come to play.

If you've just bought a domain and have a Unix machine connected to the internet (laptop, VPS or dedicated instance), you'll get your SSL certificate in 10 minutes chrono. Let's start!

Prerequisites

Before running this, make sure the machine you're running it from can act as the personal DNS server for your domain:

  • Configure its externally-visible IP in your DNS registrar as the first and second personal/custom DNS server for the domain.
  • Ensure your server's firewall is letting UDP port 53 traffic in. You might need to do this both on your machine (e.g. with sudo ufw allow dns on Linux) and in the control panel of your VPS / dedicated cloud instance.
  • If running from a local network, ensure your home router lets UDP/53 traffic through. Most routers will let you do that with upnp (provided in the package miniupnpc in most distros, incl. Homebrew): upnpc -e "Temporary DNS to get an SSL certificate" -r 53 UDP
  • Ensure you have a recent node.js (I recommend using the "n" package) and the dig utility (often packaged under dnsutils):
    # brew install dnsutils node
    # sudo apt install dnsutils node
    npm i -G n
    n latest
  • Ensure there's no existing DNS server running on your machine. If you're not sure, there probably isn't any (the command may vary, something like sudo systemctl stop <dns> or sudo service <dns> stop where dns is one of named, systemd-resolved, bind9)
  • Install certbot. Probably as simple as sudo get install certbot or brew install certbot.
  • Download ./letsencrypt.sh, make it executable and scrutinize it (we don't live in a web of trust I'm afraid). You should also inspect this branch of mine that it's pulling as an npm dependency. Feel free to run the tests with TEST=1 ./letsencrypt.sh to check things are likely to work as they should.

Usage

./letsencrypt.sh foo.xyz someone+foo.xyz+certbot@gmail.com

That will output certificate and private key files:

/etc/letsencrypt/live/foo.xyz/fullchain.pem
/etc/letsencrypt/live/foo.xyz/privkey.pem

Which you can immediately use to run a full-flegded, green-lock HTTPS server (don't forget to clear the way for HTTPS port 443/TCP in your firewalls, see instructions above):

const express = require('express');
const https = require('https');
const fs = require('fs');

const app = express();

app.get("/", (req, res) => res.send('Hello, HTTPS!').end());

const server = https.createServer({
  cert: fs.readFileSync('/etc/letsencrypt/live/foo.xyz/fullchain.pem'),
  key: fs.readFileSync('/etc/letsencrypt/live/foo.xyz/privkey.pem'),
}, app);

const listener = server.listen(process.env.PORT,
  () => console.log(`Listening on port ${listener.address().port}`));

Run this with:

PORT=443 node server.js
#!/bin/bash
# Copyright 2021 Google LLC.
# SPDX-License-Identifier: Apache-2.0
#
# Doc: https://gist.github.com/ochafik/bb2d2ab60a60233389f2e5f0b5770085
set -eu
readonly SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" &> /dev/null && pwd )"
readonly DOMAIN="${1:-}"
readonly EMAIL="${2:-}"
readonly POISON_PILL="$RANDOM.com"
if [[ "${TEST:-0}" == 1 ]]; then
readonly TMP_NODE_HOOK_DIR=tmp
mkdir -p $TMP_NODE_HOOK_DIR
else
trap 'rm -fR "$TMP_NODE_HOOK_DIR"' EXIT
readonly TMP_NODE_HOOK_DIR=$(mktemp -d -t certbot_node_hook.XXX) || exit 1
fi
echo "Temp dir: $TMP_NODE_HOOK_DIR"
cd "${TMP_NODE_HOOK_DIR}"
echo '{}' > package.json
# Pulls this branch: https://github.com/ochafik/node-mname/tree/caa-records
npm i -S "github:ochafik/node-mname#caa-records"
# Node.js DNS server that only responds to the Let's Encrypt challenges / verifications:
echo "#!$( which node )
const { CERTBOT_DOMAIN, CERTBOT_TOKEN } = process.env
const named = require('mname')
named.createServer().on('query', (query, done) => {
console.log({type: query.type(), name: query.name(), src: query.src.address})
const domain = query.name().toLowerCase()
if (query.type() == 'TXT' && domain == '_acme-challenge.' + CERTBOT_DOMAIN)
query.addAnswer(domain, new named.TXTRecord(CERTBOT_TOKEN))
else if (query.type() == 'CAA' && domain == CERTBOT_DOMAIN)
query.addAnswer(domain, new named.CAARecord('issuewild', 'letsencrypt.org'))
else if (domain == '$POISON_PILL')
process.exit(0)
else
query.setError('nxdomain')
query.respond()
done()
}).listen(53, '0.0.0.0')" > auth-hook.js
echo "#!/bin/bash
node auth-hook.js &" > auth-hook.sh
echo "#!/bin/bash
dig +short +tries=1 @localhost $POISON_PILL 2>/dev/null" > cleanup-hook.sh
chmod +x auth-hook.sh cleanup-hook.sh
if [[ "${TEST:-0}" == 1 ]]; then
#
# Test the challenge + poison-pill behaviour.
#
export CERTBOT_TOKEN="some_secret_test_token"
export CERTBOT_DOMAIN="my-test-domain.com"
node auth-hook.js &
DNS_PID=$!
trap '"$TMP_NODE_HOOK_DIR/cleanup-hook.sh" 2>/dev/null' EXIT
sleep 3
CAA_RECORD=$( dig +short @localhost $CERTBOT_DOMAIN CAA )
TXT_RECORD=$( dig +short @localhost _acme-challenge.$CERTBOT_DOMAIN TXT )
if [[ "$CAA_RECORD" != '0 issuewild "letsencrypt.org"' || \
"${TXT_RECORD}" != "\"${CERTBOT_TOKEN}\"" ]]; then
echo "Invalid records: CAA='$CAA_RECORD', TXT='$TXT_RECORD'"
exit 1
fi
./cleanup-hook.sh &
sleep 3
if [[ -d "/proc/${DNS_PID}" ]]; then
echo "Failed to kill the adhoc DNS!"
exit 1
fi
echo "# Tests are passing!"
else
#
# Have certbot issue certificates after a DNS challenge
#
certbot certonly --domain "${DOMAIN}" --email "${EMAIL}" \
--manual --preferred-challenges=dns-01 \
--manual-auth-hook ./auth-hook.js \
--manual-cleanup-hook ./cleanup-hook.sh
fi
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment