437 lines
		
	
	
		
			15 KiB
		
	
	
	
		
			JavaScript
		
	
	
	
	
	
			
		
		
	
	
			437 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 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.
 | |
|   primaryNameservers: {
 | |
|     list: function -> list nameservers
 | |
|   },
 | |
|   zones: {
 | |
|     list: function -> list zones,
 | |
|     create:
 | |
|     update:
 | |
|     delete:
 | |
|   },
 | |
|   records: {
 | |
|     list: function -> list records,
 | |
|     create:
 | |
|     update:
 | |
|     delete:
 | |
|   }
 | |
| }
 | |
| 
 | |
| All lists will be a deep copy of the data actually stored.
 | |
|  */
 | |
| 
 | |
| module.exports = function init (opts) {
 | |
|   // opts = { filepath };
 | |
| 
 | |
|   var db = 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 = (!db.peers || Array.isArray(db.peers))? db.peers : Object.keys(db.peers).map(function (p) {
 | |
|     return db.peers[p];
 | |
|   });
 | |
|   db.peers = [].concat(db.primaryNameservers, peerList).filter(function (p) {
 | |
|     // filer out empty strings, undefined, etc.
 | |
|     return !!p;
 | |
|   }).map(function (ns) {
 | |
|     var peer = ('string' === typeof ns)? ns : { name: ns };
 | |
|     if (!ns.id) {
 | |
|       peer.id = crypto.randomBytes(16).toString('hex');
 | |
|     }
 | |
|     return peer;
 | |
|   }).reduce(function (peers, p) {
 | |
|     peers[p.name] = p;
 | |
|     return peers;
 | |
|   }, {});
 | |
|   delete db.primaryNameservers;
 | |
| 
 | |
|   // Convert domains to zones and ensure that they have proper IDs and timestamps
 | |
|   // Organize zones as a set of zone names
 | |
|   var zoneList = (!db.zones || Array.isArray(db.zones))? db.zones : Object.keys(db.zones).map(function (z) {
 | |
|     return db.zones[z];
 | |
|   });
 | |
| 
 | |
|   db.zones = [].concat(db.domains, zoneList).filter(function (z) {
 | |
|     // filer 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 db.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.)
 | |
|   (db.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
 | |
|     db.zones[record.zone] = db.zones[record.zone] || prepareZone({ name: record.zone });
 | |
|     var zone = db.zones[record.zone];
 | |
|     zone.records[record.name] = zone.records[record.name] || [];
 | |
|     var recordsForName = zone.records[record.name];
 | |
|     recordsForName.push(record);
 | |
|   });
 | |
|   delete db.records;
 | |
| 
 | |
|   // Write the migrated data
 | |
|   require('fs').writeFileSync(opts.filepath, JSON.stringify(db, null, 2));
 | |
|   //
 | |
|   // End Migration
 | |
|   //
 | |
| 
 | |
|   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;
 | |
|   }
 | |
| 
 | |
|   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: {
 | |
|       /*
 | |
|       I'm fairly certan that zone names must be unique and therefore are legitimately
 | |
|       IDs within the zones namespace. This is similarly true of record names within a zone.
 | |
|       I'm not certain that having a distinct ID adds value and it may add confusion / complexity.
 | |
|        */
 | |
|       // NOTE: `opts` exists so we can add options - like properties to read - easily in the future
 | |
|       // without modifying the function signature
 | |
|       list: function listZones(predicate, opts, cb) {
 | |
|         // TODO: consider whether we should just return the zone names
 | |
|         var found = jsonDeepClone(matchZone(predicate)).map(function (z) {
 | |
|           // This is fairly inefficient!! Consider alternative storage
 | |
|           // that does not require deleting the records like this.
 | |
|           delete z.records;
 | |
|           return z;
 | |
|         });
 | |
|         return setImmediate(cb, null, found);
 | |
|       },
 | |
|       // // NOTE: I'm not sure we need a distinct 'find()' operation in the API
 | |
|       // // unless we are going to limit the output of the
 | |
|       // // 'list()' operation in some incompatible way.
 | |
|       // // NOTE: `opts` exists so we can add options - like properties to read - easily in the future
 | |
|       // // without modifying the function signature
 | |
|       // find: function getZone(predicate, opts, cb) {
 | |
|       //   if (!predicate.name || predicate.id) {
 | |
|       //     return setImmediate(cb, new Error('Finding a zone requires a `name` or `id`'));
 | |
|       //   }
 | |
|       //   // TODO: implement a limit / short circuit and possibly offset
 | |
|       //   // to allow for paging of zone data.
 | |
|       //   var found = matchZone(predicate);
 | |
|       //   if (!found[0]) {
 | |
|       //     // TODO: make error message more specific?
 | |
|       //     return setImmediate(cb, new Error('Zone not found'));
 | |
|       //   }
 | |
| 
 | |
|       //   var z = jsonDeepClone(found[0]);
 | |
|       //   delete z.records;
 | |
|       //   return setImmediate(cb, null, z);
 | |
|       // },
 | |
|       create: function createZone(zone, cb) {
 | |
|         // We'll need a lock mechanism of some sort that works
 | |
|         // for simultaneous requests and multiple processes.
 | |
|         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 ' + zone.name + ' already exists'));
 | |
|           }
 | |
|           
 | |
|           db.zones[zone.name] = prepareZone(zone);
 | |
|           return setImmediate(function () {
 | |
|             cb(null, jsonDeepClone(db.zones[zone.name]));
 | |
|             // release lock
 | |
|           });
 | |
|         });
 | |
