450 lines
15 KiB
JavaScript
450 lines
15 KiB
JavaScript
'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;
|
|
};
|
|
};
|