In progress: Create store API to enable non-json based stores #9
|
@ -1,2 +1,3 @@
|
||||||
node_modules
|
node_modules
|
||||||
.*.sw*
|
.*.sw*
|
||||||
|
local-db.js
|
||||||
|
|
|
@ -387,7 +387,7 @@ cli.main(function (args, cli) {
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
engine = engine || require('../lib/store.json.js').create(engineOpts);
|
engine = engine || require('../lib/store').create(engineOpts);
|
||||||
} catch(e) {
|
} catch(e) {
|
||||||
respondWithResults(e);
|
respondWithResults(e);
|
||||||
return;
|
return;
|
||||||
|
@ -413,7 +413,7 @@ cli.main(function (args, cli) {
|
||||||
}
|
}
|
||||||
if (cli.http) {
|
if (cli.http) {
|
||||||
try {
|
try {
|
||||||
engine = engine || require('../lib/store.json.js').create(engineOpts);
|
engine = engine || require('../lib/store').create(engineOpts);
|
||||||
} catch(e) {
|
} catch(e) {
|
||||||
console.error(e);
|
console.error(e);
|
||||||
return;
|
return;
|
||||||
|
|
|
@ -184,7 +184,7 @@ function getNs(engine, zs, results, cb) {
|
||||||
// d.vanityNs should only be vanity nameservers (pointing to this same server)
|
// d.vanityNs should only be vanity nameservers (pointing to this same server)
|
||||||
if (z.vanityNs || results.authority.some(function (ns) {
|
if (z.vanityNs || results.authority.some(function (ns) {
|
||||||
console.log('[debug] ns', ns);
|
console.log('[debug] ns', ns);
|
||||||
return -1 !== engine.primaryNameservers.indexOf(ns.data.toLowerCase());
|
return -1 !== engine.primaryNameservers().indexOf(ns.data.toLowerCase());
|
||||||
})) {
|
})) {
|
||||||
results.authority.length = 0;
|
results.authority.length = 0;
|
||||||
results.authority.push(engine.zones._toSoa(z));
|
results.authority.push(engine.zones._toSoa(z));
|
||||||
|
@ -359,7 +359,7 @@ module.exports.query = function (engine, query, cb) {
|
||||||
// NOTE: I think that the issue here is EXTERNAL vs INTERNAL vanity NS
|
// NOTE: I think that the issue here is EXTERNAL vs INTERNAL vanity NS
|
||||||
// We _should_ reply for EXTERNAL vanity NS... but not when it's listed on the SOA internally?
|
// We _should_ reply for EXTERNAL vanity NS... but not when it's listed on the SOA internally?
|
||||||
// It's surrounding the problem of what if I do sub domain delegation to the same server.
|
// It's surrounding the problem of what if I do sub domain delegation to the same server.
|
||||||
if (-1 === engine.primaryNameservers.indexOf(r.data.toLowerCase())) {
|
if (-1 === engine.primaryNameservers().indexOf(r.data.toLowerCase())) {
|
||||||
console.log("It's a vanity NS");
|
console.log("It's a vanity NS");
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
|
@ -230,6 +230,11 @@ module.exports.create = function (cli, engine/*, dnsd*/) {
|
||||||
zone.class = zone.className;
|
zone.class = zone.className;
|
||||||
zone.type = zone.typeName;
|
zone.type = zone.typeName;
|
||||||
zone.soa = true;
|
zone.soa = true;
|
||||||
|
|
||||||
|
// TODO: consider sending a predicate object through the engine
|
||||||
|
// to the actual store in case it is highly inefficient to transfer
|
||||||
|
// a large number of records from the store that will just be
|
||||||
|
// thrown away.
|
||||||
engine.records.all(function (err, records) {
|
engine.records.all(function (err, records) {
|
||||||
records = records.filter(function (r) {
|
records = records.filter(function (r) {
|
||||||
return r.zone === zonename;
|
return r.zone === zonename;
|
||||||
|
@ -239,6 +244,8 @@ module.exports.create = function (cli, engine/*, dnsd*/) {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// I wonder what an API that gets ALL records from all zones is for
|
||||||
app.get('/api/records', function (req, res) {
|
app.get('/api/records', function (req, res) {
|
||||||
engine.records.all(function (err, records) {
|
engine.records.all(function (err, records) {
|
||||||
res.send({ records: records.map(mapRecord) });
|
res.send({ records: records.map(mapRecord) });
|
||||||
|
|
|
@ -1,101 +1,80 @@
|
||||||
'use strict';
|
'use strict';
|
||||||
|
|
||||||
|
var crypto = require('crypto');
|
||||||
|
var dns = require('dns');
|
||||||
|
var os = require('os');
|
||||||
|
var path = require('path');
|
||||||
|
|
||||||
|
var pathResolvers = {
|
||||||
|
'.': function fromCwd(relPath) {
|
||||||
|
return path.join(process.cwd(), relPath);
|
||||||
|
},
|
||||||
|
'~': function fromHomedir(relPath) {
|
||||||
|
if (!os.homedir) {
|
||||||
|
throw new Error(
|
||||||
|
'Resolving home directory relative paths is not supported in this version of node.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return path.join(os.homedir(), relPath);
|
||||||
|
},
|
||||||
|
noop: function (p) { return p; }
|
||||||
|
}
|
||||||
|
|
||||||
module.exports.create = function (opts) {
|
module.exports.create = function (opts) {
|
||||||
// opts = { filepath };
|
// opts = { filepath };
|
||||||
|
// `opts.filepath` is a module id or path to a module that contains a store plugin or file
|
||||||
|
var pathFn = pathResolvers[opts.filepath[0]] || pathResolvers.noop;
|
||||||
|
var storeId = pathFn(opts.filepath);
|
||||||
|
var pathToStore = require.resolve(storeId);
|
||||||
|
|
||||||
var engine = { db: null };
|
var engine = { db: null };
|
||||||
|
|
||||||
function notDeleted(r) {
|
function notDeleted(r) {
|
||||||
return !r.revokedAt && !r.deletedAt;
|
return !r.revokedAt && !r.deletedAt;
|
||||||
}
|
}
|
||||||
|
|
||||||
var db = require(opts.filepath);
|
// instantiate the DB module
|
||||||
var stat = require('fs').statSync(opts.filepath);
|
var db = (pathToStore.slice(-5) === '.json') ?
|
||||||
var crypto = require('crypto');
|
// JSON files should be loaded using our built in store.json.js
|
||||||
//
|
require('./store.json.js')(pathToStore) :
|
||||||
// Manual Migration
|
// everything else should be loaded as a module and passed our opts object
|
||||||
//
|
require(storeId)(opts);
|
||||||
db.primaryNameservers.forEach(function (ns, i, arr) {
|
|
||||||
if ('string' === typeof ns) {
|
|
||||||
ns = { name: ns };
|
|
||||||
arr[i] = ns;
|
|
||||||
}
|
|
||||||
if (!ns.id) {
|
|
||||||
ns.id = crypto.randomBytes(16).toString('hex');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
db.zones = db.zones || [];
|
|
||||||
if (db.domains) {
|
|
||||||
db.zones = db.zones.concat(db.domains);
|
|
||||||
}
|
|
||||||
db.zones.forEach(function (zone) {
|
|
||||||
if (!zone.name) {
|
|
||||||
zone.name = zone.id;
|
|
||||||
zone.id = null;
|
|
||||||
}
|
|
||||||
if (!zone.id) {
|
|
||||||
zone.id = crypto.randomBytes(16).toString('hex');
|
|
||||||
}
|
|
||||||
if (!zone.createdAt) { zone.createdAt = stat.mtime.valueOf(); }
|
|
||||||
if (!zone.updatedAt) { zone.updatedAt = stat.mtime.valueOf(); }
|
|
||||||
});
|
|
||||||
db.records.forEach(function (record) {
|
|
||||||
if (!record.id) {
|
|
||||||
record.id = crypto.randomBytes(16).toString('hex');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
require('fs').writeFileSync(opts.filepath, JSON.stringify(db, null, 2));
|
|
||||||
//
|
|
||||||
// End Migration
|
|
||||||
//
|
|
||||||
|
|
||||||
db.save = function (cb) {
|
// TODO: examine usage of engine.primaryNameservers to see if we are supporting it right
|
||||||
if (db.save._saving) {
|
engine.primaryNameservers = db.primaryNameservers.list;
|
||||||
console.log('make pending');
|
|
||||||
db.save._pending.push(cb);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
db.save._saving = true;
|
|
||||||
require('fs').writeFile(opts.filepath, JSON.stringify(db, null, 2), function (err) {
|
|
||||||
console.log('done writing');
|
|
||||||
var pending = db.save._pending.splice(0);
|
|
||||||
db.save._saving = false;
|
|
||||||
cb(err);
|
|
||||||
if (!pending.length) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
db.save(function (err) {
|
|
||||||
console.log('double save');
|
|
||||||
pending.forEach(function (cb) { cb(err); });
|
|
||||||
});
|
|
||||||
});
|
|
||||||
};
|
|
||||||
db.save._pending = [];
|
|
||||||
|
|
||||||
engine.primaryNameservers = db.primaryNameservers;
|
|
||||||
engine.peers = {
|
engine.peers = {
|
||||||
all: function (cb) {
|
all: function (cb) {
|
||||||
var dns = require('dns');
|
var pNS = db.primaryNameservers.list();
|
||||||
var count = db.primaryNameservers.length;
|
|
||||||
function gotRecord() {
|
function getRecord(ns, done) {
|
||||||
count -= 1;
|
|
||||||
if (!count) {
|
|
||||||
cb(null, db.primaryNameservers);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
function getRecord(ns) {
|
|
||||||
dns.resolve4(ns.name, function (err, addresses) {
|
dns.resolve4(ns.name, function (err, addresses) {
|
||||||
console.log('ns addresses:');
|
console.log('ns addresses:');
|
||||||
console.log(addresses);
|
console.log(addresses);
|
||||||
if (err) { console.error(err); gotRecord(); return; }
|
if (err) { console.error(err); done(); return; }
|
||||||
ns.type = 'A';
|
ns.type = 'A';
|
||||||
ns.address = addresses[0];
|
ns.address = addresses[0];
|
||||||
gotRecord();
|
done();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
db.primaryNameservers.forEach(getRecord);
|
|
||||||
|
// resolve addreses for all of the primary nameservers in parallel
|
||||||
|
pNS.forEach(function (ns) {
|
||||||
|
var status = { pending: true };
|
||||||
|
function done() {
|
||||||
|
status.pending = false;
|
||||||
|
// TODO: determine if the locally stored records should get updated
|
||||||
|
var incomplete = tasks.filter(function (s) { return s.pending; });
|
||||||
|
if (incomplete.length < 1) {
|
||||||
|
cb(null, pNS);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
getRecord(ns, done);
|
||||||
|
return status;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
engine.zones = {
|
engine.zones = {
|
||||||
_immutableKeys: [ 'id', 'name', 'primary', 'serial', 'revokedAt', 'changedAt', 'insertedAt', 'updatedAt', 'deletedAt' ]
|
_immutableKeys: [ 'id', 'name', 'primary', 'serial', 'revokedAt', 'changedAt', 'insertedAt', 'updatedAt', 'deletedAt' ]
|
||||||
, _mutableKeys: [ 'admin', 'expiration', 'minimum', 'refresh', 'retry', 'ttl', 'vanity' ]
|
, _mutableKeys: [ 'admin', 'expiration', 'minimum', 'refresh', 'retry', 'ttl', 'vanity' ]
|
||||||
|
@ -105,8 +84,9 @@ module.exports.create = function (opts) {
|
||||||
// epoch in seconds will do
|
// epoch in seconds will do
|
||||||
return parseInt(Math.round(date/1000).toString().slice(-10), 10);
|
return parseInt(Math.round(date/1000).toString().slice(-10), 10);
|
||||||
}
|
}
|
||||||
|
// NOTE/TODO: despite the _, _toSoa is used outside this file (in lib/digd.js and lib/httpd.js)
|
||||||
, _toSoa: function (domain) {
|
, _toSoa: function (domain) {
|
||||||
var nameservers = domain.vanityNs || engine.primaryNameservers.map(function (n) { return n.name; });
|
var nameservers = domain.vanityNs || engine.primaryNameservers().map(function (n) { return n.name; });
|
||||||
|
|
||||||
var index = Math.floor(Math.random() * nameservers.length) % nameservers.length;
|
var index = Math.floor(Math.random() * nameservers.length) % nameservers.length;
|
||||||
var nameserver = nameservers[index];
|
var nameserver = nameservers[index];
|
||||||
|
@ -122,6 +102,7 @@ module.exports.create = function (opts) {
|
||||||
, name_server: nameserver
|
, name_server: nameserver
|
||||||
|
|
||||||
// admin -- email address or domain for admin
|
// admin -- email address or domain for admin
|
||||||
|
// default is effectively admin@{domain name}
|
||||||
, admin: domain.admin || ('admin.' + domain.name)
|
, admin: domain.admin || ('admin.' + domain.name)
|
||||||
, email_addr: domain.admin || ('admin.' + domain.name)
|
, email_addr: domain.admin || ('admin.' + domain.name)
|
||||||
|
|
||||||
|
@ -148,7 +129,7 @@ module.exports.create = function (opts) {
|
||||||
}
|
}
|
||||||
, all: function (cb) {
|
, all: function (cb) {
|
||||||
process.nextTick(function () {
|
process.nextTick(function () {
|
||||||
cb(null, db.zones.slice(0).filter(notDeleted));
|
cb(null, db.zones().filter(notDeleted));
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
, get: function (queries, cb) {
|
, get: function (queries, cb) {
|
||||||
|
@ -157,7 +138,7 @@ module.exports.create = function (opts) {
|
||||||
return { name: n };
|
return { name: n };
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
var myDomains = db.zones.filter(function (d) {
|
var myDomains = db.zones().filter(function (d) {
|
||||||
return queries.some(function (q) {
|
return queries.some(function (q) {
|
||||||
return (d.name.toLowerCase() === q.name) && notDeleted(d);
|
return (d.name.toLowerCase() === q.name) && notDeleted(d);
|
||||||
});
|
});
|
||||||
|
@ -167,19 +148,17 @@ module.exports.create = function (opts) {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
, touch: function (zone, cb) {
|
, touch: function (zone, cb) {
|
||||||
var existing;
|
db.zones.get(zone, function (err, existing) {
|
||||||
db.zones.some(function (z) {
|
if (err || !existing) {
|
||||||
if (z.id && zone.id === z.id) { existing = z; return true; }
|
cb(err, null);
|
||||||
if (z.name && zone.name === z.name) { existing = z; return true; }
|
return;
|
||||||
});
|
}
|
||||||
if (!existing) {
|
existing.updatedAt = new Date().valueOf(); // toISOString();
|
||||||
cb(null, null);
|
console.log('touch saving...');
|
||||||
|
db.zone.update(existing, function (err) {
|
||||||
|
cb(err, !err && existing || null);
|
||||||
|
});
|
||||||
return;
|
return;
|
||||||
}
|
|
||||||
existing.updatedAt = new Date().valueOf(); // toISOString();
|
|
||||||
console.log('touch saving...');
|
|
||||||
db.save(function (err) {
|
|
||||||
cb(err, !err && existing || null);
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
, save: function (zone, cb) {
|
, save: function (zone, cb) {
|
||||||
|
@ -191,65 +170,69 @@ module.exports.create = function (opts) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
, update: function (zone, cb) {
|
, update: function (zone, cb) {
|
||||||
var existing;
|
db.zones.get({ id: zone.id }, function (err, found) {
|
||||||
var dirty;
|
var dirty;
|
||||||
|
|
||||||
db.zones.some(function (z) {
|
if (err) {
|
||||||
if (z.id === zone.id) {
|
console.log('error finding zone');
|
||||||
existing = z;
|
cb(new Error("Error finding zone for '" + zone.id + "'"), null);
|
||||||
return true;
|
return;
|
||||||
}
|
}
|
||||||
});
|
|
||||||
|
|
||||||
if (!existing) {
|
if (!found) {
|
||||||
console.log('no existing zone');
|
console.log('no existing zone');
|
||||||
cb(new Error("zone for '" + zone.id + "' does not exist"), null);
|
cb(new Error("zone for '" + zone.id + "' does not exist"), null);
|
||||||
return;
|
return;
|
||||||
}
|
|
||||||
|
|
||||||
console.log('found existing zone');
|
|
||||||
console.log(existing);
|
|
||||||
console.log(zone);
|
|
||||||
Object.keys(zone).forEach(function (key) {
|
|
||||||
if (-1 !== engine.zones._immutableKeys.indexOf(key)) { return; }
|
|
||||||
if (existing[key] !== zone[key]) {
|
|
||||||
dirty = true;
|
|
||||||
console.log('existing key', key, existing[key], zone[key]);
|
|
||||||
existing[key] = zone[key];
|
|
||||||
}
|
}
|
||||||
});
|
|
||||||
|
|
||||||
zone.updatedAt = new Date().valueOf(); // toISOString(); // Math.round(Date.now() / 1000);
|
console.log('found existing zone');
|
||||||
if (dirty) {
|
console.log(found);
|
||||||
zone.changedAt = zone.updatedAt;
|
console.log(zone);
|
||||||
}
|
Object.keys(zone).forEach(function (key) {
|
||||||
|
if (-1 !== engine.zones._immutableKeys.indexOf(key)) { return; }
|
||||||
|
if (found[key] !== zone[key]) {
|
||||||
|
dirty = true;
|
||||||
|
console.log('existing key', key, found[key], zone[key]);
|
||||||
|
found[key] = zone[key];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
console.log('saving...');
|
found.updatedAt = new Date().valueOf(); // toISOString(); // Math.round(Date.now() / 1000);
|
||||||
db.save(function (err) {
|
if (dirty) {
|
||||||
cb(err, !err && existing || null);
|
found.changedAt = found.updatedAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('saving...');
|
||||||
|
db.zones.update(found, function (err) {
|
||||||
|
cb(err, !err && found || null);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
, create: function (zone, cb) {
|
, create: function (zone, cb) {
|
||||||
var newZone = { id: crypto.randomBytes(16).toString('hex') };
|
var zoneName = (zone.name||'').toLowerCase();
|
||||||
var existing;
|
db.zones.get({ name: zoneName }, function (err, found) {
|
||||||
var nss = [];
|
if (err) {
|
||||||
|
console.error(err);
|
||||||
zone.name = (zone.name||'').toLowerCase();
|
cb(new Error("error attempting to create new zone '" + zoneName + "'"));
|
||||||
db.zones.some(function (z) {
|
|
||||||
if (z.name === zone.name) {
|
|
||||||
existing = z;
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
if (existing) {
|
|
||||||
cb(new Error("tried to create new zone, but '" + existing.name + "' already exists"));
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
newZone.name = zone.name;
|
if (found) {
|
||||||
|
cb(new Error("tried to create new zone, but '" + found.name + "' already exists"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var newZone = {
|
||||||
|
id: crypto.randomBytes(16).toString('hex'),
|
||||||
|
name: zoneName
|
||||||
|
};
|
||||||
|
var nss = [];
|
||||||
|
|
||||||
newZone.createdAt = Date.now();
|
newZone.createdAt = Date.now();
|
||||||
newZone.updatedAt = newZone.createdAt;
|
newZone.updatedAt = newZone.createdAt;
|
||||||
|
|
||||||
|
/*
|
||||||
|
Set only the mutable keys in the new zone from the proposed zone object
|
||||||
|
*/
|
||||||
Object.keys(zone).forEach(function (key) {
|
Object.keys(zone).forEach(function (key) {
|
||||||
//if (-1 !== engine.zones._immutableKeys.indexOf(key)) { return; }
|
//if (-1 !== engine.zones._immutableKeys.indexOf(key)) { return; }
|
||||||
if (-1 === engine.zones._mutableKeys.indexOf(key)) { return; }
|
if (-1 === engine.zones._mutableKeys.indexOf(key)) { return; }
|
||||||
|
@ -262,7 +245,12 @@ module.exports.create = function (opts) {
|
||||||
} else {
|
} else {
|
||||||
newZone.vanity = false;
|
newZone.vanity = false;
|
||||||
}
|
}
|
||||||
db.primaryNameservers.forEach(function (ns, i) {
|
|
||||||
|
// TODO: distinguish between primary and secondary zones
|
||||||
|
// TODO: determine if we need to do anything special for delegation
|
||||||
|
|
||||||
|
// create records for the primary nameservers (or vanity name servers)
|
||||||
|
db.primaryNameservers.list().forEach(function (ns, i) {
|
||||||
var nsx = 'ns' + (i + 1);
|
var nsx = 'ns' + (i + 1);
|
||||||
var nsZone;
|
var nsZone;
|
||||||
var ttl = 43200; // 12h // TODO pick a well-reasoned number
|
var ttl = 43200; // 12h // TODO pick a well-reasoned number
|
||||||
|
@ -302,7 +290,13 @@ module.exports.create = function (opts) {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
db.zones.push(newZone);
|
db.zones.create(newZone, function (err) {
|
||||||
|
// WIP: going to need to figure out how to manage this as a transaction
|
||||||
|
// Significant benefit to having records owned by the zone is we won't have
|
||||||
|
// records for zones that don't otherwise exist - at least at the engine level.
|
||||||
|
|
||||||
|
// every line below this one is not yet modified...
|
||||||
|
});
|
||||||
nss.forEach(function (ns) {
|
nss.forEach(function (ns) {
|
||||||
db.records.push(ns);
|
db.records.push(ns);
|
||||||
});
|
});
|
|
@ -0,0 +1,449 @@
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
var crypto = require('crypto');
|
||||||
|
|
||||||
|
function jsonDeepClone(target) {
|
||||||
|
return JSON.parse(
|
||||||
|
JSON.stringify(target)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function mergeObjects() {
|
||||||
|
// arguments should be an array of objects. We
|
||||||
|
// reverse it because the last argument to set
|
||||||
|
// a value wins.
|
||||||
|
var args = [].slice.call(arguments).reverse();
|
||||||
|
var len = args.length;
|
||||||
|
if (len === 1) {
|
||||||
|
return args[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
// gather the set of keys from all arguments
|
||||||
|
var keyLists = args.map(function (arg) {
|
||||||
|
return Object.keys(arg);
|
||||||
|
});
|
||||||
|
|
||||||
|
var keys = Object.keys(keyLists.reduce(function (all, list) {
|
||||||
|
list.forEach(function (k) {
|
||||||
|
all[k] = true;
|
||||||
|
});
|
||||||
|
return all;
|
||||||
|
}, {}));
|
||||||
|
|
||||||
|
// for each key
|
||||||
|
return keys.reduce(function (target, k) {
|
||||||
|
// find the first argument (because of the reverse() above) with the key set
|
||||||
|
var values = [];
|
||||||
|
var isObject = false;
|
||||||
|
for (var i = 0; i < len; i++) {
|
||||||
|
var v = args[i];
|
||||||
|
var vType = typeof v;
|
||||||
|
|
||||||
|
if (vType === 'object') {
|
||||||
|
if (!v) {
|
||||||
|
// typeof null is object. null is the only falsey object. null represents
|
||||||
|
// a delete or the end of our argument list;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
// we need to collect values until we get a non-object, so we can merge them
|
||||||
|
values.push(v);
|
||||||
|
isObject = true;
|
||||||
|
} else if (!isObject) {
|
||||||
|
if (vType === 'undefined') {
|
||||||
|
// if the arg actually has the key set this is effectively a "delete"
|
||||||
|
if (keyList[i].indexOf(k) != -1) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
// otherwise we need to check the next argument's value, so we don't break the loop
|
||||||
|
} else {
|
||||||
|
values.push(v);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// a previous value was an object, this one isn't
|
||||||
|
// That means we are done collecting values.
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (values.length > 0) {
|
||||||
|
target[k] = mergeObjects.apply(null, values);
|
||||||
|
}
|
||||||
|
|
||||||
|
return target;
|
||||||
|
}, {});
|
||||||
|
}
|
||||||
|
|
||||||
|
function prepareZone(zone, options) {
|
||||||
|
var opts = options || {};
|
||||||
|
var timestamp = opts.timestamp || Date.now();
|
||||||
|
if (!zone.name) {
|
||||||
|
zone.name = zone.id;
|
||||||
|
zone.id = null;
|
||||||
|
}
|
||||||
|
if (!zone.id) {
|
||||||
|
zone.id = crypto.randomBytes(16).toString('hex');
|
||||||
|
}
|
||||||
|
if (!zone.createdAt) { zone.createdAt = timestamp; }
|
||||||
|
if (!zone.updatedAt || opts.isUpdate) { zone.updatedAt = timestamp; }
|
||||||
|
|
||||||
|
// create a names set for the zone, keyed by record name mapped to
|
||||||
|
// an object for the various records with that name, by type (A, MX, TXT, etc.)
|
||||||
|
zone.records = zone.records || {};
|
||||||
|
|
||||||
|
return zone;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
`init()` should return a `lock(forOps)` function, where `forOps` describes the portions
|
||||||
|
of the database that we need to obtain a lock for (so we can write to them). If `forOps`
|
||||||
|
is underfined, we only need to read the currently valid data.
|
||||||
|
|
||||||
|
`lock(forOps)` should return an object with: {
|
||||||
|
save: function -> undefined - changes to in memory representation should be persisted.
|
||||||
|
This could be considered the equivalent of committing a transaction to the database.
|
||||||
|
This will release any write lock obtained. `save()` will return an error if no write
|
||||||
|
lock was obtained OR writes are made to locations other than were locked.,
|
||||||
|
discard: function -> undefined - changes to in memory representation should be discarded.
|
||||||
|
This could be considered the equivalent of cancelling a transaction to the database.
|
||||||
|
This will release any write lock obtained.,
|
||||||
|
peers: {
|
||||||
|
list: function -> list FQDNs that we expec to be in sync with this server
|
||||||
|
},
|
||||||
|
zones: {
|
||||||
|
list: function -> list zones,
|
||||||
|
write:
|
||||||
|
delete:
|
||||||
|
},
|
||||||
|
records: {
|
||||||
|
list: function -> list records,
|
||||||
|
write:
|
||||||
|
delete:
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
All lists will be a deep copy of the data actually stored.
|
||||||
|
*/
|
||||||
|
|
||||||
|
module.exports = function init (opts) {
|
||||||
|
// opts = { filepath };
|
||||||
|
|
||||||
|
var fsDb = require(opts.filepath);
|
||||||
|
var mtime = require('fs').statSync(opts.filepath).mtime.valueOf();
|
||||||
|
|
||||||
|
//
|
||||||
|
// Migration from other formats
|
||||||
|
//
|
||||||
|
|
||||||
|
// Convert the primary nameservers from an array of strings to objects with names and IDs.
|
||||||
|
// also switch to the 'peers' name, since we are really interested in the other FQDNs that
|
||||||
|
// use the same data store and are kept in sync.
|
||||||
|
var peerList = (!fsDb.peers || Array.isArray(fsDb.peers))? fsDb.peers : Object.keys(fsDb.peers).map(function (p) {
|
||||||
|
return fsDb.peers[p];
|
||||||
|
});
|
||||||
|
fsDb.peers = [].concat(fsDb.primaryNameservers, peerList).filter(function (p) {
|
||||||
|
// filter out empty strings, undefined, etc.
|
||||||
|
return !!p;
|
||||||
|
}).map(function (ns) {
|
||||||
|
var peer = ('string' === typeof ns)? ns : { name: ns };
|
||||||
|
if (!peer.id) {
|
||||||
|
peer.id = crypto.randomBytes(16).toString('hex');
|
||||||
|
}
|
||||||
|
return peer;
|
||||||
|
}).reduce(function (peers, p) {
|
||||||
|
peers[p.name] = p;
|
||||||
|
return peers;
|
||||||
|
}, {});
|
||||||
|
delete fsDb.primaryNameservers;
|
||||||
|
|
||||||
|
// Convert domains to zones and ensure that they have proper IDs and timestamps
|
||||||
|
// Organize zones as a set of zone names
|
||||||
|
var zoneList = (!fsDb.zones || Array.isArray(fsDb.zones))? fsDb.zones : Object.keys(fsDb.zones).map(function (z) {
|
||||||
|
return fsDb.zones[z];
|
||||||
|
});
|
||||||
|
|
||||||
|
fsDb.zones = [].concat(fsDb.domains, zoneList).filter(function (z) {
|
||||||
|
// filter out empty strings, undefined, etc.
|
||||||
|
return !!z;
|
||||||
|
}).map(function (zone) {
|
||||||
|
return prepareZone(zone, { timestamp: mtime });
|
||||||
|
}).reduce(function (zones, z) {
|
||||||
|
zones[z.name] = z;
|
||||||
|
return zones;
|
||||||
|
}, {});
|
||||||
|
delete fsDb.domains;
|
||||||
|
|
||||||
|
// NOTE: Records belong to zones, but they previously referred to them only by a
|
||||||
|
// zone property. This may pose problems where the whole list of records is not easily
|
||||||
|
// filtered / kept in memory / indexed and/or retrieved by zone. Traditionally,
|
||||||
|
// records are stored "within a zone" in a zone file. We want to have the store API
|
||||||
|
// behave more traditionally, even though some stores (like a SQL database
|
||||||
|
// table) might actually store the zone as a property of a record as we currently do.
|
||||||
|
// (This fits with the somewhat unexpected and confusing logic of wildcard records.)
|
||||||
|
(fsDb.records || []).forEach(function (record) {
|
||||||
|
// make sure the record has an ID
|
||||||
|
if (!record.id) {
|
||||||
|
record.id = crypto.randomBytes(16).toString('hex');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Put it in it's zone - synthesize one if needed
|
||||||
|
fsDb.zones[record.zone] = fsDb.zones[record.zone] || prepareZone({ name: record.zone });
|
||||||
|
var zone = fsDb.zones[record.zone];
|
||||||
|
// Keep in mind that each name may have multiple records (whether or not they are
|
||||||
|
// of different types, classes, etc.), but each record must have a unique ID.
|
||||||
|
zone.records[record.name] = zone.records[record.name] || {};
|
||||||
|
var recordsForName = zone.records[record.name];
|
||||||
|
recordsForName[record.id] = record;
|
||||||
|
});
|
||||||
|
delete fsDb.records;
|
||||||
|
|
||||||
|
// Write the migrated data
|
||||||
|
require('fs').writeFileSync(opts.filepath, JSON.stringify(fsDb, null, 2));
|
||||||
|
//
|
||||||
|
// End Migration
|
||||||
|
//
|
||||||
|
|
||||||
|
return function lock(forOps) {
|
||||||
|
/*
|
||||||
|
forOps : {
|
||||||
|
write: {
|
||||||
|
zone: string - required - a zone name,
|
||||||
|
names: [string] - optional - a list of record names that may be modified. May be 0 length,
|
||||||
|
records: [string] - optional - a list of record IDs that may be modified. May be 0 length (default)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
1. You can't get a lock for a whole zone without first releasing any locks for names and records
|
||||||
|
within the zone. A whole zone lock will block
|
||||||
|
2. You can't get a lock for a name within a zone without first releasing any locks for records
|
||||||
|
within that name and zone.
|
||||||
|
3. Locks for a specific record do not block new locks with the same zone, name, but a different
|
||||||
|
record ID.
|
||||||
|
4. Creating a new zone, name, or record requires obtaining a lock for it's key (name or ID), even
|
||||||
|
though it does not exist yet. This prevents race conditions where 2 requests (or processes) attempt
|
||||||
|
to create the same resource at the same time.
|
||||||
|
|
||||||
|
Note: The UI probably needs to know if it is trying to write based on an outdated copy of data. Such
|
||||||
|
writes should be detected and fail loudly.
|
||||||
|
|
||||||
|
locks probably involve lockfiles on the filesystem (with watches) so that writes and locks can be
|
||||||
|
communicated easily across processes.
|
||||||
|
*/
|
||||||
|
var db = mergeObjects(fsDb);
|
||||||
|
|
||||||
|
var save = function save (cb) {
|
||||||
|
if (save._saving) {
|
||||||
|
console.log('make pending');
|
||||||
|
save._pending.push(cb);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
save._saving = true;
|
||||||
|
require('fs').writeFile(opts.filepath, JSON.stringify(db, null, 2), function (err) {
|
||||||
|
console.log('done writing');
|
||||||
|
var pending = save._pending.splice(0);
|
||||||
|
save._saving = false;
|
||||||
|
cb(err);
|
||||||
|
if (!pending.length) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
save(function (err) {
|
||||||
|
console.log('double save');
|
||||||
|
pending.forEach(function (cb) { cb(err); });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
};
|
||||||
|
save._pending = [];
|
||||||
|
|
||||||
|
function matchPredicate(predicate) {
|
||||||
|
return function (toCheck) {
|
||||||
|
// which items match the predicate?
|
||||||
|
if (!toCheck) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// check all the keys in the predicate - only supporting exact match
|
||||||
|
// of at least one listed option for all keys right now
|
||||||
|
if (Object.keys(predicate || {}).some(function (k) {
|
||||||
|
return [].concat(predicate[k]).indexOf(toCheck[k]) === -1;
|
||||||
|
})) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// we have a match
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function matchZone(predicate) {
|
||||||
|
var zonenames = !!predicate.name ? [].concat(predicate.name) : Object.keys(db.zones);
|
||||||
|
var check = matchPredicate(predicate);
|
||||||
|
// TODO: swap the filter() for a functional style "loop" recursive function
|
||||||
|
// that lets us return early if we have a limit, etc.
|
||||||
|
var found = zonenames.filter(function (zonename) {
|
||||||
|
/*
|
||||||
|
if (predicate.id && predicate.id !== z.id) { return false; }
|
||||||
|
if (predicate.name && predicate.name !== z.name) { return false; }
|
||||||
|
*/
|
||||||
|
return check(db.zones[zonename]);
|
||||||
|
}).map(function (zonename) {
|
||||||
|
return db.zones[zonename];
|
||||||
|
});
|
||||||
|
|
||||||
|
return found;
|
||||||
|
}
|
||||||
|
|
||||||
|
// NOTE: `opts` exists so we can add options - like properties to read - easily in the future
|
||||||
|
// without modifying the function signature
|
||||||
|
function listZones(predicate, opts, cb) {
|
||||||
|
var found = jsonDeepClone(matchZone(predicate))
|
||||||
|
return setImmediate(cb, null, found);
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeZone(zone, cb) {
|
||||||
|
matchZone({ name: zone.name }, function (err, matched) {
|
||||||
|
if (err) {
|
||||||
|
return setImmediate(cb, err);
|
||||||
|
}
|
||||||
|
|
||||||
|
var found = matched[0];
|
||||||
|
var isUpdate = !!found;
|
||||||
|
|
||||||
|
var combined = mergeObjects((found || {}), zone);
|
||||||
|
db.zones[zone.name] = prepareZone(combined, { isUpdate: isUpdate });
|
||||||
|
return setImmediate(function () {
|
||||||
|
cb(null, jsonDeepClone(db.zones[zone.name]));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function deleteZone(zone, cb) {
|
||||||
|
matchZone({ name: zone.name }, function (err, matched) {
|
||||||
|
if (err) {
|
||||||
|
return setImmediate(cb, err);
|
||||||
|
}
|
||||||
|
var found = matched[0];
|
||||||
|
if (!found) {
|
||||||
|
return setImmediate(cb, new Error('Zone not found'));
|
||||||
|
}
|
||||||
|
|
||||||
|
delete db.zones[zone.name];
|
||||||
|
return setImmediate(function () {
|
||||||
|
cb();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function listRecords(rPredicate, cb) {
|
||||||
|
var recordNames = [].concat(rPredicate.name);
|
||||||
|
var check = matchPredicate(rPredicate);
|
||||||
|
|
||||||
|
var found = matchZone({ name: rPredicate.zone }).reduce(function (records, zone) {
|
||||||
|
// get the records from the zone that match the record predicate
|
||||||
|
var zFound = recordNames.filter(function (name) {
|
||||||
|
return !!zone.records[name];
|
||||||
|
}).map(function (name) {
|
||||||
|
return Object.keys(zone.records[name]).map(function (id) {
|
||||||
|
return zone.records[name][id];
|
||||||
|
}).filter(check);
|
||||||
|
});
|
||||||
|
return records.concat(zFound);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return setImmediate(cb, null, jsonDeepClone(found));
|
||||||
|
}
|
||||||
|
|
||||||
|
function modifyRecords (record, options, cb) {
|
||||||
|
var opts = options || {};
|
||||||
|
var isDelete = !!opts.isDelete;
|
||||||
|
if (!record.zone) {
|
||||||
|
return setImmediate(cb, new Error('No zone specified for record'));
|
||||||
|
}
|
||||||
|
if (!record.name) {
|
||||||
|
return setImmediate(cb, new Error('No name specified for record'));
|
||||||
|
}
|
||||||
|
if (isDelete && !record.id) {
|
||||||
|
return setImmediate(cb, new Error('No id specified to delete record'));
|
||||||
|
}
|
||||||
|
|
||||||
|
var zone = matchZone({ name: record.zone })[0];
|
||||||
|
if (!zone) {
|
||||||
|
return setImmediate(cb, new Error('Unble to find zone ' + record.zone + ' for record'));
|
||||||
|
}
|
||||||
|
var isUpdate = (record.id && !isDelete);
|
||||||
|
if (!isUpdate) {
|
||||||
|
record.id = crypto.randomBytes(16).toString('hex');
|
||||||
|
}
|
||||||
|
|
||||||
|
var recordsForName = zone.records[record.name] = zone.records[record.name] || {};
|
||||||
|
var found = recordsForName[record.id];
|
||||||
|
|
||||||
|
if ((isUpdate || isDelete) && !found) {
|
||||||
|
return setImmediate(cb, new Error('Unable to find record with ID: ' + record.id));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isDelete) {
|
||||||
|
recordsForName[record.id] = (mergeObjects((found || {}), record));
|
||||||
|
}
|
||||||
|
|
||||||
|
var zoneUpdate = {
|
||||||
|
name: record.name,
|
||||||
|
records: {}
|
||||||
|
};
|
||||||
|
zoneUpdate.records[record.name] = keep;
|
||||||
|
return writeZone(zoneUpdate, function (err) {
|
||||||
|
if (err) {
|
||||||
|
return cb(err);
|
||||||
|
}
|
||||||
|
|
||||||
|
return cb(
|
||||||
|
null,
|
||||||
|
isDelete ? null : jsonDeepClone(recordsForName[record.id])
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeRecord(record, cb) {
|
||||||
|
modifyRecords(record, null, cb);
|
||||||
|
}
|
||||||
|
|
||||||
|
function deleteRecord(record, cb) {
|
||||||
|
modifyRecords(record, { isDelete: true }, cb);
|
||||||
|
}
|
||||||
|
|
||||||
|
var dbApi = {
|
||||||
|
save: function () {
|
||||||
|
// hide _pending and _saving from callers
|
||||||
|
var args = [].slice.call(arguments);
|
||||||
|
return save.apply(null, args);
|
||||||
|
},
|
||||||
|
// peers really isn't editable - it's literally the list of FQDN's
|
||||||
|
// that this database is replicated to in a multi-master fashion.
|
||||||
|
//
|
||||||
|
// However, lib/store/index.js does plenty to update these records in support
|
||||||
|
// of the SOA records that are built from them (as does this file in the "migration"
|
||||||
|
// section). I'm toying with the idea of not storing them seperately or creating the
|
||||||
|
// SOA records somewhat immediately.
|
||||||
|
peers: function listPeers(cb) {
|
||||||
|
// Most data stores are going to have an asynchronous storage API. If we need
|
||||||
|
// synchronous access to the data it is going to have to be cached. If it is
|
||||||
|
// cached, there is still the issue the cache getting out of sync (a legitimate
|
||||||
|
// issue anyway). If we explicitly make all of these operations async then we
|
||||||
|
// have greater flexibility for store implmentations to address these issues.
|
||||||
|
return setImmediate(cb, null, jsonDeepClone(db.peers));
|
||||||
|
},
|
||||||
|
zones: {
|
||||||
|
list: listZones,
|
||||||
|
write: writeZone,
|
||||||
|
delete: deleteZone
|
||||||
|
},
|
||||||
|
records: {
|
||||||
|
list: listRecords,
|
||||||
|
write: writeRecord,
|
||||||
|
delete: deleteRecord
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return dbApi;
|
||||||
|
};
|
||||||
|
};
|
Loading…
Reference in New Issue