Files
proxy/nodejs/models/dns_provider/duckdns.js
T
wmantly 1c7ad9aaae Fix DuckDNS domains field colliding with DnsProvider's own relation (#127)
Reported error when adding a DuckDNS provider:

  TypeError: this.domains.map is not a function
    at Proxy.updateDomains (models/dns_provider.js:185:37)

DnsProvider.__intraModel merges `{...DnsProvider._keyMap,
...Provider._keyMap}`, so a provider-defined field with the same name
as one of DnsProvider's own (created_by, updated_by, name,
dnsProvider, domains, id) silently overwrites it. DuckDNS defined a
`domains` field (the operator-supplied comma-separated subdomain
list), which replaced DnsProvider's `domains` relation (rel: 'many' to
Domain, populated by updateDomains()) — so `this.domains` stopped
being the array relation and became DuckDNS's raw string instead.

Rename the field to `subdomains` throughout (model, docs, tests). Add
a comment on __intraModel documenting the collision risk for future
providers, and a regression test asserting no registered provider's
_keyMap redefines one of DnsProvider's reserved field names.
2026-07-14 01:24:53 -04:00

138 lines
5.1 KiB
JavaScript

'use strict';
const axios = require('axios');
const dns = require('node:dns').promises;
const {DnsApi} = require('./common');
/*
DuckDNS is a free dynamic DNS service: an operator registers one or more
subdomains under duckdns.org (e.g. "myhost" -> myhost.duckdns.org) on the
DuckDNS website, then updates that name's records with a single
account-wide token. Its API is much smaller than a full DNS provider's:
- There is no read or list API. `getRecords` here resolves the domain via
public DNS instead, since that's the only source of truth available.
- There's no API to enumerate which subdomains a token owns either, so the
operator supplies them directly (the `subdomains` field below) rather than
them being discovered like the other providers.
- Only one A record, one AAAA record, and one TXT record exist per domain,
always at the domain's own apex — DuckDNS has no concept of sub-records
under a registered name. createRecord/deleteRecordById are written
around that; other record types are rejected with a clear error.
*/
class DuckDns extends DnsApi{
static _keyMap = {
token: {isRequired: true, type: 'string', isPrivate: true, displayName: 'Token'},
subdomains: {isRequired: true, type: 'string', displayName: 'Subdomains (comma-separated, e.g. "myhost,myhost2")'},
}
static displayName = 'DuckDNS';
static displayIconUni = ''
static displayIconHtml = `
<svg viewBox="0 0 1024 1024" xmlns="http://www.w3.org/2000/svg">
<circle cx="512" cy="512" r="512" style="fill:#3ca7d5"/>
<path d="M512 256c-141.4 0-256 114.6-256 256s114.6 256 256 256 256-114.6 256-256-114.6-256-256-256zm0 448c-106 0-192-86-192-192s86-192 192-192 192 86 192 192-86 192-192 192z" style="fill:#fff"/>
<circle cx="512" cy="512" r="96" style="fill:#fff"/>
</svg>`
constructor(args){
super()
this.token = args.token;
this.subdomains = args.subdomains;
}
// DuckDNS has one endpoint for everything: setting ip/ipv6 updates the
// A/AAAA record, setting txt updates the TXT record, clear=true wipes
// the field being set. It always responds 200 with a body of "OK"/"KO"
// rather than using HTTP error codes, so auth failures are read from
// the body, not caught as an axios error.
async update(domains, params){
let query = new URLSearchParams({domains, token: this.token, verbose: 'true', ...params});
let res = await axios.get(`https://www.duckdns.org/update?${query}`);
let [status] = String(res.data).trim().split('\n');
if(status !== 'OK') throw this.errors.unauthorized();
}
// No API to enumerate owned subdomains, so the operator supplies them.
// This call both validates the token and, as a side effect, syncs each
// domain's A/AAAA record to this host's current public IP if the token
// is valid (DuckDNS auto-detects the caller's IP when `ip` is omitted)
// — the same thing an operator would need to do anyway when pointing a
// fresh DuckDNS domain at this proxy.
async listDomains(){
let labels = this.subdomains.split(',').map(d => d.trim()).filter(Boolean);
await this.update(labels.join(','), {});
return labels.map(label => ({domain: `${label}.duckdns.org`}));
}
__label(domain){
return domain.domain.replace(/\.duckdns\.org$/, '');
}
// No read API exists; public DNS is the only source of truth available.
async getRecords(domain, options){
let records = [];
for(let [type, resolve] of [['A', 'resolve4'], ['AAAA', 'resolve6']]){
try{
let [data] = await dns[resolve](domain.domain);
records.push({id: type, type, name: '', data});
}catch{}
}
try{
let [data] = await dns.resolveTxt(domain.domain);
records.push({id: 'TXT', type: 'TXT', name: '', data: data.join('')});
}catch{}
if(!options) return records;
return records.filter((record)=>{
let matchCount = 0
for(let key in options){
if(record[key] === options[key] && ++matchCount === Object.keys(options).length){
return true;
}
}
});
}
// DuckDNS records only exist at the domain's own apex; there is no
// sub-record concept to map a name onto.
apexName(domainName){
return '@';
}
async createRecord(domain, options){
options = this.__parseOptions(options, ['type', 'data']);
let label = this.__label(domain);
if(options.type === 'A') await this.update(label, {ip: options.data});
else if(options.type === 'AAAA') await this.update(label, {ipv6: options.data});
else if(options.type === 'TXT') await this.update(label, {txt: options.data});
else throw this.errors.other(400, `DuckDNS only supports A, AAAA and TXT records, got '${options.type}'`);
return {id: options.type, type: options.type, name: '', data: options.data};
}
async deleteRecordById(domain, id){
let label = this.__label(domain);
if(id === 'A') await this.update(label, {ip: '', clear: 'true'});
else if(id === 'AAAA') await this.update(label, {ipv6: '', clear: 'true'});
else if(id === 'TXT') await this.update(label, {txt: '', clear: 'true'});
}
async deleteRecords(domain, options){
let records = await this.getRecords(domain, options);
for(let record of records){
await this.deleteRecordById(domain, record.id);
}
return true;
}
}
module.exports = DuckDns;