Files
proxy/nodejs/models/dns_provider.js
T
wmantly 73c0f85ff7 DnsProvider.create: fix Domain key mismatch and roll back on failure (#129)
Two compounding bugs, both hit while adding a DuckDNS provider:

1. Domain.get() normalized every lookup via tldExtract before
   checking Redis. model-redis' Table.create() stores the new record
   under the literal key it's given, then calls this.get() internally
   to return the created instance — so for any domain tldExtract
   doesn't recognize as a shared/public suffix (e.g. a DuckDNS name
   like "myhost.duckdns.org", which tldExtract naively normalizes to
   "duckdns.org"), that final read-back always missed and create()
   threw EntryNotFound, despite the record having just been written
   successfully. In other words: no *.duckdns.org domain could ever
   be created. Fixed by trying the exact string first and only
   falling back to the tldExtract-normalized parent if no exact
   record exists — preserving the original "look up an arbitrary
   hostname, find the Domain that governs it" behavior for real
   lookups, while fixing create()'s own read-back of what it just
   wrote.

2. create() saves the DnsProvider row first, then calls
   updateDomains() as a separate step. If updateDomains() throws —
   e.g. a Domain collides with a stale/orphaned record left over from
   an earlier failed attempt (exactly what bug 1 was silently
   producing) — the already-saved provider row was never cleaned up,
   leaving a broken, domain-less provider behind despite the API
   returning an error. Fixed by wrapping updateDomains() in its own
   try/catch and removing the provider on failure.

   That fix has its own subtlety: `instance` (from super.create())
   has its `domains` relation resolved by super.create()'s own
   internal get() call, which runs BEFORE updateDomains() creates any
   Domain rows — so instance.domains is permanently stale (always
   empty), on both the success and failure paths. Removing `instance`
   directly would delete the provider but silently leave behind
   whatever domains updateDomains() did manage to create. Fixed by
   re-fetching (this.get(instance.id)) before both the success return
   and the failure-path remove(), so relations are current in both
   cases — the returned/API-response instance and the rollback's
   cascade-delete.

