Merge pull request #114 from theta42/feat/dynamic-dns
Dynamic DNS: keep A records pointed at the current public (WAN) IP
This commit is contained in:
@@ -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(){
|
||||
|
||||
@@ -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',
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
@@ -20,6 +20,11 @@ module.exports = {
|
||||
enabled: true,
|
||||
initial: 5000,
|
||||
interval: 86400000,
|
||||
},
|
||||
dynamicDns:{
|
||||
enabled: true,
|
||||
initial: 8000,
|
||||
interval: 14400000,
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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'])
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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){
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
'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}`;
|
||||
}
|
||||
|
||||
// Expose a derived fqdn to the client (REST list + websocket payloads both
|
||||
// serialize via toJSON), so the UI doesn't depend on client-side parsing.
|
||||
toJSON(){
|
||||
return {...super.toJSON(), fqdn: this.fqdn()};
|
||||
}
|
||||
|
||||
// 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);
|
||||
// Empty status on success so the UI only surfaces actual errors.
|
||||
await this.update({last_ip: ip, last_status: '', 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));
|
||||
@@ -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
@@ -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
@@ -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{
|
||||
|
||||
|
||||
@@ -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 = {};
|
||||
@@ -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}
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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};
|
||||
@@ -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};
|
||||
+92
-1
@@ -63,12 +63,26 @@
|
||||
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 +220,81 @@
|
||||
|
||||
</div>
|
||||
</div>
|
||||
<div class="row mb-3">
|
||||
<div class="col-12">
|
||||
<div class="card shadow-lg">
|
||||
<div class="card-header d-flex align-items-center">
|
||||
<span class="card-icon me-2"><i class="fa-solid fa-tower-broadcast"></i></span>
|
||||
<span class="card-title">Dynamic A Records</span>
|
||||
<span class="ms-auto text-muted small">
|
||||
This server's public IP:
|
||||
<span class="badge text-bg-primary fs-6"><i class="fa-solid fa-globe me-1"></i><span id="ddns-current-ip">…</span></span>
|
||||
</span>
|
||||
</div>
|
||||
<div class="card-header actionMessage" style="display:none"></div>
|
||||
|
||||
<div class="card-body">
|
||||
<p class="text-muted mb-3">
|
||||
These A records are pointed at this server's public IP automatically —
|
||||
every 4 hours and immediately when added. Enter <code>@</code> as the host
|
||||
for the domain apex.
|
||||
</p>
|
||||
|
||||
<!-- Add form -->
|
||||
<form action="dns/dynamic" onsubmit="formAJAX(this)" class="row g-2 align-items-end mb-4">
|
||||
<div class="col-12 col-md-5">
|
||||
<label 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-8 col-md-5">
|
||||
<label class="form-label">Host</label>
|
||||
<input type="text" name="name" class="form-control" placeholder="home · @ for apex" validate=":1" />
|
||||
<b class="invalid-feedback"></b>
|
||||
</div>
|
||||
<div class="col-4 col-md-2 d-grid">
|
||||
<button type="submit" class="btn btn-success"><i class="fa-solid fa-plus me-1"></i>Add</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<!-- Record list -->
|
||||
<div class="list-group">
|
||||
<div jq-repeat="DynamicRecord" jq-repeat-index="id"
|
||||
class="list-group-item d-flex align-items-center flex-wrap gap-2">
|
||||
|
||||
<div class="me-auto">
|
||||
<div class="fw-bold fs-5">
|
||||
<i class="fa-solid fa-circle-nodes text-secondary me-1"></i>{{ fqdn }}
|
||||
</div>
|
||||
<div class="small text-muted">
|
||||
{{#last_ip}}
|
||||
<i class="fa-solid fa-arrow-right-long"></i>
|
||||
<span class="badge text-bg-success">{{ last_ip }}</span>
|
||||
updated <span class="momentFromNow" data-date="{{ last_updated }}">recently</span>
|
||||
{{/last_ip}}
|
||||
{{^last_ip}}
|
||||
<span class="badge text-bg-secondary">awaiting first update…</span>
|
||||
{{/last_ip}}
|
||||
{{#last_status}}
|
||||
<span class="text-danger ms-2"><i class="fa-solid fa-triangle-exclamation me-1"></i>{{ last_status }}</span>
|
||||
{{/last_status}}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary" title="Update now"
|
||||
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-outline-danger" title="Remove"
|
||||
method="DELETE" action="dns/dynamic/{{id}}" onclick="formAJAX()">
|
||||
<i class="fa-solid fa-trash-can"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<%- include('bottom') %>
|
||||
|
||||
Reference in New Issue
Block a user