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
+1
View File
@@ -32,6 +32,7 @@ app.contoller = require('./controller');
*/
require('./services/host_lookup');
require('./services/host_scheduler');
require('./services/dynamic_dns');
// Push pubsub over the socket and back.
app.onListen.push(function(){
+14
View File
@@ -61,6 +61,20 @@ module.exports = {
enabled: true,
initial: 30000,
interval: 86400000,
},
dynamicDns:{
enabled: true,
initial: 15000, // first refresh 15s after start
interval: 14400000, // then every 4 hours
}
},
// Dynamic DNS: services queried (in order) to learn this box's public IP.
dynamicDns:{
ipServices: [
'https://api.ipify.org',
'https://icanhazip.com',
'https://ifconfig.me/ip',
],
},
};
+5
View File
@@ -20,6 +20,11 @@ module.exports = {
enabled: true,
initial: 5000,
interval: 86400000,
},
dynamicDns:{
enabled: true,
initial: 8000,
interval: 14400000,
}
},
};
+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');
+3 -3
View File
@@ -11,10 +11,10 @@
"scripts": {
"start": "node ./bin/www",
"dev": "npx nodemon --ignore public/ ./bin/www",
"test": "node --test test/unit/callback_queue.test.js test/unit/host_lookup.test.js test/unit/wildcard_matchany.test.js test/unit/roles.test.js test/unit/oidc.test.js test/unit/safe_redirect.test.js test/unit/host_features.test.js test/unit/unix_socket.test.js test/integration/dns_provider.test.js",
"test:unit": "node --test test/unit/callback_queue.test.js test/unit/host_lookup.test.js test/unit/wildcard_matchany.test.js test/unit/roles.test.js test/unit/oidc.test.js test/unit/safe_redirect.test.js test/unit/host_features.test.js test/unit/unix_socket.test.js",
"test": "node --test test/unit/callback_queue.test.js test/unit/host_lookup.test.js test/unit/wildcard_matchany.test.js test/unit/roles.test.js test/unit/oidc.test.js test/unit/safe_redirect.test.js test/unit/host_features.test.js test/unit/dynamic_record.test.js test/unit/unix_socket.test.js test/integration/dns_provider.test.js",
"test:unit": "node --test test/unit/callback_queue.test.js test/unit/host_lookup.test.js test/unit/wildcard_matchany.test.js test/unit/roles.test.js test/unit/oidc.test.js test/unit/safe_redirect.test.js test/unit/host_features.test.js test/unit/dynamic_record.test.js test/unit/unix_socket.test.js",
"test:integration": "node --test test/integration/dns_provider.test.js",
"test:watch": "node --test --watch test/unit/callback_queue.test.js test/unit/host_lookup.test.js test/unit/wildcard_matchany.test.js test/unit/roles.test.js test/unit/oidc.test.js test/unit/safe_redirect.test.js test/unit/host_features.test.js test/unit/unix_socket.test.js test/integration/dns_provider.test.js"
"test:watch": "node --test --watch test/unit/callback_queue.test.js test/unit/host_lookup.test.js test/unit/wildcard_matchany.test.js test/unit/roles.test.js test/unit/oidc.test.js test/unit/safe_redirect.test.js test/unit/host_features.test.js test/unit/dynamic_record.test.js test/unit/unix_socket.test.js test/integration/dns_provider.test.js"
},
"engines": {
"node": ">=18.0.0"
+78 -1
View File
@@ -1,8 +1,10 @@
'use strict';
const router = require('express').Router();
const {DnsProvider, Domain} = require('../models').models;
const {DnsProvider, Domain, DynamicRecord} = require('../models').models;
const authz = require('../middleware/authz');
const {Grant} = require('../models/grant');
const {getPublicIp} = require('../utils/public_ip');
const Model = DnsProvider;
@@ -76,6 +78,81 @@ router.get('/domain/:item', authz.requireDomainRole('viewer', authz.resolve.doma
}
});
// ---- Dynamic DNS: A records kept pointed at this box's public (WAN) IP ----
// Registered before the '/:item' provider routes so '/dynamic' isn't captured.
// Current public IP, for the UI to display.
router.get('/dynamic/ip', async function(req, res, next){
try{
return res.json({ip: await getPublicIp()});
}catch(error){
return next(error);
}
});
// List dynamic records the caller may view (own/granted domains, or all for admin).
router.get('/dynamic', async function(req, res, next){
try{
let results = await DynamicRecord.listDetail();
results = await authz.filterViewable(req, results, r => r.domain);
return res.json({results});
}catch(error){
return next(error);
}
});
// Create a record (manager on its domain), then apply it immediately.
router.post('/dynamic', authz.requireDomainRole('manager', req => req.body.domain), async function(req, res, next){
try{
req.body.created_by = authz.reqUsername(req);
let item = await DynamicRecord.create(req.body);
// Point it at the current IP now rather than waiting for the next cycle.
// apply() swallows its own errors (recorded in last_status).
try{ await item.apply(await getPublicIp()); }catch(error){ /* scheduler will retry */ }
let record = await DynamicRecord.get(item.id);
return res.json({message: `"${record.fqdn()}" added.`, ...record});
}catch(error){
return next(error);
}
});
// Force an immediate refresh of one record (manager on its domain).
router.post('/dynamic/:id/refresh', async function(req, res, next){
try{
let record = await DynamicRecord.get(req.params.id);
let effective = await authz.getEffective(req);
if(!Grant.allows(effective, 'manager', authz.toDomain(record.domain))){
let error = new Error('Forbidden'); error.name = 'Forbidden'; error.status = 403;
error.message = `You need 'manager' rights on ${record.domain}.`;
throw error;
}
let result = await record.apply(await getPublicIp());
return res.json({message: `Refreshed "${record.fqdn()}".`, result});
}catch(error){
return next(error);
}
});
// Stop managing a record (manager on its domain). Leaves the provider A record
// in place at its last value.
router.delete('/dynamic/:id', async function(req, res, next){
try{
let record = await DynamicRecord.get(req.params.id);
let effective = await authz.getEffective(req);
if(!Grant.allows(effective, 'manager', authz.toDomain(record.domain))){
let error = new Error('Forbidden'); error.name = 'Forbidden'; error.status = 403;
error.message = `You need 'manager' rights on ${record.domain}.`;
throw error;
}
await record.remove();
return res.json({message: `${record.fqdn()} removed.`, ...record});
}catch(error){
return next(error);
}
});
router.get('/:item', authz.requireAdmin, async function(req, res, next){
try{
+29
View File
@@ -0,0 +1,29 @@
'use strict';
const conf = require('@simpleworkjs/conf');
const {DynamicRecord} = require('../models').models;
function dynamicDnsService(){
/**
* Dynamic DNS Service
*
* Keeps every declared DynamicRecord pointed at this deployment's current
* public (WAN) IP — for sites on WAN DHCP where the IP changes.
*
* Schedule (conf.service.dynamicDns):
* - Initial refresh shortly after start
* - Recurring refresh every 4 hours (14400000ms)
*
* refreshAll resolves the public IP once, then reconciles each record's A
* record via the domain's provider (create/replace only when it drifted).
*/
setTimeout(DynamicRecord.refreshAll.bind(DynamicRecord), conf.service.dynamicDns.initial);
setInterval(DynamicRecord.refreshAll.bind(DynamicRecord), conf.service.dynamicDns.interval);
console.log('Dynamic DNS service initialized');
console.log(`- Public IP refresh: ${conf.service.dynamicDns.initial / 1000}s after start, then every ${conf.service.dynamicDns.interval / 3600000}h`);
}
if(conf.service.dynamicDns && conf.service.dynamicDns.enabled !== false) dynamicDnsService();
module.exports = {};
+103
View File
@@ -0,0 +1,103 @@
'use strict';
const {describe, test} = require('node:test');
const assert = require('node:assert');
const {isIPv4, extractIp} = require('../../utils/public_ip');
const {planARecordUpdate} = require('../../utils/dns_records');
/**
* Pure helpers behind the dynamic-DNS feature: public-IP parsing and the
* A-record reconciliation decision. The provider I/O and redis-backed model are
* exercised manually (see the plan's verification section).
*/
describe('isIPv4', () => {
test('accepts valid dotted quads', () => {
assert.ok(isIPv4('1.2.3.4'));
assert.ok(isIPv4('255.255.255.255'));
assert.ok(isIPv4('0.0.0.0'));
assert.ok(isIPv4(' 10.0.0.1 ')); // trimmed
});
test('rejects out-of-range, malformed, IPv6, junk', () => {
assert.ok(!isIPv4('256.1.1.1'));
assert.ok(!isIPv4('1.2.3'));
assert.ok(!isIPv4('1.2.3.4.5'));
assert.ok(!isIPv4('::1'));
assert.ok(!isIPv4('example.com'));
assert.ok(!isIPv4(''));
assert.ok(!isIPv4(1234));
});
});
describe('extractIp', () => {
test('bare text response', () => {
assert.strictEqual(extractIp('203.0.113.7\n'), '203.0.113.7');
});
test('JSON object response (ipify ?format=json)', () => {
assert.strictEqual(extractIp({ip: '203.0.113.7'}), '203.0.113.7');
assert.strictEqual(extractIp({origin: '203.0.113.7'}), '203.0.113.7');
});
test('finds an embedded IPv4 in noisy text', () => {
assert.strictEqual(extractIp('Your IP is 203.0.113.7 today'), '203.0.113.7');
});
test('returns null for no/invalid IP', () => {
assert.strictEqual(extractIp('no ip here'), null);
assert.strictEqual(extractIp({ip: 'not-an-ip'}), null);
assert.strictEqual(extractIp(null), null);
assert.strictEqual(extractIp('2001:db8::1'), null);
});
});
describe('planARecordUpdate', () => {
const IP = '203.0.113.10';
test('creates when no matching record exists', () => {
assert.deepStrictEqual(
planARecordUpdate([], 'home', IP),
{deleteIds: [], create: true}
);
});
test('no-op when a correct record already exists', () => {
let recs = [{id: '1', name: 'home', data: IP}];
assert.deepStrictEqual(
planARecordUpdate(recs, 'home', IP),
{deleteIds: [], create: false}
);
});
test('deletes stale same-name records and re-creates on IP change', () => {
let recs = [{id: '1', name: 'home', data: '198.51.100.1'}];
assert.deepStrictEqual(
planARecordUpdate(recs, 'home', IP),
{deleteIds: ['1'], create: true}
);
});
test('deletes duplicates but keeps the correct one (no re-create)', () => {
let recs = [
{id: '1', name: 'home', data: IP},
{id: '2', name: 'home', data: '198.51.100.9'},
];
assert.deepStrictEqual(
planARecordUpdate(recs, 'home', IP),
{deleteIds: ['2'], create: false}
);
});
test('ignores records for other names', () => {
let recs = [{id: '1', name: 'other', data: '198.51.100.1'}];
assert.deepStrictEqual(
planARecordUpdate(recs, 'home', IP),
{deleteIds: [], create: true}
);
});
test('apex matches records whose parsed name is empty', () => {
let recs = [{id: '1', name: '', data: '198.51.100.1'}];
assert.deepStrictEqual(
planARecordUpdate(recs, '', IP),
{deleteIds: ['1'], create: true}
);
});
});
+21
View File
@@ -0,0 +1,21 @@
'use strict';
// Pure DNS-record reconciliation logic, kept out of models/dns_provider.js so it
// can be unit-tested without a redis connection. Used by Domain.upsertARecord.
/**
* Decide how to reconcile an A record to `ip`, given the domain's current A
* records (as returned by a provider's getRecords: {id, name, data}) and the
* target sub-name (`''` for apex).
*
* Returns {deleteIds: [...], create: bool}: delete every same-name A record whose
* value differs, and create a new one unless a correct one already exists.
*/
function planARecordUpdate(aRecords, sub, ip){
let matches = (aRecords || []).filter(r => (r.name || '') === sub);
let correct = matches.some(r => r.data === ip);
let deleteIds = matches.filter(r => r.data !== ip).map(r => r.id);
return {deleteIds, create: !correct};
}
module.exports = {planARecordUpdate};
+69
View File
@@ -0,0 +1,69 @@
'use strict';
// Discover this deployment's current public (WAN) IP by asking an external echo
// service. Used by the dynamic-DNS feature to keep A records pointed at the box
// even on WAN DHCP. Pure helpers (isIPv4/extractIp) are unit-tested; getPublicIp
// does the network calls.
const axios = require('axios');
const conf = require('@simpleworkjs/conf');
const DEFAULT_SERVICES = [
'https://api.ipify.org',
'https://icanhazip.com',
'https://ifconfig.me/ip',
];
// Strict dotted-quad IPv4 check (0-255 per octet).
function isIPv4(str){
if(typeof str !== 'string') return false;
let m = str.trim().match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/);
if(!m) return false;
for(let i = 1; i <= 4; i++){
if(Number(m[i]) > 255) return false;
}
return true;
}
// Pull an IPv4 out of a service response, which may be bare text ("1.2.3.4\n")
// or JSON ({"ip":"1.2.3.4"}). Returns the IP string or null.
function extractIp(body){
if(body === undefined || body === null) return null;
if(typeof body === 'object'){
let candidate = body.ip || body.address || body.origin;
return isIPv4(candidate) ? candidate.trim() : null;
}
let text = String(body).trim();
if(isIPv4(text)) return text;
// Some endpoints wrap the value; try to find the first IPv4 token.
let m = text.match(/\b(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})\b/);
return m && isIPv4(m[1]) ? m[1] : null;
}
// Try each configured service in order; return the first valid IPv4. Throws if
// they all fail so the caller can log and skip this cycle.
async function getPublicIp(){
let services = (conf.dynamicDns && conf.dynamicDns.ipServices) || DEFAULT_SERVICES;
let lastError;
for(let url of services){
try{
let res = await axios.get(url, {timeout: 5000, responseType: 'text'});
let ip = extractIp(res.data);
if(ip) return ip;
lastError = new Error(`No IPv4 in response from ${url}`);
}catch(error){
lastError = error;
}
}
let error = new Error('PublicIpUnavailable');
error.name = 'PublicIpUnavailable';
error.message = `Could not determine public IP: ${lastError && lastError.message}`;
throw error;
}
module.exports = {isIPv4, extractIp, getPublicIp, DEFAULT_SERVICES};
+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') %>