ptrcports/alpine/acme-update/uacme-hook

206 lines
5 KiB
Text
Raw Normal View History

2023-10-20 02:42:27 +00:00
#!/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)
})