7d9c63b049
Air-gap:
- DynamicRecord.refreshAll() called getPublicIp() (api.ipify.org,
icanhazip.com, ifconfig.me) every 4h on a timer regardless of
whether any dynamic records were configured -- the one background
call in the repo not actually gated by feature use. Now skips the
lookup entirely when there's nothing to refresh.
- Removed the stray, unauthenticated GET /test page (a leftover
jq-repeat demo) that loaded jQuery + Mustache from external CDNs.
- Removed a dead IE<9-only html5shim script tag pointing at a domain
that no longer resolves.
Docs:
- New GET /docs (index) and /docs/:slug routes render this project's
own README, DEPLOYMENT, api.md, and docs/*.md server-side via
marked (new dependency) -- so the documentation is readable from
the running app with no route to GitHub Pages, where it otherwise
only lives. Public, no auth, same tier as the health endpoint.
- .dockerignore/Dockerfile updated: docs/, DEPLOYMENT.md, and
nodejs/api.md were previously excluded from the image entirely
("served via GitHub Pages, not from the image") -- now copied in
alongside README.md/tos.md-style, since they're needed at runtime.
106 lines
3.6 KiB
JavaScript
106 lines
3.6 KiB
JavaScript
'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. Checked
|
|
// BEFORE the public-IP lookup: on a stock install with zero dynamic
|
|
// records configured, this runs on a timer regardless (services/dynamic_dns.js)
|
|
// -- without this guard it would still reach out to the public-IP
|
|
// resolvers (utils/public_ip.js) every cycle for nothing, which is
|
|
// exactly the kind of always-on external call an air-gapped deployment
|
|
// can't have.
|
|
static async refreshAll(){
|
|
let records = await this.listDetail();
|
|
if(!records.length) return {count: 0};
|
|
|
|
let ip;
|
|
try{
|
|
ip = await getPublicIp();
|
|
}catch(error){
|
|
console.error('DynamicRecord.refreshAll: public IP lookup failed', error.message);
|
|
return {error: error.message};
|
|
}
|
|
|
|
for(let record of records){
|
|
await record.apply(ip);
|
|
}
|
|
return {ip, count: records.length};
|
|
}
|
|
}
|
|
|
|
DynamicRecord.register(ModelPs(DynamicRecord));
|