From 91f5046846f7dd8a0c679e5402990ab85df1efab Mon Sep 17 00:00:00 2001 From: ptrcnull Date: Fri, 20 Oct 2023 04:42:27 +0200 Subject: [PATCH] acme-update: new ptrcport --- acme-update/APKBUILD | 21 ++++ acme-update/acme-update | 65 +++++++++++++ acme-update/uacme-hook | 205 ++++++++++++++++++++++++++++++++++++++++ 3 files changed, 291 insertions(+) create mode 100644 acme-update/APKBUILD create mode 100755 acme-update/acme-update create mode 100755 acme-update/uacme-hook diff --git a/acme-update/APKBUILD b/acme-update/APKBUILD new file mode 100644 index 0000000..e0229cf --- /dev/null +++ b/acme-update/APKBUILD @@ -0,0 +1,21 @@ +# Contributor: Patrycja Rosa +# Maintainer: Patrycja Rosa +pkgname=acme-update +pkgver=1 +pkgrel=0 +pkgdesc="uacme stuff" +url="https://git.ptrc.gay/ptrcnull/ptrcports" +arch="all" +license="BSD-2-Clause" +depends="uacme node-print" +source="acme-update uacme-hook" + +package() { + install -Dm755 "$srcdir"/acme-update -t "$pkgdir"/usr/bin/ + install -Dm755 "$srcdir"/uacme-hook -t "$pkgdir"/usr/libexec/ +} + +sha512sums=" +d9b6fcc1bf35ff8672062b8dd579d707d3fdbb041aae7713f0e5d57384d5bd92cdb48a0fa5c8a122df3c2954919ef5d7c6aafe0f52dbec438bf93f2f54994cf9 acme-update +7386138a0382a8c029185fb966d770e573237579e919f873792d7d4b8d29759dc2fffce1e0aa7368e584bd7ec8e2f4918d4d8d35b74e13701c368c2888c912fa uacme-hook +" diff --git a/acme-update/acme-update b/acme-update/acme-update new file mode 100755 index 0000000..ac496ae --- /dev/null +++ b/acme-update/acme-update @@ -0,0 +1,65 @@ +#!/bin/sh -eu +exec &> >(tee -a /var/log/acme.log) 2>&1 +echo "[acme-update] starting cert renewal at: $(date)" + +. /etc/uacme/config.sh + +export UACME_CHALLENGE_PATH=/var/www/acme/.well-known/acme-challenge + +expiry_date() { + openssl x509 -enddate -noout -in "$1" | cut -d= -f2 | sed 's/ GMT//' +} + +actually_did_something=false + +for domain in $domains; do + acme_domain="$domain" + if [ "${domain:0:1}" = "*" ]; then + acme_domain="${domain/\*./} $domain" + domain="${domain/\*./}" + fi + cert="/etc/ssl/uacme/$domain/cert.pem" + + echo + if [ -f "$cert" ]; then + date_exp=$(date -d "$(expiry_date "$cert")" "+%s" || true) + date_now=$(date "+%s") + # if more than 1 month + if [ "$(( date_exp - date_now ))" -gt 2592000 ]; then + echo "[acme-update] cert for $domain expires in more than a month, skipping" + continue + fi + fi + echo "[acme-update] getting cert for $domain" + + hook=/usr/libexec/uacme-hook + + /usr/bin/uacme -v --hook $hook -b 384 --type EC issue $acme_domain || true + cp -fv /etc/ssl/uacme/private/$domain/key.pem /etc/ssl/uacme/$domain/cert.pem.key + chown acme:acme /etc/ssl/uacme/$domain/cert.pem.key + chmod 440 /etc/ssl/uacme/$domain/cert.pem.key + + cp -fv /etc/ssl/uacme/$domain/cert.pem /etc/ssl/uacme/all/$domain.pem + cp -fv /etc/ssl/uacme/$domain/cert.pem.key /etc/ssl/uacme/all/$domain.pem.key + + cert="/etc/ssl/uacme/$domain/cert.pem" + if ! [ -e "$cert" ]; then + echo "[acme-update] warning: cert $cert does not exist" + continue + fi + expiration="$(expiry_date "$cert")" + actually_did_something=true + + echo "[acme-update] certificate expiration for $domain: $expiration" +done + +if $actually_did_something; then + doas service haproxy reload + doas service soju reload + doas service maddy restart + doas service mosquitto restart +fi + +echo info: cert renewal completed successfully at: $(date) + +exit 0 diff --git a/acme-update/uacme-hook b/acme-update/uacme-hook new file mode 100755 index 0000000..2bcbcdd --- /dev/null +++ b/acme-update/uacme-hook @@ -0,0 +1,205 @@ +#!/usr/bin/node + +require('ptrc') + +const fs = require('fs') +const dns = require('node:dns/promises') + +const ACME_PATH = '/var/www/acme/.well-known/acme-challenge/' + +const config = fs.readFileSync('/etc/uacme/config.json', 'utf8').apply(JSON.parse) +const [ , , action, method, domain, token, recordValue ] = process.argv + +function findZone(fullDomain) { + let domain = fullDomain + while (true) { + if (Object.keys(config.domains).includes(domain)) { + return domain + } + + const newDomain = domain.split('.').slice(1).join('.') + if (!newDomain.includes('.')) { + throw new Error(`cannot find keys for ${fullDomain}`) + } + + domain = newDomain + } +} + +const recordName = `_acme-challenge.${domain}` +const zone = findZone(domain) +const shortRecordName = recordName.replaceAll(`.${zone}`, '') + +console.log(`> ${action} ${recordName} [${shortRecordName} at ${zone}] ${token} = '${recordValue}'`) + +class Hetzner { + ns = 'hydrogen.ns.hetzner.com' + + async _fetch (url, opts) { + if (!opts) { + opts = {} + } + opts.headers ??= {} + opts.headers['Auth-API-Token'] = config.secrets.hetzner + if (opts.body) { + opts.method ??= 'POST' + opts.headers['Content-Type'] = 'application/json' + } + const res = await fetch('https://dns.hetzner.com/api/v1' + url, opts) + if (res.status !== 200) { + const body = await res.text() + throw new Error(`status ${res.status}: ${body}`) + } + return res.json() + } + + async init() { + const { zones } = await this._fetch('/zones') + this.zoneID = zones.find(x => x.name === zone).id + } + + async create () { + await this.remove() + + console.log(`>> [hetzner] creating ${shortRecordName}.${zone} (zone ${this.zoneID}) with value ${recordValue}`) + await this._fetch('/records', { + body: JSON.stringify({ + value: recordValue, + ttl: 300, + type: 'TXT', + name: shortRecordName, + zone_id: this.zoneID + }) + }) + } + + async remove () { + const { records } = await this._fetch(`/records?zone_id=${this.zoneID}`) + for (let record of records) { + if (record.name === shortRecordName) { + console.log(`>> [hetzner] removing ${recordName} (${record.id})`) + try { + await this._fetch(`/records/${record.id}`, { method: 'DELETE' }) + } catch (err) { + console.error('>> [hetzner] failed to remove record:', err) + } + } + } + } +} + +class Cloudflare { + ns = 'aida.ns.cloudflare.com' + + init () { + process.env['CF_API_TOKEN'] = config.secrets.cloudflare + } + + _exec (args) { + const res = exec('flarectl', [ '--json', ...args ]) + if (res.status !== 0) { + throw new Error(`'flarectl --json ${args.join(" ")}' exited with ${res.status}:\n${res.stderr}`) + } + return res.stdout + } + + async create () { + await this.remove() + + console.log(`>> [cloudflare] creating ${recordName} with value ${recordValue}`) + this._exec([ + 'dns', 'create', + '--zone', zone, + '--type', 'TXT', + '--name', recordName, + '--content', recordValue + ]) + } + + async remove () { + const records = this._exec(['dns', 'list', '--zone', zone]) + for (let record of records) { + if (record.Name === recordName) { + console.log(`>> [cloudflare] removing ${recordName} (${record.ID})`) + try { + this._exec(['dns', 'delete', '--zone', zone, '--id', record.ID]) + } catch (err) { + console.error('>> [cloudflare] failed to remove record:', err) + } + } + } + } +} + +class Http { + init () { + this.path = ACME_PATH + token + } + + async create () { + console.log(`>> [http] creating ${this.path} with contents ${recordValue}`) + fs.writeFileSync(this.path, recordValue, 'utf8') + } + + async remove () { + console.log(`>> [http] removing ${this.path}`) + fs.unlinkSync(this.path) + } +} + +const handlers = { + hetzner: new Hetzner(), + cloudflare: new Cloudflare(), + http: new Http() +} + +async function main() { + const handlerName = config.domains[zone] + if ((method === 'dns-01') === (handlerName === 'http')) { + console.log(`> handler is ${handlerName} for method ${method}, try again`) + process.exit(1) + } + + console.log('> using handler:', handlerName) + const handler = handlers[handlerName] + await handler.init() + + if (action === 'begin') { + await handler.create() + } else { + await handler.remove() + } + + if (method === 'dns-01') { + const dns4 = await dns.resolve4(handler.ns) + const dns6 = await dns.resolve6(handler.ns) + dns.setServers(dns4.concat(dns6)) + let tries = 0 + while (tries < 5) { + console.log(`> checking DNS (attempt ${tries+1}/5)`) + let values + try { + values = (await dns.resolveTxt(recordName)).flat() + } catch (err) { + if (err.code === 'ENOTFOUND') { + console.log('> got ENOTFOUND from DNS') + values = [] + } else { + console.error(`> error when querying TXT ${recordName}:`, err) + } + } + console.log('> DNS result:', values) + if ((action === 'begin') === values.includes(recordValue)) { + process.exit(0) + } + + await new Promise((resolve, reject) => setTimeout(resolve, 5000)) + tries++ + } + console.log('> could not ensure the DNS record was created!!') + } +} +main().catch(err => { + console.error('> uncaught error:', err.toString().split('\n').join('\n> ')) + process.exit(1) +})