#!/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) })