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
+80 -1
View File
@@ -63,12 +63,32 @@
return row
};
$.scope.DynamicRecord.parseData = function(row){
row['fqdn'] = (row.name === '@' || !row.name) ? row.domain : `${row.name}.${row.domain}`;
row['last_updated_text'] = row.last_updated ? moment(row.last_updated, "x").fromNow() : 'never';
return row;
};
async function ddnsLoadCurrentIp(){
try{
let res = await app.api.get('dns/dynamic/ip');
$('#ddns-current-ip').text(res.ip);
}catch(error){
$('#ddns-current-ip').text('unavailable');
}
}
$(document).ready(async function(){
// Set the jq Templates
$.scope.providerField.put = function(){};
$.scope.DnsProvider.push(...(await app.api.get('dns?detail=true')).results);
// Populate
// Dynamic DNS: existing records, the domain dropdown, and the current IP.
$.scope.DynamicRecord.push(...(await app.api.get('dns/dynamic')).results);
$.scope.ddnsDomain.push(...(await app.api.get('dns/domain?detail=true')).results);
ddnsLoadCurrentIp();
// Populate
providerGet();
app.subscribe(/^model:/, function(data, topic){
@@ -206,4 +226,63 @@
</div>
</div>
<div class="row mb-3">
<div class="col-12">
<div class="card shadow-lg">
<div class="card-header">
<span class="card-icon float-start"><i class="fa-solid fa-tower-broadcast"></i></span>
<span class="card-title">Dynamic A Records (WAN IP)</span>
<span class="float-end">Current public IP: <b id="ddns-current-ip">…</b></span>
</div>
<div class="card-header actionMessage" style="display:none"></div>
<div class="card-body">
<p class="text-muted">
These A records are updated to this server's current public IP every 4 hours
(and immediately when added). Use <b>@</b> as the subdomain for the domain apex.
</p>
<form action="dns/dynamic" onsubmit="formAJAX(this)" class="row g-2 align-items-end mb-3">
<div class="col-md-4 form-group">
<label for="domain" class="form-label">Domain</label>
<select name="domain" class="form-select" validate=":1">
<option value="" selected>Select a domain</option>
<option jq-repeat="ddnsDomain" value="{{domain}}">{{domain}}</option>
</select>
</div>
<div class="col-md-4 form-group">
<label for="name" class="form-label">Subdomain</label>
<input type="text" name="name" class="form-control" placeholder="ex: home (or @ for apex)" validate=":1" />
</div>
<div class="col-md-4 form-group">
<button type="submit" class="btn btn-success">
<i class="fa-solid fa-plus"></i> Add
</button>
</div>
</form>
<table class="table table-sm align-middle">
<thead>
<tr><th>Host</th><th>Current IP</th><th>Last updated</th><th>Status</th><th class="text-end">Actions</th></tr>
</thead>
<tbody>
<tr jq-repeat="DynamicRecord" jq-repeat-index="id">
<td><b>{{ fqdn }}</b></td>
<td>{{ last_ip }}</td>
<td>{{ last_updated_text }}</td>
<td>{{ last_status }}</td>
<td class="text-end">
<button type="button" class="btn btn-sm btn-warning" method="POST" action="dns/dynamic/{{id}}/refresh" onclick="formAJAX()">
<i class="fa-solid fa-rotate"></i>
</button>
<button type="button" class="btn btn-sm btn-danger" method="DELETE" action="dns/dynamic/{{id}}" onclick="formAJAX()">
<i class="fa-solid fa-trash-can"></i>
</button>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
<%- include('bottom') %>