205 lines
5 KiB
JavaScript
Executable file
205 lines
5 KiB
JavaScript
Executable file
#!/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)
|
|
})
|