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));