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