a2d194f855
For deployments on WAN DHCP, operators can declare A records in the DNS section
that the app updates to this box's current public IP every 4 hours (and
immediately on create).
- utils/public_ip.js: getPublicIp() queries external echo services (ipify +
fallbacks, configurable) with pure isIPv4/extractIp helpers.
- utils/dns_records.js: pure planARecordUpdate() reconciliation decision.
- models/dns_provider.js: Domain.upsertARecord(name, ip) — provider-agnostic
upsert via getRecords + deleteRecordById + createRecord (createRecord alone is
not a reliable cross-provider upsert). Apex ('@') handling added to each
provider (CloudFlare uses the domain name, Porkbun an empty name, DigitalOcean
'@') via a new DnsApi.apexName().
- models/dynamic_record.js: DynamicRecord model (deterministic id per host,
apply()/refreshAll()), registered + ModelPs-wrapped for live UI updates.
- services/dynamic_dns.js + conf: 4h scheduler mirroring host_scheduler.
- routes/dns.js: /dynamic CRUD + /dynamic/ip, gated to domain managers/admins.
- views/dns.ejs: "Dynamic A Records (WAN IP)" card with add form + list.
- test/unit/dynamic_record.test.js: public-IP parsing + reconciliation logic.
Verified end-to-end against a live Porkbun domain (create, idempotent, IP-change,
cleanup) plus unit suite (111 pass).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
126 lines
2.8 KiB
JavaScript
126 lines
2.8 KiB
JavaScript
'use strict';
|
|
|
|
const tldExtract = require('tld-extract').parse_host;
|
|
|
|
class DnsApi{
|
|
errors = {
|
|
unauthorized: ()=>{
|
|
let error = new Error('UnauthorizedDnsApi');
|
|
error.name = 'UnauthorizedDnsApi';
|
|
error.message = `Unauthorized call to ${this.constructor.name}`;
|
|
error.status = 424;
|
|
|
|
return error;
|
|
},
|
|
invalidInput: (keys)=>{
|
|
let error = new Error('InvalidInput');
|
|
error.name = 'InvalidInput';
|
|
error.message = `Required keys missing: ${keys.join(', ')}`
|
|
|
|
return error
|
|
|
|
},
|
|
other: (status, message, APIcode)=>{
|
|
let error = new Error('OtherDnsApiError');
|
|
error.name = 'OtherDnsApiError';
|
|
error.message = `DNS API Error ${this.constructor.name}: ${status} ${message}`;
|
|
error.status = 424;
|
|
error.APIcode = APIcode;
|
|
return error;
|
|
},
|
|
}
|
|
|
|
static info(){
|
|
let svgDataUrl = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(this.displayIconHtml)
|
|
.replace(/'/g, '%27')
|
|
.replace(/"/g, '%22')}`
|
|
|
|
return {
|
|
displayName: this.displayName,
|
|
displayIconUni: this.displayIconUni,
|
|
displayIconHtml: svgDataUrl,
|
|
fields: this._keyMap,
|
|
}
|
|
}
|
|
|
|
static toJSON(){
|
|
return {
|
|
...this.info(),
|
|
}
|
|
}
|
|
|
|
/*
|
|
No instance data should ever be shared, so just give the static level inf
|
|
*/
|
|
toJSON(){
|
|
return this.constructor.toJSON();
|
|
}
|
|
|
|
|
|
__typeCheck(type){
|
|
if(!['A', 'MX', 'CNAME', 'ALIAS', 'TXT', 'NS', 'AAAA', 'SRV', 'TLSA', 'CAA', 'HTTPS', 'SVCB'].includes(type)) throw new Error('PorkBun API: Invalid type passed')
|
|
}
|
|
|
|
// How this provider names an apex (root-of-domain) record. Callers pass the
|
|
// sentinel '@' for apex; each provider maps it to its own convention. Default
|
|
// is '@' (DigitalOcean). CloudFlare uses the full domain name; Porkbun uses
|
|
// an empty name. Sub-domain records are passed through unchanged.
|
|
apexName(domainName){
|
|
return '@';
|
|
}
|
|
|
|
|
|
/*
|
|
The API and the generic class interface have different opinions of what keys
|
|
hold what data, the __parseOptions and __pastseRes normal the keys to what
|
|
the class expects
|
|
|
|
What the the API calls it : What the class wants it as.
|
|
*/
|
|
__apiKeyMap = {};
|
|
|
|
__parseOptions(options, keys){
|
|
if(!options && !keys) return undefined;
|
|
|
|
if(keys){
|
|
let missingKeys = []
|
|
for(let key of keys){
|
|
if(!options[key]) missingKeys.push(key)
|
|
}
|
|
|
|
if(missingKeys.length) throw this.errors.invalidInput(missingKeys);
|
|
}
|
|
|
|
for(let [apiKey, clsKey] of Object.entries(this.__apiKeyMap)){
|
|
if(options[clsKey]){
|
|
options[apiKey] = options[clsKey];
|
|
delete options[clsKey];
|
|
}
|
|
}
|
|
|
|
if(options.type) this.__typeCheck(options.type);
|
|
|
|
return options;
|
|
}
|
|
|
|
__parseRes(data){
|
|
for(let item of data){
|
|
for(let [apiKey, clsKey] of Object.entries(this.__apiKeyMap)){
|
|
if(item[apiKey]){
|
|
item[clsKey] = item[apiKey];
|
|
}
|
|
}
|
|
try{
|
|
item.name = tldExtract(item.name).sub
|
|
}catch{}
|
|
}
|
|
|
|
return data;
|
|
}
|
|
}
|
|
|
|
module.exports = {
|
|
DnsApi,
|
|
};
|
|
|