|       },
 | |
|       update: function updateZone(zone, cb) {
 | |
|         // We'll need a lock mechanism of some sort that works
 | |
|         // for simultaneous requests and multiple processes.
 | |
|         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'));
 | |
|           }
 | |
|           // make sure we are not writing records through this interface
 | |
|           delete zone.records;
 | |
| 
 | |
|           var combined = mergeObjects(found, zone);
 | |
|           db.zones[zone.name] = prepareZone(combined, { isUpdate: true });
 | |
|           return setImmediate(function () {
 | |
|             cb(null, jsonDeepClone(db.zones[zone.name]));
 | |
|             // release lock
 | |
|           });
 | |
|         });
 | |
|       },
 | |
|       delete: function(zone, cb) {
 | |
|         // We'll need a lock mechanism of some sort that works
 | |
|         // for simultaneous requests and multiple processes.
 | |
|         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();
 | |
|             // release lock
 | |
|           });
 | |
|         });
 | |
|       }
 | |
|     },
 | |
|     records: {
 | |
|       list: 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 zone.records[name].filter(check);
 | |
|           });
 | |
|           return records.concat(zFound);
 | |
|         }, []);
 | |
| 
 | |
|         return setImmediate(cb, null, jsonDeepClone(found));
 | |
|       },
 | |
|       // find: function getRecord(rPredicate, cb) {
 | |
|       //   var recordNames = [].concat(rPredicate.name);
 | |
|       //   var check = matchPredicate(rPredicate);
 | |
| 
 | |
|       //   // TODO: swap the `filter()` and `map()` for a functional style "loop"
 | |
|       //   // recursive function that lets us return early if we have a limit, etc.
 | |
|       //   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 zone.records[name].filter(check);
 | |
|       //     });
 | |
|       //     return records.concat(zFound);
 | |
|       //   }, []);
 | |
| 
 | |
|       //   return setImmediate(cb, null, jsonDeepClone(found[0]));
 | |
|       // },
 | |
|       create: function(record, cb) {
 | |
|         var zone = matchZone({ name: record.zone })[0];
 | |
|         if (!zone) {
 | |
|           return setImmediate(cb, new Error('Unble to find zone ' + record.zone + ' to create record'));
 | |
|         }
 | |
| 
 | |
|         var records = zone.records[record.name] = zone.records[record.name] || [];
 | |
|         var check = matchPredicate(record);
 | |
|         if (records.filter(check)[0]) {
 | |
|           return setImmediate(cb, new Error('Exact record already exists in zone ' + record.zone ));
 | |
|         }
 | |
| 
 | |
|         return setImmediate(cb, null, jsonDeepClone(found));
 | |
|       },
 | |
|       update: function(record, cb) {},
 | |
|       delete: function(record, cb) {}
 | |
|     }
 | |
|   };
 | |
| 
 | |
|   return dbApi;
 | |
| };
 |