Add DuckDNS as a free DNS provider option (#124)
DuckDNS's API is smaller than the other providers' (no list/read API, no arbitrary sub-records, one A/AAAA + one TXT record per domain), so domains are entered by the operator instead of auto-discovered, and getRecords reads from public DNS since there's nothing else to query. Documented as a free option in the README and DNS provider docs.
This commit is contained in:
@@ -8,7 +8,7 @@ A reverse proxy and HTTPS termination service using OpenResty/nginx with a manag
|
|||||||
|
|
||||||
- Automated HTTPS/SSL certificate management via Let's Encrypt
|
- Automated HTTPS/SSL certificate management via Let's Encrypt
|
||||||
- Support for HTTP-01 (auto-ssl) and DNS-01 (wildcard) ACME challenges
|
- Support for HTTP-01 (auto-ssl) and DNS-01 (wildcard) ACME challenges
|
||||||
- Multiple DNS provider integrations (Cloudflare, DigitalOcean, PorkBun)
|
- Multiple DNS provider integrations (Cloudflare, DigitalOcean, PorkBun, DuckDNS — DuckDNS is free)
|
||||||
- Wildcard SSL certificate support with automatic renewal
|
- Wildcard SSL certificate support with automatic renewal
|
||||||
- Dynamic host routing with wildcard domain matching (*, **)
|
- Dynamic host routing with wildcard domain matching (*, **)
|
||||||
- Web-based management interface
|
- Web-based management interface
|
||||||
@@ -163,6 +163,13 @@ For wildcard SSL certificates, configure a DNS provider via the web UI or API:
|
|||||||
- **Cloudflare** - Requires API token
|
- **Cloudflare** - Requires API token
|
||||||
- **DigitalOcean** - Requires API token
|
- **DigitalOcean** - Requires API token
|
||||||
- **PorkBun** - Requires API key and secret API key
|
- **PorkBun** - Requires API key and secret API key
|
||||||
|
- **DuckDNS** - Free. Requires your account token and the list of subdomains
|
||||||
|
you've registered at [duckdns.org](https://www.duckdns.org) (e.g.
|
||||||
|
`myhost` for `myhost.duckdns.org`). Good option if you don't own a
|
||||||
|
domain — DuckDNS gives you one for free. Note DuckDNS only supports a
|
||||||
|
single A/AAAA record and a single TXT record per domain (no arbitrary
|
||||||
|
subdomains), which is enough for both dynamic DNS and DNS-01 wildcard
|
||||||
|
certs but not for hosting other DNS records.
|
||||||
|
|
||||||
Once configured, create a wildcard host (e.g., `*.example.com`) and the system will automatically request and manage the DNS-01 challenge certificate.
|
Once configured, create a wildcard host (e.g., `*.example.com`) and the system will automatically request and manage the DNS-01 challenge certificate.
|
||||||
|
|
||||||
|
|||||||
+16
-1
@@ -687,7 +687,7 @@ curl -H "auth-token: your-token-here" \
|
|||||||
```
|
```
|
||||||
|
|
||||||
**Responses:**
|
**Responses:**
|
||||||
- `200` `{"results": [{"name": "Cloudflare", "fields": {...}}, {"name": "DigitalOcean", ...}, ...]}`
|
- `200` `{"results": [{"name": "Cloudflare", "fields": {...}}, {"name": "DigitalOcean", ...}, {"name": "PorkBun", ...}, {"name": "DuckDns", ...}]}`
|
||||||
|
|
||||||
### Create DNS Provider
|
### Create DNS Provider
|
||||||
|
|
||||||
@@ -722,6 +722,21 @@ curl -H "Content-Type: application/json" \
|
|||||||
https://proxy-host.com/api/dns
|
https://proxy-host.com/api/dns
|
||||||
```
|
```
|
||||||
|
|
||||||
|
**DuckDNS (free):**
|
||||||
|
```bash
|
||||||
|
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"}' \
|
||||||
|
https://proxy-host.com/api/dns
|
||||||
|
```
|
||||||
|
|
||||||
|
`domains` 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
|
||||||
|
arbitrary sub-records) — enough for dynamic DNS and DNS-01 wildcard certs.
|
||||||
|
|
||||||
**Responses:**
|
**Responses:**
|
||||||
- `200` `{"message": "\"provider-id\" added.", ...}`
|
- `200` `{"message": "\"provider-id\" added.", ...}`
|
||||||
- `422` Validation error or invalid API credentials
|
- `422` Validation error or invalid API credentials
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ The proxy system consists of three main components working together to provide h
|
|||||||
│ - Cloudflare │
|
│ - Cloudflare │
|
||||||
│ - DigitalOcean │
|
│ - DigitalOcean │
|
||||||
│ - PorkBun │
|
│ - PorkBun │
|
||||||
|
│ - DuckDNS (free) │
|
||||||
│ (DNS-01 challenges) │
|
│ (DNS-01 challenges) │
|
||||||
└──────────────────────┘
|
└──────────────────────┘
|
||||||
```
|
```
|
||||||
|
|||||||
+1
-1
@@ -11,7 +11,7 @@ A reverse proxy and HTTPS termination service using OpenResty/nginx with a manag
|
|||||||
|
|
||||||
- **Automated HTTPS/SSL** - Let's Encrypt integration with HTTP-01 and DNS-01 challenges
|
- **Automated HTTPS/SSL** - Let's Encrypt integration with HTTP-01 and DNS-01 challenges
|
||||||
- **Wildcard SSL Certificates** - Support for wildcard domains with automatic renewal
|
- **Wildcard SSL Certificates** - Support for wildcard domains with automatic renewal
|
||||||
- **Multiple DNS Providers** - Cloudflare, DigitalOcean, PorkBun integrations
|
- **Multiple DNS Providers** - Cloudflare, DigitalOcean, PorkBun, DuckDNS (free) integrations
|
||||||
- **Advanced Routing** - Sophisticated wildcard domain matching (*, **)
|
- **Advanced Routing** - Sophisticated wildcard domain matching (*, **)
|
||||||
- **RESTful API** - Full programmatic control
|
- **RESTful API** - Full programmatic control
|
||||||
- **Web Interface** - User-friendly management GUI
|
- **Web Interface** - User-friendly management GUI
|
||||||
|
|||||||
+16
-1
@@ -680,7 +680,7 @@ curl -H "auth-token: your-token-here" \
|
|||||||
```
|
```
|
||||||
|
|
||||||
**Responses:**
|
**Responses:**
|
||||||
- `200` `{"results": [{"name": "Cloudflare", "fields": {...}}, {"name": "DigitalOcean", ...}, ...]}`
|
- `200` `{"results": [{"name": "Cloudflare", "fields": {...}}, {"name": "DigitalOcean", ...}, {"name": "PorkBun", ...}, {"name": "DuckDns", ...}]}`
|
||||||
|
|
||||||
### Create DNS Provider
|
### Create DNS Provider
|
||||||
|
|
||||||
@@ -715,6 +715,21 @@ curl -H "Content-Type: application/json" \
|
|||||||
https://proxy-host.com/api/dns
|
https://proxy-host.com/api/dns
|
||||||
```
|
```
|
||||||
|
|
||||||
|
**DuckDNS (free):**
|
||||||
|
```bash
|
||||||
|
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"}' \
|
||||||
|
https://proxy-host.com/api/dns
|
||||||
|
```
|
||||||
|
|
||||||
|
`domains` 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
|
||||||
|
arbitrary sub-records) — enough for dynamic DNS and DNS-01 wildcard certs.
|
||||||
|
|
||||||
**Responses:**
|
**Responses:**
|
||||||
- `200` `{"message": "\"provider-id\" added.", ...}`
|
- `200` `{"message": "\"provider-id\" added.", ...}`
|
||||||
- `422` Validation error or invalid API credentials
|
- `422` Validation error or invalid API credentials
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ const providers = {
|
|||||||
Cloudflare: require('./dns_provider/cloudflare'),
|
Cloudflare: require('./dns_provider/cloudflare'),
|
||||||
DigitalOcean: require('./dns_provider/digitalocean'),
|
DigitalOcean: require('./dns_provider/digitalocean'),
|
||||||
PorkBun: require('./dns_provider/porkbun'),
|
PorkBun: require('./dns_provider/porkbun'),
|
||||||
|
DuckDns: require('./dns_provider/duckdns'),
|
||||||
};
|
};
|
||||||
|
|
||||||
class Domain extends Table{
|
class Domain extends Table{
|
||||||
|
|||||||
@@ -0,0 +1,137 @@
|
|||||||
|
'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 `domains` 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'},
|
||||||
|
domains: {isRequired: true, type: 'string', displayName: 'Domains (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.domains = args.domains;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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.domains.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;
|
||||||
@@ -68,7 +68,7 @@ test/
|
|||||||
|
|
||||||
**dns_provider.test.js**
|
**dns_provider.test.js**
|
||||||
- DNS provider contract compliance
|
- DNS provider contract compliance
|
||||||
- All existing providers (Cloudflare, DigitalOcean, PorkBun)
|
- All existing providers (Cloudflare, DigitalOcean, PorkBun, DuckDNS)
|
||||||
- Method signatures
|
- Method signatures
|
||||||
- Key mapping
|
- Key mapping
|
||||||
- Type validation
|
- Type validation
|
||||||
|
|||||||
@@ -153,6 +153,54 @@ describe('DNS Provider Contract Compliance', () => {
|
|||||||
validateTypeChecking(instance);
|
validateTypeChecking(instance);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('DuckDNS Provider', () => {
|
||||||
|
const DuckDns = require('../../models/dns_provider/duckdns');
|
||||||
|
|
||||||
|
test('should meet DNS provider contract', () => {
|
||||||
|
const mockCredentials = {token: 'mock-token', domains: 'mockhost'};
|
||||||
|
const instance = validateDnsProviderContract(DuckDns, mockCredentials);
|
||||||
|
|
||||||
|
assert.ok(instance, 'DuckDNS provider should be instantiated');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should have correct _keyMap structure', () => {
|
||||||
|
assert.ok(DuckDns._keyMap.token, 'Should require token');
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should have valid method signatures', () => {
|
||||||
|
const instance = new DuckDns({token: 'mock-token', domains: 'mockhost'});
|
||||||
|
validateMethodSignatures(instance);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should validate key mapping', () => {
|
||||||
|
const instance = new DuckDns({token: 'mock-token', domains: 'mockhost'});
|
||||||
|
validateKeyMapping(instance);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should validate type checking', () => {
|
||||||
|
const instance = new DuckDns({token: 'mock-token', domains: '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'});
|
||||||
|
await assert.rejects(
|
||||||
|
() => instance.createRecord({domain: 'mockhost.duckdns.org'}, {type: 'CNAME', data: 'example.com'}),
|
||||||
|
/DuckDNS only supports A, AAAA and TXT records/
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('__label strips the .duckdns.org suffix', () => {
|
||||||
|
const instance = new DuckDns({token: 'mock-token', domains: 'mockhost'});
|
||||||
|
assert.strictEqual(instance.__label({domain: 'mockhost.duckdns.org'}), 'mockhost');
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
Reference in New Issue
Block a user