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.
This commit is contained in:
+2
-2
@@ -727,11 +727,11 @@ curl -H "Content-Type: application/json" \
|
|||||||
curl -H "Content-Type: application/json" \
|
curl -H "Content-Type: application/json" \
|
||||||
-H "auth-token: your-token-here" \
|
-H "auth-token: your-token-here" \
|
||||||
-X POST \
|
-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
|
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
|
[duckdns.org](https://www.duckdns.org) (e.g. `myhost` for
|
||||||
`myhost.duckdns.org`), since DuckDNS has no API to list them for you.
|
`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
|
DuckDNS only supports one A/AAAA record and one TXT record per domain (no
|
||||||
|
|||||||
+2
-2
@@ -720,11 +720,11 @@ curl -H "Content-Type: application/json" \
|
|||||||
curl -H "Content-Type: application/json" \
|
curl -H "Content-Type: application/json" \
|
||||||
-H "auth-token: your-token-here" \
|
-H "auth-token: your-token-here" \
|
||||||
-X POST \
|
-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
|
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
|
[duckdns.org](https://www.duckdns.org) (e.g. `myhost` for
|
||||||
`myhost.duckdns.org`), since DuckDNS has no API to list them for you.
|
`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
|
DuckDNS only supports one A/AAAA record and one TXT record per domain (no
|
||||||
|
|||||||
@@ -93,6 +93,13 @@ class DnsProvider extends Table{
|
|||||||
}
|
}
|
||||||
|
|
||||||
let Provider = providers[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};
|
let _keyMap = {...this._keyMap, ...Provider._keyMap};
|
||||||
|
|
||||||
return ({
|
return ({
|
||||||
|
|||||||
@@ -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
|
- 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.
|
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
|
- 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.
|
them being discovered like the other providers.
|
||||||
- Only one A record, one AAAA record, and one TXT record exist per domain,
|
- 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
|
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{
|
class DuckDns extends DnsApi{
|
||||||
static _keyMap = {
|
static _keyMap = {
|
||||||
token: {isRequired: true, type: 'string', isPrivate: true, displayName: 'Token'},
|
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';
|
static displayName = 'DuckDNS';
|
||||||
@@ -38,7 +38,7 @@ class DuckDns extends DnsApi{
|
|||||||
constructor(args){
|
constructor(args){
|
||||||
super()
|
super()
|
||||||
this.token = args.token;
|
this.token = args.token;
|
||||||
this.domains = args.domains;
|
this.subdomains = args.subdomains;
|
||||||
}
|
}
|
||||||
|
|
||||||
// DuckDNS has one endpoint for everything: setting ip/ipv6 updates the
|
// 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
|
// — the same thing an operator would need to do anyway when pointing a
|
||||||
// fresh DuckDNS domain at this proxy.
|
// fresh DuckDNS domain at this proxy.
|
||||||
async listDomains(){
|
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(','), {});
|
await this.update(labels.join(','), {});
|
||||||
|
|
||||||
return labels.map(label => ({domain: `${label}.duckdns.org`}));
|
return labels.map(label => ({domain: `${label}.duckdns.org`}));
|
||||||
|
|||||||
@@ -158,7 +158,7 @@ describe('DNS Provider Contract Compliance', () => {
|
|||||||
const DuckDns = require('../../models/dns_provider/duckdns');
|
const DuckDns = require('../../models/dns_provider/duckdns');
|
||||||
|
|
||||||
test('should meet DNS provider contract', () => {
|
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);
|
const instance = validateDnsProviderContract(DuckDns, mockCredentials);
|
||||||
|
|
||||||
assert.ok(instance, 'DuckDNS provider should be instantiated');
|
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.type, 'string');
|
||||||
assert.strictEqual(DuckDns._keyMap.token.isRequired, true);
|
assert.strictEqual(DuckDns._keyMap.token.isRequired, true);
|
||||||
assert.strictEqual(DuckDns._keyMap.token.isPrivate, true);
|
assert.strictEqual(DuckDns._keyMap.token.isPrivate, true);
|
||||||
assert.ok(DuckDns._keyMap.domains, 'Should require domains');
|
assert.ok(DuckDns._keyMap.subdomains, 'Should require subdomains');
|
||||||
assert.strictEqual(DuckDns._keyMap.domains.isRequired, true);
|
assert.strictEqual(DuckDns._keyMap.subdomains.isRequired, true);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should have valid method signatures', () => {
|
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);
|
validateMethodSignatures(instance);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should validate key mapping', () => {
|
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);
|
validateKeyMapping(instance);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should validate type checking', () => {
|
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);
|
validateTypeChecking(instance);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('rejects non A/AAAA/TXT record creation with a clear error', async () => {
|
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(
|
await assert.rejects(
|
||||||
() => instance.createRecord({domain: 'mockhost.duckdns.org'}, {type: 'CNAME', data: 'example.com'}),
|
() => instance.createRecord({domain: 'mockhost.duckdns.org'}, {type: 'CNAME', data: 'example.com'}),
|
||||||
/DuckDNS only supports A, AAAA and TXT records/
|
/DuckDNS only supports A, AAAA and TXT records/
|
||||||
@@ -197,12 +197,43 @@ describe('DNS Provider Contract Compliance', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('__label strips the .duckdns.org suffix', () => {
|
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');
|
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
|
* Example: How to add tests for a new DNS provider
|
||||||
*
|
*
|
||||||
|
|||||||
Reference in New Issue
Block a user