Add dynamic DNS: keep A records pointed at the current public IP
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>
This commit is contained in:
@@ -90,8 +90,14 @@ class CloudFlare extends DnsApi{
|
||||
});
|
||||
}
|
||||
|
||||
// CloudFlare names an apex record with the full domain (e.g. example.com).
|
||||
apexName(domainName){
|
||||
return domainName;
|
||||
}
|
||||
|
||||
async createRecord(domain, options){
|
||||
try{
|
||||
if(options.name === '@') options.name = this.apexName(domain.domain);
|
||||
let res = await this.axios('post',
|
||||
`${domain.zoneId}/dns_records`,
|
||||
this.__parseOptions(options, ['type', 'name', 'data'])
|
||||
|
||||
@@ -61,6 +61,14 @@ class DnsApi{
|
||||
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
|
||||
|
||||
@@ -70,6 +70,8 @@ class DigitalOcean extends DnsApi{
|
||||
}
|
||||
|
||||
async createRecord(domain, options){
|
||||
// DigitalOcean names an apex record '@' (the base apexName default).
|
||||
if(options.name === '@') options.name = this.apexName(domain.domain);
|
||||
options = this.__parseOptions(options, ['type', 'name', 'data']);
|
||||
let res = await this.axios('post', `/domains/${domain.domain}/records`, options);
|
||||
|
||||
|
||||
@@ -93,10 +93,17 @@ class PorkBun extends DnsApi{
|
||||
});
|
||||
}
|
||||
|
||||
// Porkbun names an apex record with an empty name.
|
||||
apexName(domainName){
|
||||
return '';
|
||||
}
|
||||
|
||||
async createRecord(domain, options, force = true){
|
||||
try{
|
||||
// Throw errors for missing keys, do this first.
|
||||
options = this.__parseOptions(options, ['type', 'name', 'data']);
|
||||
// Apex ('@') maps to an empty name for Porkbun; because that name is
|
||||
// legitimately falsy, only type+data are required here (not name).
|
||||
if(options.name === '@') options.name = this.apexName(domain.domain);
|
||||
options = this.__parseOptions(options, ['type', 'data']);
|
||||
|
||||
// Delete the current records
|
||||
if(force){
|
||||
|
||||
Reference in New Issue
Block a user