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:
2026-07-10 23:55:56 -04:00
parent 1f7a9e5ded
commit a2d194f855
17 changed files with 542 additions and 7 deletions
+23
View File
@@ -7,6 +7,7 @@ const Table = require('.');
const ModelPs = require('../utils/model_pubsub');
const tldExtract = require('tld-extract').parse_host;
const {planARecordUpdate} = require('../utils/dns_records');
const providers = {
Cloudflare: require('./dns_provider/cloudflare'),
@@ -46,6 +47,28 @@ class Domain extends Table{
async deleteRecords(...args){
return await this.provider.api.deleteRecords(this, ...args);
}
/**
* Provider-agnostic upsert of a single A record to `ip`. Providers'
* createRecord is not a reliable cross-provider upsert (CloudFlare returns the
* stale record on a duplicate, DigitalOcean duplicates, CloudFlare's
* deleteRecords is a no-op), but all three expose getRecords + deleteRecordById,
* so reconcile explicitly. `name` is a sub-label or '@' for apex.
*/
async upsertARecord(name, ip){
let sub = (name === '@' || name === '') ? '' : name;
let aRecords = await this.getRecords({type: 'A'});
let {deleteIds, create} = planARecordUpdate(aRecords, sub, ip);
for(let id of deleteIds){
await this.provider.api.deleteRecordById(this, id);
}
if(create){
await this.createRecord({type: 'A', name: (sub === '' ? '@' : sub), data: ip});
}
return {ip, changed: create || deleteIds.length > 0};
}
}
Domain.register(ModelPs(Domain));
+6
View File
@@ -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'])
+8
View File
@@ -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);
+9 -2
View File
@@ -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){
+90
View File
@@ -0,0 +1,90 @@
'use strict';
const Table = require('.');
const ModelPs = require('../utils/model_pubsub');
const {getPublicIp} = require('../utils/public_ip');
/**
* DynamicRecord
*
* A declared A record that the app keeps pointed at this deployment's current
* public (WAN) IP, refreshed on a schedule (services/dynamic_dns.js) and on
* create. `name` is a sub-label, or '@' for the domain apex. One record per
* (domain, name) — the id is deterministic so re-adding updates in place.
*/
class DynamicRecord extends Table{
static _key = 'id';
static _keyMap = {
'created_by': {isRequired: true, type: 'string', min: 3, max: 500},
'created_on': {default: function(){return (new Date).getTime()}},
'updated_on': {default: function(){return (new Date).getTime()}, always: true},
'id': {isRequired: true, type: 'string', min: 1, max: 600},
'domain': {isRequired: true, type: 'string'},
'name': {isRequired: true, type: 'string'},
'last_ip': {isRequired: false, type: 'string'},
'last_status': {isRequired: false, type: 'string'},
'last_updated': {isRequired: false, type: 'number'},
}
// Deterministic id so the same (domain, name) is a single record.
static mkId({domain, name}){
return `${name || '@'}:${domain}`;
}
static async create(data){
// Require an existing Domain (also gives us provider access for updates).
let Domain = require('.').models.Domain;
await Domain.get(data.domain); // throws EntryNotFound if unknown
data.id = this.mkId(data);
// Upsert: replace an existing record for the same host rather than 409ing.
try{
let existing = await this.get(data.id);
if(existing) await existing.remove();
}catch(error){ /* not found is fine */ }
return super.create(data);
}
// Full hostname this record represents.
fqdn(){
return (this.name === '@' || !this.name) ? this.domain : `${this.name}.${this.domain}`;
}
// Point this record at `ip` and record the outcome. Never throws — a single
// bad record must not abort a whole refresh cycle.
async apply(ip){
try{
let Domain = require('.').models.Domain;
let domain = await Domain.get(this.domain);
let res = await domain.upsertARecord(this.name, ip);
await this.update({last_ip: ip, last_status: 'ok', last_updated: Date.now()});
return res;
}catch(error){
console.error('DynamicRecord.apply', this.id, error.message);
try{
await this.update({last_status: String(error.message).slice(0, 480), last_updated: Date.now()});
}catch(e){ /* best effort */ }
return {error: error.message};
}
}
// Resolve the public IP once, then reconcile every record to it.
static async refreshAll(){
let ip;
try{
ip = await getPublicIp();
}catch(error){
console.error('DynamicRecord.refreshAll: public IP lookup failed', error.message);
return {error: error.message};
}
let records = await this.listDetail();
for(let record of records){
await record.apply(ip);
}
return {ip, count: records.length};
}
}
DynamicRecord.register(ModelPs(DynamicRecord));
+1
View File
@@ -7,6 +7,7 @@ const Table = setUpTable(conf.redis);
module.exports = Table;
require('./dns_provider');
require('./dynamic_record');
require('./host');
require('./token');
require('./user');