acme-update: new ptrcport
This commit is contained in:
parent
ca0db50ba3
commit
91f5046846
3 changed files with 291 additions and 0 deletions
21
acme-update/APKBUILD
Normal file
21
acme-update/APKBUILD
Normal 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
65
acme-update/acme-update
Executable 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
205
acme-update/uacme-hook
Executable 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)
|
||||
})
|
Loading…
Reference in a new issue