tr-ddns
Version:
Simple DDNS Server
421 lines (399 loc) • 9.8 kB
JavaScript
'use strict'
const EventEmitter = require('node:events');
const { Packet } = require('dns2');
const ipaddr = require('ipaddr.js');
const nullish = require('./nullish');
const log = require('./log');
class NameDB extends EventEmitter {
#hosts;
#domains;
#debug;
#stat;
constructor(config) {
super();
this.#debug = config?.debug ? true : false;
this.#stat = { lookup: { total: 0, errors: 0 } };
this.#hosts = new Map();
this.#domains = new Map();
setInterval(function() { this.#updateSerials()}.bind(this), 3600000);
}
#serial() {
return (Math.floor((Date.now() / 15)) % 4294967295) + 1;
}
#updateSerials() {
let ns = this.#serial();
for (let d of this.#domains.values()) {
let os = d.serial;
if ((ns > os) || ((os - ns) > 2147483648)) {
d.serial = ns;
}
}
}
#incrementSerial(lcdomain) {
let d = this.#domains.get(lcdomain);
if (d?.serial) {
d.serial = (d.serial + 1) % 4294967296;
if (d.serial == 0) {
d.serial = 1;
}
return true;
}
return false;
}
addDomain(domain) {
if (! this.validDomain(domain)) {
throw new Error('Invalid domain');
}
let lcdomain = domain.toLowerCase();
for (let k of this.#domains.keys()) {
if (k === lcdomain) {
let msg = 'Domain ' + lcdomain + 'is already included';
throw new Error(msg);
}
if ((k.length > lcdomain.length) && ((k.slice(-1 - lcdomain.length)) === ('.' + lcdomain))) {
let msg = 'Already included domain ' + k + ' is a subdomain of ' + lcdomain;
throw new Error(msg);
}
if ((lcdomain.length > k.length) && ((lcdomain.slice(-1 - k.length)) === ('.' + k))) {
let msg = 'Domain ' + lcdomain + ' is a subdomain of already included domain ' + k;
throw new Error(msg);
}
}
let serial = this.#serial();
this.#domains.set(lcdomain, { name: domain, lcname: lcdomain, serial, timeout: null, expires: null } );
this.emit('adddomain', lcdomain);
}
#searchDomain(lcname) {
let components = lcname.split('.').reverse();
if (components.length > 0) {
let n = components.shift();
while (true) {
let d = this.#domains.get(n);
if (d) {
return d;
}
if (components.length < 1) {
break;
}
n = components.shift() + '.' + n;
}
}
return false;
}
removeDomain(domain) {
if (! (typeof(domain) === 'string')) {
return false;
}
let lcdomain = domain.toLowerCase();
if (! this.#domains.has(lcdomain)) {
return false;
}
for (let n of this.#hosts.entries()) {
if (n[1].domain === lcdomain) {
if (n[1].timeout) {
clearTimeout(n[1].timeout);
n[1].timeout = null;
}
this.#hosts.delete(n[0]);
}
}
this.#domains.delete(lcdomain);
this.emit('removedomain', lcdomain);
return true;
}
set(name, data, ttlMs, merge) {
if (! this.valid(name)) {
if (this.#debug) {
log('Host name is invalid');
}
return false;
}
let lcname = name.toLowerCase();
let d = this.#searchDomain(lcname);
if (d === false) {
if (this.#debug) {
log('Host name is outside of accepted domains');
}
return false;
}
if (! (data && (typeof(data) === 'object'))) {
return false;
}
let n = { name: name, lcname: lcname, domain: d.lcname, data: {}, timeout: null };
let deleteA = false;
if ((typeof(data?.a) === 'string') && (ipaddr.IPv4.isValid(data.a))) {
n.data.a = data.a;
} else if (data?.a === '') {
deleteA = true;
n.data.a = null;
} else if (nullish(data?.a)) {
n.data.a = null;
} else {
return false;
}
let deleteAAAA = false;
if ((typeof(data?.aaaa) === 'string') && (ipaddr.IPv6.isValid(data.aaaa))) {
n.data.aaaa = ipaddr.IPv6.parse(data.aaaa).toNormalizedString();
} else if (data?.aaaa === '') {
deleteAAAA = true;
n.data.aaaa = null;
} else if (nullish(data?.aaaa)) {
n.data.aaaa = null;
} else {
return false;
}
let deleteTXT = false;
if ((typeof(data?.txt) === 'string') && (data.txt !== '')) {
n.data.txt = data.txt;
} else if (data?.txt === '') {
deleteTXT = true;
n.data.txt = null;
} else if (nullish(data?.txt)) {
n.data.txt = null;
} else {
return false;
}
let deleteMX = false;
if (this.valid(data?.mx) && (data.mx !== '')) {
n.data.mx = data.mx;
} else if (data?.mx === '') {
deleteMX = true;
n.data.mx = null;
} else if (nullish(data?.mx)) {
n.data.mx = null;
} else {
return false;
}
if (Number.isSafeInteger(ttlMs) && (ttlMs > 0) && (ttlMs <= 2147483647)) {
n.timeout = setTimeout(function() { let d = this.#hosts.get(lcname);
if (this.#debug) {
log('ttl exceeded:', lcname);
}
if (d) {
d.timeout = null;
this.#hosts.delete(lcname);
this.emit('remove', lcname);
}
}.bind(this),
ttlMs);
n.expires = Date.now() + ttlMs;
} else if (nullish(ttlMs) || (ttlMs === 0)) {
n.timeout = null;
n.expires = null;
} else {
return false;
}
let o = this.#hosts.get(lcname);
if (o) {
if (o.timeout) {
clearTimeout(o.timeout);
o.timeout = null;
}
if (merge) {
if (nullish(n.data.a) && (! nullish(o.data.a)) && (! deleteA)) {
n.data.a = o.data.a;
}
if (nullish(n.data.aaaa) && (! nullish(o.data.aaaa)) && (! deleteAAAA)) {
n.data.aaaa = o.data.aaaa;
}
if (nullish(n.data.txt) && (! nullish(o.data.txt)) && (! deleteTXT)) {
n.data.txt = o.data.txt;
}
if (nullish(n.data.mx) && (! nullish(o.data.mx)) && (! deleteMX)) {
n.data.mx = o.data.mx;
}
}
}
this.#incrementSerial(n.domain);
this.#hosts.set(lcname, n);
this.emit(o ? 'update' : 'add', lcname);
return true;
}
remove(name) {
if (! this.valid(name)) {
return false;
}
let lcname = name.toLowerCase();
let h = this.#hosts.get(lcname);
if (! h) {
return false;
}
if (h.timeout) {
clearTimeout(h.timeout);
h.timeout = null;
}
this.#incrementSerial(h.domain);
let rv = this.#hosts.delete(lcname);
this.emit('remove', lcname);
return rv;
}
get(name, type) {
this.#stat.lookup.total++;
if (! this.valid(name)) {
if (this.#debug) {
log('Host name is invalid in query');
}
this.#stat.lookup.errors++;
return false;
}
let lcname = name.toLowerCase();
let d = this.#domains.get(lcname);
let h = this.#hosts.get(lcname);
let lcdomain = null;
if (d) {
lcdomain = d.lcname;
} else if (h) {
lcdomain = h.domain;
} else {
let dd = this.#searchDomain(lcname);
if (dd) {
lcdomain = dd.lcname;
}
}
let r = false;
switch (type) {
case Packet.TYPE.SOA:
if (d?.name && Number.isSafeInteger(d?.serial)) {
r = { name: lcname,
type: Packet.TYPE.SOA,
'class': Packet.CLASS.IN,
ttl: 60,
primary: lcname,
admin: 'postmaster.' + lcname,
serial: d.serial,
refresh: 300,
retry: 3,
expiration: 10,
minimum: 10 };
} else if (lcdomain !== null) {
r = null;
}
break;
case Packet.TYPE.NS:
if (d?.name) {
r = { name: lcname,
type: Packet.TYPE.NS,
'class': Packet.CLASS.IN,
ttl: 60,
ns: lcname };
} else if (lcdomain !== null) {
r = null;
}
break;
case Packet.TYPE.A:
if (h?.data?.a) {
r = { name: lcname,
type: Packet.TYPE.A,
'class': Packet.CLASS.IN,
ttl: 60,
address: h.data.a };
} else if (lcdomain !== null) {
r = null;
}
break;
case Packet.TYPE.AAAA:
if (h?.data?.aaaa) {
r = { name: lcname,
type: Packet.TYPE.AAAA,
'class': Packet.CLASS.IN,
ttl: 60,
address: h.data.aaaa };
} else if (lcdomain !== null) {
r = null;
}
break;
case Packet.TYPE.TXT:
if (h?.data?.txt) {
r = { name: lcname,
type: Packet.TYPE.TXT,
'class': Packet.CLASS.IN,
ttl: 60,
data: h.data.txt };
} else if (lcdomain !== null) {
r = null;
}
break;
case Packet.TYPE.MX:
if (h?.data?.mx) {
r = { name: lcname,
type: Packet.TYPE.MX,
'class': Packet.CLASS.IN,
ttl: 60,
exchange: h.data.mx,
priority: 1 };
} else if (lcdomain !== null) {
r = null;
}
break;
case Packet.TYPE.CNAME:
case Packet.TYPE.PTR:
case Packet.TYPE.SRV:
case Packet.TYPE.MD:
case Packet.TYPE.MF:
case Packet.TYPE.MB:
case Packet.TYPE.MG:
case Packet.TYPE.MR:
case Packet.TYPE.NULL:
case Packet.TYPE.WKS:
case Packet.TYPE.HINFO:
case Packet.TYPE.MINFO:
case Packet.TYPE.EDNS:
case Packet.TYPE.SPF:
case Packet.TYPE.AXFR:
case Packet.TYPE.MAILB:
case Packet.TYPE.MAILA:
case Packet.TYPE.ANY:
case Packet.TYPE.CAA:
if (lcdomain !== null) {
r = null;
}
break;
}
if (r === false) {
this.#stat.lookup.errors++;
}
return r;
}
flush() {
for (let n of this.#hosts.values()) {
if (n.timeout) {
clearTimeout(n.timeout);
n.timeout = null;
}
}
this.#hosts.clear();
this.#domains.clear();
this.emit('flush');
}
stats() {
return {
lookup: Object.assign({}, this.#stat.lookup),
domains: this.#domains.size,
hosts: this.#hosts.size
};
}
valid(name) {
return ((typeof(name) === 'string') &&
(name.length <= 253) &&
/^([a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)(\.([a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)){1,125}$/.test(name));
}
validDomain(domain) {
return ((typeof(domain) === 'string') && this.valid('x.' + domain));
}
dump() {
let domains = [];
let hosts = [];
for (let d of this.#domains.values()) {
d = Object.assign({}, d);
delete d.timeout;
domains.push(d);
}
for (let h of this.#hosts.values()) {
h = Object.assign({}, h);
delete h.timeout;
hosts.push(h);
}
return { hosts, domains };
}
};
module.exports = NameDB;