Manually verified against a live Redis (this project's test
philosophy explicitly excludes Redis-ORM-dependent tests from the
automated suite — see test/README.md "Philosophy"):
- A pre-existing orphaned Domain (simulating bug 1's fallout) now
  produces an accurate "already exists" error instead of a confusing
  EntryNotFound for the wrong (normalized) domain name, and the
  failed create() leaves zero orphaned providers behind.
- A genuinely new *.duckdns.org domain now creates successfully,
  correctly links to its provider, and is fully cascade-deleted when
  the provider is removed.
- npm test: 192/192 pass.
2026-07-14 20:54:08 -04:00

314 lines
10 KiB
JavaScript

'use strict';
const crypto = require("crypto");
const conf = require('@simpleworkjs/conf');
const Table = require('.');
const ModelPs = require('../utils/model_pubsub');
const tldExtract = require('tld-extract').parse_host;
const {planARecordUpdate} = require('../utils/dns_records');
const providers = {
Cloudflare: require('./dns_provider/cloudflare'),
DigitalOcean: require('./dns_provider/digitalocean'),
PorkBun: require('./dns_provider/porkbun'),
DuckDns: require('./dns_provider/duckdns'),
};
class Domain extends Table{
static _key = 'domain';
static _keyMap = {
'created_by': {isRequired: true, type: 'string', min: 3, max: 500},
'created_on': {default: function(){return (new Date).getTime()}},
'updated_by': {default:"__NONE__", isRequired: false, type: 'string',},
'updated_on': {default: function(){return (new Date).getTime()}, always: true},
'domain': {isRequired: true, type: 'string'},
'dnsProvider_id': {isRequired: true, type: 'string'},
'provider': {model: 'DnsProvider', rel:'one', localKey: 'dnsProvider_id'},
'zoneId': {isRequired: false, type: 'string'},
}
// Try the exact string first — Domain.create() stores records under the
// literal domain it's given (see model-redis' Table.create, which HSETs
// under data[this._key] verbatim), and its own final `this.get(...)` to
// return the created instance must find that same literal key. Falling
// straight to tldExtract normalization here breaks that for any domain
// tldExtract doesn't recognize as a shared/public suffix — e.g. a DuckDNS
// name like "myhost.duckdns.org" normalizes to "duckdns.org" (tldExtract
// has no idea duckdns.org is shared across many independent registrants),
// so create() would always throw EntryNotFound on its own read-back
// despite the record having just been written successfully.
// Only fall back to the normalized parent domain if no exact record
// exists — that's what lets callers look up an arbitrary hostname (e.g.
// "www.example.com") and find the Domain that governs it (example.com).
static async get(domain, ...args){
try{
return await super.get(domain, ...args);
}catch(error){
if(error.name !== 'EntryNotFound') throw error;
let normalized;
try{
normalized = tldExtract(domain).domain;
}catch{
throw error;
}
if(normalized === domain) throw error;
return await super.get(normalized, ...args);
}
}
async getRecords(...args){
return await this.provider.api.getRecords(this, ...args);
}
async createRecord(...args){
return await this.provider.api.createRecord(this, ...args);
}
async deleteRecords(...args){
return await this.provider.api.deleteRecords(this, ...args);
}
/**
* Provider-agnostic upsert of a single A record to `ip`. Providers'
* createRecord is not a reliable cross-provider upsert (CloudFlare returns the
* stale record on a duplicate, DigitalOcean duplicates, CloudFlare's
* deleteRecords is a no-op), but all three expose getRecords + deleteRecordById,
* so reconcile explicitly. `name` is a sub-label or '@' for apex.
*/
async upsertARecord(name, ip){
let sub = (name === '@' || name === '') ? '' : name;
let aRecords = await this.getRecords({type: 'A'});
let {deleteIds, create} = planARecordUpdate(aRecords, sub, ip);
for(let id of deleteIds){
await this.provider.api.deleteRecordById(this, id);
}
if(create){
await this.createRecord({type: 'A', name: (sub === '' ? '@' : sub), data: ip});
}
return {ip, changed: create || deleteIds.length > 0};
}
}
Domain.register(ModelPs(Domain));
class DnsProvider extends Table{
static _key = 'id';
static _keyMap = {
'created_by': {isRequired: true, type: 'string', min: 3, max: 500},
'created_on': {default: function(){return (new Date).getTime()}},
'updated_by': {default:"__NONE__", isRequired: false, type: 'string',},
'updated_on': {default: function(){return (new Date).getTime()}, always: true},
'id': {default: ()=>crypto.randomBytes(8).toString("hex")},
'name': {isRequired: true, type: 'string'},
'dnsProvider': {isRequired: true, type: 'string'},
'domains': {model:'Domain', rel: 'many', remoteKey: 'dnsProvider_id'}
}
static __intraModel(provider){
if(!Object.keys(providers).includes(provider)){
throw new Error('Invalid DNS provider');
}
let Provider = providers[provider];
// Provider._keyMap is spread last, so a provider-defined field with the
// same name as one of ours (created_by, updated_by, name, dnsProvider,
// domains, id — see this._keyMap above) silently overwrites it. This bit
// a DuckDNS field named `domains`: it replaced the `domains` relation
// (rel: 'many' to Domain), so `this.domains` stopped being an array and
// `updateDomains()` broke with "this.domains.map is not a function".
// New providers must avoid these names.
let _keyMap = {...this._keyMap, ...Provider._keyMap};
return ({
[this.name] : class extends this {
static _keyMap = _keyMap;
static Provider = Provider;
}
})[this.name];
}
static async create(data, ...args){
let Provider;
try{
let __intraModel = this.__intraModel(data.dnsProvider);
Provider = __intraModel.Provider;
// This is here test if the given API key is valid
let provider = new __intraModel.Provider(data, ...args);
let domains = await provider.listDomains();
let instance = await super.create.call(__intraModel, data, ...args);
try{
await instance.updateDomains(domains);
}catch(updateError){
// Don't leave a half-created provider behind (e.g. a Domain that
// collided with a stale/orphaned record from a previous failed
// attempt — see updateDomains' own comment on zoneId omission for
// another case this can throw). Re-fetch before removing: `instance`
// had its `domains` relation resolved by super.create()'s own
// internal get() BEFORE updateDomains() created any Domain rows, so
// it's permanently stale (always empty) — removing `instance`
// directly would delete the provider but silently leave behind
// whatever domains updateDomains() managed to create before failing,
// which is exactly the kind of orphan this is meant to prevent.
try{
await (await this.get(instance.id)).remove();
}catch(removeError){
console.error('DnsProvider create: failed to roll back', instance.id, 'after updateDomains error:', removeError.message);
}
throw updateError;
}
// Same staleness issue as above: re-fetch so the returned instance
// (and the API response built from it) reflects the domains
// updateDomains() actually just created, not the empty snapshot from
// before it ran.
return await this.get(instance.id);
}catch(error){
if(error.name === 'UnauthorizedDnsApi'){
let keys = [];
for(let key in Provider._keyMap){
keys.push({'key': key, message: 'Invalid Key'})
}
throw this.errors.ObjectValidateError(keys, "API rejected key");
}
// Don't swallow other failures (e.g. a domain-sync validation error):
// returning undefined here made the route crash on `item.id` with an
// opaque message. Surface the real error to the caller instead.
throw error;
}
}
static async get(data, ...args){
let instance = await super.get(data, ...args);
let __intraModel = this.__intraModel(instance.dnsProvider);
return await super.get.call(__intraModel, data, ...args);
}
static listProviders(){
let out = [];
for(let provider in providers){
out.push({
name: provider,
fields: providers[provider]._keyMap,
});
}
return out;
}
// Re-sync every configured provider's domain list from its API. Mirrors the
// manual /dns/domain/refresh/:item route (get -> updateDomains) across all
// providers; used by the host scheduler. Never throws — one bad provider
// (e.g. a revoked key) must not abort the rest.
static async refreshAllDomains(){
let ids;
try{
ids = await this.list();
}catch(error){
console.error('refreshAllDomains: could not list providers', error.message);
return;
}
for(let id of ids){
try{
let provider = await this.get(id);
await provider.updateDomains();
}catch(error){
console.error('refreshAllDomains: provider', id, error.message);
}
}
}
get api(){
return new this.constructor.Provider(this);
}
async listDomains(){
return this.api.listDomains();
}
async updateDomains(domains){
domains = domains || await this.listDomains();
let currentDomains = this.domains.map(domain => domain.domain);
for(let domain of domains){
if(currentDomains.includes(domain.domain)){
delete currentDomains[currentDomains.indexOf(domain.domain)];
continue;
}
await Domain.create({
created_by: this.created_by,
domain: domain.domain,
dnsProvider_id: this.id,
// Only providers with a zone concept (e.g. Cloudflare) return a
// zoneId. Porkbun/DigitalOcean don't, and passing an explicit
// `undefined` trips model-redis' type check ("zoneId is not string
// type") and aborts the whole sync — so omit it when absent.
...(domain.zoneId !== undefined ? {zoneId: domain.zoneId} : {}),
});
}
for(let domain of currentDomains){
if(!domain) continue
domain = await Domain.get(domain);
await domain.remove();
}
}
async remove(){
for(let domain of await this.domains){
await domain.remove();
}
let instance = await super.remove();
return instance;
}
toJSON(){
return {
...super.toJSON(),
...this.constructor.Provider.toJSON()
};
}
}
DnsProvider.register(ModelPs(DnsProvider))
if(require.main === module){(async function(){try{
const conf = require('@simpleworkjs/conf');
// console.log(await DnsProvider.findall());
let provider = await DnsProvider.get('84c6613b9464fbe1');
// console.log(await provider.listDomains())
let domain = await Domain.get('holycore.quest') // pork
// let domain = await Domain.get('rm-rf.stream') // DO
// let domain = await Domain.get('test.wtf') // CF
console.log(await domain.createRecord({
type: 'TXT',
name: 'apitewefweefwsewft222',
data: 'sdddddddad'
}));
let txtRecords = await domain.getRecords();
console.log(txtRecords.map(i=>`${i.name}: ${i.data}`))
// console.log(await domain.deleteRecords({type: 'TXT'}))
}catch(error){
console.log('IIFE Error:', error);
}finally{
process.exit(0);
}})()}