From a2d194f855d975141cf0c5e1ce0a53a9442b9910 Mon Sep 17 00:00:00 2001 From: William Mantly Date: Fri, 10 Jul 2026 23:55:56 -0400 Subject: [PATCH] Add dynamic DNS: keep A records pointed at the current public IP MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- nodejs/app.js | 1 + nodejs/conf/base.js | 14 +++ nodejs/conf/development.js | 5 + nodejs/models/dns_provider.js | 23 +++++ nodejs/models/dns_provider/cloudflare.js | 6 ++ nodejs/models/dns_provider/common.js | 8 ++ nodejs/models/dns_provider/digitalocean.js | 2 + nodejs/models/dns_provider/porkbun.js | 11 ++- nodejs/models/dynamic_record.js | 90 ++++++++++++++++++ nodejs/models/index.js | 1 + nodejs/package.json | 6 +- nodejs/routes/dns.js | 79 +++++++++++++++- nodejs/services/dynamic_dns.js | 29 ++++++ nodejs/test/unit/dynamic_record.test.js | 103 +++++++++++++++++++++ nodejs/utils/dns_records.js | 21 +++++ nodejs/utils/public_ip.js | 69 ++++++++++++++ nodejs/views/dns.ejs | 81 +++++++++++++++- 17 files changed, 542 insertions(+), 7 deletions(-) create mode 100644 nodejs/models/dynamic_record.js create mode 100644 nodejs/services/dynamic_dns.js create mode 100644 nodejs/test/unit/dynamic_record.test.js create mode 100644 nodejs/utils/dns_records.js create mode 100644 nodejs/utils/public_ip.js diff --git a/nodejs/app.js b/nodejs/app.js index a829835..b8b644f 100755 --- a/nodejs/app.js +++ b/nodejs/app.js @@ -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(){ diff --git a/nodejs/conf/base.js b/nodejs/conf/base.js index a10c2fe..bb74a45 100644 --- a/nodejs/conf/base.js +++ b/nodejs/conf/base.js @@ -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', + ], + }, }; diff --git a/nodejs/conf/development.js b/nodejs/conf/development.js index 12c5167..afb275b 100644 --- a/nodejs/conf/development.js +++ b/nodejs/conf/development.js @@ -20,6 +20,11 @@ module.exports = { enabled: true, initial: 5000, interval: 86400000, + }, + dynamicDns:{ + enabled: true, + initial: 8000, + interval: 14400000, } }, }; diff --git a/nodejs/models/dns_provider.js b/nodejs/models/dns_provider.js index 7d1eefc..e5d09dd 100644 --- a/nodejs/models/dns_provider.js +++ b/nodejs/models/dns_provider.js @@ -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)); diff --git a/nodejs/models/dns_provider/cloudflare.js b/nodejs/models/dns_provider/cloudflare.js index c44abe8..fb2b0f7 100644 --- a/nodejs/models/dns_provider/cloudflare.js +++ b/nodejs/models/dns_provider/cloudflare.js @@ -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']) diff --git a/nodejs/models/dns_provider/common.js b/nodejs/models/dns_provider/common.js index 32f88a2..24540c2 100644 --- a/nodejs/models/dns_provider/common.js +++ b/nodejs/models/dns_provider/common.js @@ -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 diff --git a/nodejs/models/dns_provider/digitalocean.js b/nodejs/models/dns_provider/digitalocean.js index fc3cad8..d3f6ede 100644 --- a/nodejs/models/dns_provider/digitalocean.js +++ b/nodejs/models/dns_provider/digitalocean.js @@ -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); diff --git a/nodejs/models/dns_provider/porkbun.js b/nodejs/models/dns_provider/porkbun.js index 0fe86c6..99c3e95 100644 --- a/nodejs/models/dns_provider/porkbun.js +++ b/nodejs/models/dns_provider/porkbun.js @@ -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){ diff --git a/nodejs/models/dynamic_record.js b/nodejs/models/dynamic_record.js new file mode 100644 index 0000000..1dfa812 --- /dev/null +++ b/nodejs/models/dynamic_record.js @@ -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)); diff --git a/nodejs/models/index.js b/nodejs/models/index.js index beef71a..47912e5 100644 --- a/nodejs/models/index.js +++ b/nodejs/models/index.js @@ -7,6 +7,7 @@ const Table = setUpTable(conf.redis); module.exports = Table; require('./dns_provider'); +require('./dynamic_record'); require('./host'); require('./token'); require('./user'); diff --git a/nodejs/package.json b/nodejs/package.json index b43a35c..8ce6bfc 100644 --- a/nodejs/package.json +++ b/nodejs/package.json @@ -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" diff --git a/nodejs/routes/dns.js b/nodejs/routes/dns.js index ee8a48f..d798efc 100644 --- a/nodejs/routes/dns.js +++ b/nodejs/routes/dns.js @@ -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{ diff --git a/nodejs/services/dynamic_dns.js b/nodejs/services/dynamic_dns.js new file mode 100644 index 0000000..0640580 --- /dev/null +++ b/nodejs/services/dynamic_dns.js @@ -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 = {}; diff --git a/nodejs/test/unit/dynamic_record.test.js b/nodejs/test/unit/dynamic_record.test.js new file mode 100644 index 0000000..405ffe9 --- /dev/null +++ b/nodejs/test/unit/dynamic_record.test.js @@ -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} + ); + }); +}); diff --git a/nodejs/utils/dns_records.js b/nodejs/utils/dns_records.js new file mode 100644 index 0000000..280716d --- /dev/null +++ b/nodejs/utils/dns_records.js @@ -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}; diff --git a/nodejs/utils/public_ip.js b/nodejs/utils/public_ip.js new file mode 100644 index 0000000..1a53765 --- /dev/null +++ b/nodejs/utils/public_ip.js @@ -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}; diff --git a/nodejs/views/dns.ejs b/nodejs/views/dns.ejs index df970cb..12e6690 100644 --- a/nodejs/views/dns.ejs +++ b/nodejs/views/dns.ejs @@ -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 @@ +
+
+
+
+ + Dynamic A Records (WAN IP) + Current public IP: +
+ +
+

+ These A records are updated to this server's current public IP every 4 hours + (and immediately when added). Use @ as the subdomain for the domain apex. +

+ +
+
+ + +
+
+ + +
+
+ +
+
+ + + + + + + + + + + + + + +
HostCurrent IPLast updatedStatusActions
{{ fqdn }}{{ last_ip }}{{ last_updated_text }}{{ last_status }} + + +
+
+
+
+
<%- include('bottom') %>