In progress: Create store API to enable non-json based stores #9
							
								
								
									
										1
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										1
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							| @ -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); | ||||||
|       }); |       }); | ||||||
							
								
								
									
										449
									
								
								lib/store/store.json.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										449
									
								
								lib/store/store.json.js
									
									
									
									
									
										Normal file
									
								
							| @ -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…
	
	
			
			x
			
			
		
	
		Reference in New Issue
	
	Block a user