acme-update: new ptrcport

This commit is contained in:
ptrcnull 2023-10-20 04:42:27 +02:00
parent ca0db50ba3
commit 91f5046846
Signed by: ptrcnull
GPG key ID: 411F7B30801DD9CA
3 changed files with 291 additions and 0 deletions

21
acme-update/APKBUILD Normal file
View file

@ -0,0 +1,21 @@
# Contributor: Patrycja Rosa <alpine@ptrcnull.me>
# Maintainer: Patrycja Rosa <alpine@ptrcnull.me>
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
"

65
acme-update/acme-update Executable file
View file

@ -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

205
acme-update/uacme-hook Executable file
View file

@ -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)
})