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