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:
@@ -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));
|
||||
Reference in New Issue
Block a user