diff --git a/README.md b/README.md index 0f4d8f8..74b90a6 100755 --- a/README.md +++ b/README.md @@ -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 - 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 - Dynamic host routing with wildcard domain matching (*, **) - 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 - **DigitalOcean** - Requires API token - **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. diff --git a/docs/api.md b/docs/api.md index 093b4d1..901f0e0 100755 --- a/docs/api.md +++ b/docs/api.md @@ -687,7 +687,7 @@ curl -H "auth-token: your-token-here" \ ``` **Responses:** -- `200` `{"results": [{"name": "Cloudflare", "fields": {...}}, {"name": "DigitalOcean", ...}, ...]}` +- `200` `{"results": [{"name": "Cloudflare", "fields": {...}}, {"name": "DigitalOcean", ...}, {"name": "PorkBun", ...}, {"name": "DuckDns", ...}]}` ### Create DNS Provider @@ -722,6 +722,21 @@ curl -H "Content-Type: application/json" \ 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:** - `200` `{"message": "\"provider-id\" added.", ...}` - `422` Validation error or invalid API credentials diff --git a/docs/architecture.md b/docs/architecture.md index b848aac..6757d12 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -45,6 +45,7 @@ The proxy system consists of three main components working together to provide h │ - Cloudflare │ │ - DigitalOcean │ │ - PorkBun │ + │ - DuckDNS (free) │ │ (DNS-01 challenges) │ └──────────────────────┘ ``` diff --git a/docs/index.md b/docs/index.md index b2cab3a..592de3f 100644 --- a/docs/index.md +++ b/docs/index.md @@ -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 - **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 (*, **) - **RESTful API** - Full programmatic control - **Web Interface** - User-friendly management GUI diff --git a/nodejs/api.md b/nodejs/api.md index 113826d..d6ef8dc 100755 --- a/nodejs/api.md +++ b/nodejs/api.md @@ -680,7 +680,7 @@ curl -H "auth-token: your-token-here" \ ``` **Responses:** -- `200` `{"results": [{"name": "Cloudflare", "fields": {...}}, {"name": "DigitalOcean", ...}, ...]}` +- `200` `{"results": [{"name": "Cloudflare", "fields": {...}}, {"name": "DigitalOcean", ...}, {"name": "PorkBun", ...}, {"name": "DuckDns", ...}]}` ### Create DNS Provider @@ -715,6 +715,21 @@ curl -H "Content-Type: application/json" \ 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:** - `200` `{"message": "\"provider-id\" added.", ...}` - `422` Validation error or invalid API credentials diff --git a/nodejs/models/dns_provider.js b/nodejs/models/dns_provider.js index 6c0a671..04bdcaa 100644 --- a/nodejs/models/dns_provider.js +++ b/nodejs/models/dns_provider.js @@ -13,6 +13,7 @@ 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{ diff --git a/nodejs/models/dns_provider/duckdns.js b/nodejs/models/dns_provider/duckdns.js new file mode 100644 index 0000000..24caaed --- /dev/null +++ b/nodejs/models/dns_provider/duckdns.js @@ -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 = ` + + + + +` + + 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; diff --git a/nodejs/test/README.md b/nodejs/test/README.md index 5f379b5..8cad6bd 100644 --- a/nodejs/test/README.md +++ b/nodejs/test/README.md @@ -68,7 +68,7 @@ test/ **dns_provider.test.js** - DNS provider contract compliance -- All existing providers (Cloudflare, DigitalOcean, PorkBun) +- All existing providers (Cloudflare, DigitalOcean, PorkBun, DuckDNS) - Method signatures - Key mapping - Type validation diff --git a/nodejs/test/integration/dns_provider.test.js b/nodejs/test/integration/dns_provider.test.js index 2fc7e0f..827e0a8 100644 --- a/nodejs/test/integration/dns_provider.test.js +++ b/nodejs/test/integration/dns_provider.test.js @@ -153,6 +153,54 @@ describe('DNS Provider Contract Compliance', () => { 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'); + }); + }); }); /**