diff --git a/docs/api.md b/docs/api.md index 901f0e0..2c63b26 100755 --- a/docs/api.md +++ b/docs/api.md @@ -727,11 +727,11 @@ curl -H "Content-Type: application/json" \ curl -H "Content-Type: application/json" \ -H "auth-token: your-token-here" \ -X POST \ - -d '{"name": "My DuckDNS", "dnsProvider": "DuckDns", "token": "your-duckdns-token", "domains": "myhost,myhost2"}' \ + -d '{"name": "My DuckDNS", "dnsProvider": "DuckDns", "token": "your-duckdns-token", "subdomains": "myhost,myhost2"}' \ https://proxy-host.com/api/dns ``` -`domains` is a comma-separated list of the subdomains you've registered at +`subdomains` is a comma-separated list of the subdomains you've registered at [duckdns.org](https://www.duckdns.org) (e.g. `myhost` for `myhost.duckdns.org`), since DuckDNS has no API to list them for you. DuckDNS only supports one A/AAAA record and one TXT record per domain (no diff --git a/nodejs/api.md b/nodejs/api.md index d6ef8dc..f9b66ad 100755 --- a/nodejs/api.md +++ b/nodejs/api.md @@ -720,11 +720,11 @@ curl -H "Content-Type: application/json" \ curl -H "Content-Type: application/json" \ -H "auth-token: your-token-here" \ -X POST \ - -d '{"name": "My DuckDNS", "dnsProvider": "DuckDns", "token": "your-duckdns-token", "domains": "myhost,myhost2"}' \ + -d '{"name": "My DuckDNS", "dnsProvider": "DuckDns", "token": "your-duckdns-token", "subdomains": "myhost,myhost2"}' \ https://proxy-host.com/api/dns ``` -`domains` is a comma-separated list of the subdomains you've registered at +`subdomains` is a comma-separated list of the subdomains you've registered at [duckdns.org](https://www.duckdns.org) (e.g. `myhost` for `myhost.duckdns.org`), since DuckDNS has no API to list them for you. DuckDNS only supports one A/AAAA record and one TXT record per domain (no diff --git a/nodejs/models/dns_provider.js b/nodejs/models/dns_provider.js index 04bdcaa..01f98e3 100644 --- a/nodejs/models/dns_provider.js +++ b/nodejs/models/dns_provider.js @@ -93,6 +93,13 @@ class DnsProvider extends Table{ } 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 ({ diff --git a/nodejs/models/dns_provider/duckdns.js b/nodejs/models/dns_provider/duckdns.js index 24caaed..f93d8b0 100644 --- a/nodejs/models/dns_provider/duckdns.js +++ b/nodejs/models/dns_provider/duckdns.js @@ -13,7 +13,7 @@ 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 `domains` field below) rather than + 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 @@ -23,7 +23,7 @@ account-wide token. Its API is much smaller than a full DNS provider's: class DuckDns extends DnsApi{ static _keyMap = { token: {isRequired: true, type: 'string', isPrivate: true, displayName: 'Token'}, - domains: {isRequired: true, type: 'string', displayName: 'Domains (comma-separated, e.g. "myhost,myhost2")'}, + subdomains: {isRequired: true, type: 'string', displayName: 'Subdomains (comma-separated, e.g. "myhost,myhost2")'}, } static displayName = 'DuckDNS'; @@ -38,7 +38,7 @@ class DuckDns extends DnsApi{ constructor(args){ super() this.token = args.token; - this.domains = args.domains; + this.subdomains = args.subdomains; } // DuckDNS has one endpoint for everything: setting ip/ipv6 updates the @@ -61,7 +61,7 @@ class DuckDns extends DnsApi{ // — the same thing an operator would need to do anyway when pointing a // fresh DuckDNS domain at this proxy. async listDomains(){ - let labels = this.domains.split(',').map(d => d.trim()).filter(Boolean); + let labels = this.subdomains.split(',').map(d => d.trim()).filter(Boolean); await this.update(labels.join(','), {}); return labels.map(label => ({domain: `${label}.duckdns.org`})); diff --git a/nodejs/test/integration/dns_provider.test.js b/nodejs/test/integration/dns_provider.test.js index 827e0a8..3196ab0 100644 --- a/nodejs/test/integration/dns_provider.test.js +++ b/nodejs/test/integration/dns_provider.test.js @@ -158,7 +158,7 @@ describe('DNS Provider Contract Compliance', () => { const DuckDns = require('../../models/dns_provider/duckdns'); test('should meet DNS provider contract', () => { - const mockCredentials = {token: 'mock-token', domains: 'mockhost'}; + const mockCredentials = {token: 'mock-token', subdomains: 'mockhost'}; const instance = validateDnsProviderContract(DuckDns, mockCredentials); assert.ok(instance, 'DuckDNS provider should be instantiated'); @@ -169,27 +169,27 @@ describe('DNS Provider Contract Compliance', () => { assert.strictEqual(DuckDns._keyMap.token.type, 'string'); assert.strictEqual(DuckDns._keyMap.token.isRequired, true); assert.strictEqual(DuckDns._keyMap.token.isPrivate, true); - assert.ok(DuckDns._keyMap.domains, 'Should require domains'); - assert.strictEqual(DuckDns._keyMap.domains.isRequired, true); + assert.ok(DuckDns._keyMap.subdomains, 'Should require subdomains'); + assert.strictEqual(DuckDns._keyMap.subdomains.isRequired, true); }); test('should have valid method signatures', () => { - const instance = new DuckDns({token: 'mock-token', domains: 'mockhost'}); + const instance = new DuckDns({token: 'mock-token', subdomains: 'mockhost'}); validateMethodSignatures(instance); }); test('should validate key mapping', () => { - const instance = new DuckDns({token: 'mock-token', domains: 'mockhost'}); + const instance = new DuckDns({token: 'mock-token', subdomains: 'mockhost'}); validateKeyMapping(instance); }); test('should validate type checking', () => { - const instance = new DuckDns({token: 'mock-token', domains: 'mockhost'}); + const instance = new DuckDns({token: 'mock-token', subdomains: 'mockhost'}); validateTypeChecking(instance); }); test('rejects non A/AAAA/TXT record creation with a clear error', async () => { - const instance = new DuckDns({token: 'mock-token', domains: 'mockhost'}); + const instance = new DuckDns({token: 'mock-token', subdomains: 'mockhost'}); await assert.rejects( () => instance.createRecord({domain: 'mockhost.duckdns.org'}, {type: 'CNAME', data: 'example.com'}), /DuckDNS only supports A, AAAA and TXT records/ @@ -197,12 +197,43 @@ describe('DNS Provider Contract Compliance', () => { }); test('__label strips the .duckdns.org suffix', () => { - const instance = new DuckDns({token: 'mock-token', domains: 'mockhost'}); + const instance = new DuckDns({token: 'mock-token', subdomains: 'mockhost'}); assert.strictEqual(instance.__label({domain: 'mockhost.duckdns.org'}), 'mockhost'); }); }); }); +describe('DNS Provider _keyMap field names', () => { + // Regression test for the DuckDNS `domains` field bug: DnsProvider merges + // `{...DnsProvider._keyMap, ...Provider._keyMap}` (see __intraModel in + // ../../models/dns_provider.js), so a provider field with the same name as + // one of DnsProvider's own (created_by, updated_by, name, dnsProvider, + // domains, id — see DnsProvider._keyMap) silently overwrites it. That + // happened with a DuckDNS field named `domains`, which replaced the + // `domains` relation (rel: 'many' to Domain) and broke updateDomains() + // with "this.domains.map is not a function". Every registered provider's + // _keyMap must avoid these names. + // + // This checks each provider class directly rather than going through + // ../../models/dns_provider.js, which pulls in the Redis-backed Table + // base class (model-redis) — not needed for a static field-name check, + // and not guaranteed to have a reachable Redis in every test environment. + const RESERVED = ['created_by', 'updated_by', 'name', 'dnsProvider', 'domains', 'id']; + const providerClasses = { + Cloudflare: require('../../models/dns_provider/cloudflare'), + DigitalOcean: require('../../models/dns_provider/digitalocean'), + PorkBun: require('../../models/dns_provider/porkbun'), + DuckDns: require('../../models/dns_provider/duckdns'), + }; + + for(const [name, ProviderClass] of Object.entries(providerClasses)){ + test(`${name} does not define a reserved DnsProvider field`, () => { + const collisions = Object.keys(ProviderClass._keyMap).filter(key => RESERVED.includes(key)); + assert.deepStrictEqual(collisions, [], `${name} redefines reserved field(s): ${collisions.join(', ')}`); + }); + } +}); + /** * Example: How to add tests for a new DNS provider *