Closed olljanat closed 5 years ago
@olljanat I started on one when my company was likely going with Docker Swarm, but we ended up switching to Kubernetes, so I stopped development on it.
It was mostly implementing some backend APIs, KoaJS as the HTTP(S) server framework (in NodeJS), with a Vue.js frontend (just to view IPAM usage, not to implement any changes to the backend IPs / pools), PowerDNS integration, and a RocksDB backend KV store.
It used the docker-ipam-proxy-plugin from this repo.
Please note that I am not offering any support or improvements to the following code. It can be also considered released under the MIT License (which the plugin in this repo is)
Also, I scrubbed quite a lot of organizational strings out which were hardcoded in various places, so I expect the test suite likely wouldnt pass without adjustments.
Here's most of it -
server.js
-
// from https://github.com/mgtitimoli/await-mutex
// await-mutex is Unlicense licensed
class Mutex {
constructor() {
this._locking = Promise.resolve();
this._locked = false;
}
isLocked() {
return this._locked;
}
lock() {
this._locked = true;
let unlockNext,
willLock = new Promise(resolve => unlockNext = resolve);
willLock.then(() => this._locked = false);
let willUnlock = this._locking.then(() => unlockNext);
this._locking = this._locking.then(() => willLock);
return willUnlock;
}
}
const koa = require('koa'),
requests = require('koa-log-requests'),
appHandlers = new koa(),
appStatic = new koa(),
koaJsonBody = require('koa-json-body'),
koaMount = require('koa-mount'),
koaStatic = require('koa-static'),
koaSend = require('koa-send'),
koaRouter = require('koa-router'),
appRouter = new koaRouter(),
level = require('level-rocksdb'),
ipAddressLib = require('ip-address'),
ipamDb = level('/opt/ipam/db/ipamDb'),
ipamDbPromise = require('level-promisify')(ipamDb),
awaitReadStream = require('await-stream-ready').read,
ipamDbMutex = new Mutex(),
reclaimCidrIpCooldownMs = 60*2*1000;
const trimTrailingCharacter = (stringToTrim, trailingCharacterToRemove) => {
if (stringToTrim.charAt(stringToTrim.length - 1) === trailingCharacterToRemove) {
return stringToTrim.substring(0, stringToTrim.length - 1);
}
return stringToTrim;
}
const trimTrailingPeriod = stringToTrim => trimTrailingCharacter(stringToTrim, '.');
//////////////////////////////////////////////////////////////////////////////////////////////////////////////
const ipOctetCompare = (a, b) => {
if (a == b) { return 0; }
if (a < b) { return -1; }
return 1;
}
const cidrIpStringToIpOctetIntegerArray = (cidrIpString) => {
return cidrIpString.split('/')[0].split('.').map(octetString => parseInt(octetString));
}
const sortCidrIps = (cidrIpA, cidrIpB) => {
const cidrIpOctetIntegerArrayA = cidrIpStringToIpOctetIntegerArray(cidrIpA),
cidrIpOctetIntegerArrayB = cidrIpStringToIpOctetIntegerArray(cidrIpB);
for (octetId = 0; octetId<4; octetId++) {
const octetCompare = ipOctetCompare(cidrIpOctetIntegerArrayA[octetId], cidrIpOctetIntegerArrayB[octetId]);
if (octetCompare != 0) { return octetCompare; }
}
// we've exited early if any octets are inequal, so we've determine both octet arrays are equal (= 0)
return 0;
}
//////////////////////////////////////////////////////////////////////////////////////////////////////////////
appHandlers.use(koaJsonBody({
fallback: true
}));
const shouldLogRequest = (ctx) => {
if (ctx.request.url === '/instances') {
return false;
}
return true;
}
appHandlers.use(async (ctx, next) => {
await next();
if (!shouldLogRequest(ctx)) {
return;
}
let ctxResponseBodyOutput;
if (ctx.response.type === 'application/json') {
// reformat with indentation
ctxResponseBodyOutput = JSON.stringify(JSON.parse(ctx.response.body), null, 2);
} else {
ctxResponseBodyOutput = ctx.response.body;
}
console.log(`ctx.ip = ${JSON.stringify(ctx.ip, null, 2)}\nctx.request = ${JSON.stringify(ctx.request, null, 2)}\nctx.request.body = ${JSON.stringify(ctx.request.body, null, 2)}\nctx.response = ${JSON.stringify(ctx.response, null, 2)}\nctx.response.body = ${ctxResponseBodyOutput}\
n\n================================================================\n`);
});
const respondJsonBody = (ctx, responseObject) => {
ctx.type = 'application/json';
ctx.body = JSON.stringify(responseObject);
}
const respondWithAddressJson = (ctx, cidrIp) => respondJsonBody(ctx, {
'Address': cidrIp
});
const incrementCidrIp = cidrIp => {
const currentCidr = new ipAddressLib.Address4(cidrIp);
let currentIntegerIp = currentCidr.bigInteger();
const resultIp = ipAddressLib.Address4.fromBigInteger(++currentIntegerIp).address;
if (currentCidr.subnetMask === 32) {
return resultIp;
} else {
return resultIp + currentCidr.subnet;
}
}
const getPoolDetailsByName = poolName => {
if (poolName == 'test1' || poolName == 'test2' || poolName == 'test3') {
return {
name: poolName,
cidrNet: '10.100.0.0/16',
nextCidrIp: '10.100.1.0/16', // defaults to 10.100.1.0/16 (to leave room for host VMs on 10.100.0.0/24)
gateway: '10.100.0.1/16'
}
}
// return null if pool details not found
return null;
}
const asyncGetPoolNextCidrIp = async poolName => {
const poolDetails = getPoolDetailsByName(poolName);
if (poolDetails === null) {
// return null if pool details not found
console.log(`asyncGetPoolNextCidrIp: could not get pool details for pool ${poolName}`);
return null
}
const key = `poolNextCidrIp:${poolDetails.cidrNet}`;
let nextCidrIp;
try {
nextCidrIp = await ipamDbPromise.get(key);
} catch (e) {
console.log(`asyncGetPoolNextCidrIp: ipamDb did have have key ${key}; continuing with initializing this key from poolDetails`);
nextCidrIp = poolDetails.nextCidrIp;
}
const nextCidrIpIncremented = incrementCidrIp(nextCidrIp);
try {
await ipamDbPromise.put(key, nextCidrIpIncremented);
} catch (e) {
console.log(`asyncGetPoolNextCidrIp: ipamDb could not update key ${key} to ${nextCidrIpIncremented}`);
return null;
}
console.log(`asyncGetPoolNextCidrIp: ipamDb ${key} incremented to ${nextCidrIpIncremented}`);
return nextCidrIp;
}
const asyncGetPoolReclaimableCidrIps = async (poolName) => {
const poolDetails = getPoolDetailsByName(poolName);
if (poolDetails === null) {
// return null if pool details not found
console.log(`asyncAddReclaimableIpGetPoolNextCidrIp: could not get pool details for pool ${poolName}`);
return null
}
const key = `poolReclaimableCidrIps:${poolDetails.cidrNet}`;
let reclaimableCidrIps = [];
try {
reclaimableCidrIps = JSON.parse(await ipamDbPromise.get(key));
} catch (e) {
console.log(`asyncAddReclaimableIpGetPoolNextCidrIp: ipamDb did have have key ${key}; initializing with first`);
}
console.log(`asyncGetPoolReclaimableCidrIps: current value of ${key} = ${JSON.stringify(reclaimableCidrIps, null, 2)}`)
return reclaimableCidrIps;
}
const asyncUpdatePoolReclaimableCidrIps = async (poolName, newReclaimableCidrIps) => {
const poolDetails = getPoolDetailsByName(poolName);
if (poolDetails === null) {
// return null if pool details not found
console.log(`asyncUpdatePoolReclaimableCidrIps: could not get pool details for pool ${poolName}`);
return null
}
const key = `poolReclaimableCidrIps:${poolDetails.cidrNet}`;
// sort the newReclaimableCidrIps before writing the new sorted object to the database
const newReclaimableCidrIpsSorted = newReclaimableCidrIps.sort((ipMapA, ipMapB) => sortCidrIps(ipMapA.cidrIp, ipMapB.cidrIp));
try {
await ipamDbPromise.put(key, JSON.stringify(newReclaimableCidrIpsSorted));
} catch (e) {
console.log(`asyncUpdatePoolReclaimableCidrIps: ipamDb could not update key ${key} to ${reclaimableCidrIps}`);
return null;
}
console.log(`asyncUpdatePoolReclaimableCidrIps: ipamDb ${key} changed to ${newReclaimableCidrIps}`);
return newReclaimableCidrIps;
}
const asyncAddPoolReclaimableCidrIp = async (poolName, cidrIpToAddForReclaimation) => {
const poolDetails = getPoolDetailsByName(poolName);
if (poolDetails === null) {
// return null if pool details not found
console.log(`asyncAddReclaimableIpGetPoolNextCidrIp: could not get pool details for pool ${poolName}`);
return null
}
const currentReclaimableCidrIps = await asyncGetPoolReclaimableCidrIps(poolName),
newReclaimableCidrIps = appendCidrIpToReclaimableArray(currentReclaimableCidrIps, cidrIpToAddForReclaimation);
return await asyncUpdatePoolReclaimableCidrIps(poolName, newReclaimableCidrIps)
}
const asyncReclaimFirstPoolCidrIp = async (poolName) => {
const poolDetails = getPoolDetailsByName(poolName);
if (poolDetails === null) {
// return null if pool details not found
console.log(`asyncRemovePoolReclaimableCidrIp: could not get pool details for pool ${poolName}`);
return null
}
const reclaimableCidrIpsUnfiltered = await asyncGetPoolReclaimableCidrIps(poolName);
if (reclaimableCidrIpsUnfiltered === null) {
console.log(`asyncRemovePoolReclaimableCidrIp: could not get candidate reclaimable IPs for pool: ${poolName}`);
return null
}
const currentTimeInMs = Date.now(),
reclaimableCidrIpsFiltered = reclaimableCidrIpsUnfiltered.filter(candidate => (candidate.releasedEpochMs + reclaimCidrIpCooldownMs) < currentTimeInMs)
if (reclaimableCidrIpsFiltered.length == 0) {
console.log(`asyncRemovePoolReclaimableCidrIp: no reclaimable IPs past their cooldown delay found for pool: ${poolName}`);
return null
}
const firstReclaimedCidrIp = reclaimableCidrIpsFiltered[0].cidrIp;
// reclaimableCidrIpsNew will be reclaimableCidrIpsUnfiltered (raw key before operation), but without the CIDR IP we picked (which was the first in the filtered list)
const reclaimableCidrIpsNew = reclaimableCidrIpsUnfiltered.filter(lease => lease.cidrIp != firstReclaimedCidrIp)
const updateSuccess = await asyncUpdatePoolReclaimableCidrIps(poolName, reclaimableCidrIpsNew)
if (updateSuccess === null) {
return null
}
return firstReclaimedCidrIp;
}
const splitCidrIpAndNetBits = (cidr) => {
const splitCidr = cidr.split('/');
return {
ip: splitCidr[0],
netBits: splitCidr[1]
}
}
const asyncGetCidrIpByInstanceName = async instanceName => {
let cidrIp;
const key = `instanceNameToCidrIp:${instanceName}`;
try {
cidrIp = await ipamDbPromise.get(key);
} catch (e) {
console.log(`asyncGetCidrIpByInstanceName could not get key ${key}`);
return null;
}
return {
instanceName: instanceName,
cidrIp: cidrIp,
ip: splitCidrIpAndNetBits(cidrIp).ip
};
}
const asyncGetInstanceNameByIp = async ip => {
let instanceName;
const key = `ipToInstance:${ip}`;
try {
instanceName = await ipamDbPromise.get(key);
} catch (e) {
console.log(`asyncGetInstanceNameByIp: could not get key ${key}`);
return null;
}
return {
instanceName: instanceName,
ip: ip
};
}
const asyncGetLeasedEpochByIp = async ip => {
let leasedEpoch;
const key = `ipToLeasedEpoch:${ip}`;
try {
leasedEpoch = await ipamDbPromise.get(key);
} catch (e) {
console.log(`asyncGetLeasedEpochByIp: could not get key ${key}`);
return null;
}
return {
leasedEpoch: leasedEpoch,
ip: ip
};
}
/*
const ipToPtrFqdn = ip => {
return ip.split('.').reverse().join('.') + '.in-addr.arpa.';
}
*/
const ptrFqdnToIp = ptrFqdn => {
return trimTrailingPeriod(ptrFqdn).split('.').slice(0,-2).reverse().join('.');
}
const asyncKoaSendFromStatic = async (ctx, staticRelativePath) => {
if (typeof staticRelativePath === 'string') {
staticRelativePath = '/' + staticRelativePath;
} else {
staticRelativePath = ctx.path;
}
return await koaSend(ctx, 'static' + staticRelativePath);
}
const respondDnsResult = (ctx, result) => respondJsonBody(ctx, {
'result': [ result ]
});
const respondDnsResultFalse = ctx => respondDnsResult(ctx, false);
const logAndRespondWithStatus = (ctx, responseStatus, logResponse) => {
console.log(`* ${logResponse}`)
ctx.status = responseStatus;
ctx.body = logResponse;
};
const getInstanceNameFromRequestBodyOptions = requestBodyOptions => {
// if we were not passed an object, then return null (failure)
if (typeof requestBodyOptions !== 'object') {
return null;
}
// if ctx.request.body.Options.instanceName exists, then it is our instanceName to return
if (typeof requestBodyOptions.instanceName === 'string') {
return requestBodyOptions.instanceName;
}
// if we don't at least have a hostname, then return null (failure)
if (typeof requestBodyOptions.Hostname !== 'string') {
return null;
}
// the returned instanceName will at least be the hostname
let instanceName = requestBodyOptions.Hostname;
// if a Domainname exists, then we'll append the Domainname to the Hostname from above to make a FQDN
if (typeof requestBodyOptions.Domainname === 'string') {
instanceName = `${instanceName}.${requestBodyOptions.Domainname}`;
}
// return the resultant instanceName
return instanceName;
}
const respondJsonEmptySuccess = ctx => {
// return empty JSON (success)
return respondJsonBody(ctx, {});
}
const appendCidrIpToReclaimableArray = (sourceReclaimableArray, newCidrIp) => {
const currentTimeInMs = Date.now(),
newElement = {
cidrIp: newCidrIp,
releasedEpochMs: Date.now()
};
return sourceReclaimableArray.concat(newElement);
}
appRouter.get('/', async ctx => asyncKoaSendFromStatic(ctx, 'index.html'));
appRouter.get('/favicon.ico', async ctx => asyncKoaSendFromStatic(ctx));
appRouter.get('/favicon.png', async ctx => asyncKoaSendFromStatic(ctx));
// TODO (20171125): DNS handlers need to get moved to new lookup functions and error reporting patterns
/*
appRouter.get('/dns/getDomainMetadata/:reqFqdn/:reqType', ctx => respondDnsResultFalse(ctx));
appRouter.get('/dns/lookup/:reqFqdn/:reqType', async ctx => {
switch(ctx.params.reqType) {
case 'ANY':
if (ctx.params.reqFqdn.endsWith('.in-addr.arpa.')) {
const lookupIp = ptrFqdnToIp(ctx.params.reqFqdn);
const instanceNameLookup = await asyncGetInstanceNameByIp(lookupIp);
if (instanceNameLookup.found !== true) {
return;
}
return respondDnsResult(ctx, {
'qtype': 'PTR',
'qname': trimTrailingPeriod(ctx.params.reqFqdn),
'content': instanceNameLookup.instanceName,
'ttl': 0
});
} else {
cidrIpLookup = await asyncGetCidrIpByInstanceName(trimTrailingPeriod(ctx.params.reqFqdn));
if (cidrIpLookup === null) {
return logAndRespondWithStatus(ctx, 400, `/dns/lookup could not lookup cidr IP from ${ctx.params.reqFqdn}`);
}
return respondDnsResult(ctx, {
'qtype': 'A',
'qname': cidrIpLookup.instanceName,
'content': cidrIpLookup.ip,
'ttl': 0
});
}
break;
case 'SOA':
cidrIpLookup = { instanceName: trimTrailingPeriod(ctx.params.reqFqdn) };
return respondDnsResult(ctx, {
'qtype': 'SOA',
'qname': cidrIpLookup.instanceName,
'content': `ns.${cidrIpLookup.instanceName}. ns-admin.org.com. 2017071000 7200 3600 1209600 3600`,
'ttl': 3600,
'domain_id': -1
});
break;
}
});
*/
const asyncDumpAllDbToString = async db => {
let allData = '';
const dbReadStream = db.createReadStream();
dbReadStream.on('data', data => {
allData += `${data.key} = ${data.value}\n`;
});
await awaitReadStream(dbReadStream);
return allData;
}
appRouter.get('/dumpDb', async ctx => {
ctx.type = 'text/plain';
// ipamDB operations for this API call start here
const unlockIpamDbMutex = await ipamDbMutex.lock();
ctx.body = await asyncDumpAllDbToString(ipamDb);
unlockIpamDbMutex();
});
const asyncDumpDbIpToInstanceArrayUnsorted = async (db) => {
const dbReadStream = db.createReadStream(),
keyPrefix = `ipToInstance:`;
let responseObject = [];
dbReadStream.on('data', data => {
if (data.key.startsWith(keyPrefix)) {
const ip = data.key.split(':')[1];
responseObject.push({ ip: ip, name: data.value});
}
});
await awaitReadStream(dbReadStream);
// add in KVs for leasedEpoch
for (instanceIndex in responseObject) {
const instanceIp = responseObject[instanceIndex].ip,
leasedEpochObj = await asyncGetLeasedEpochByIp(instanceIp);
responseObject[instanceIndex].leasedEpoch = leasedEpochObj.leasedEpoch;
}
return responseObject;
}
appRouter.get('/instances', async ctx => {
ctx.type = 'application/json';
// ipamDB operations for this API call start here
const unlockIpamDbMutex = await ipamDbMutex.lock();
const unsortedIpToInstanceArray = await asyncDumpDbIpToInstanceArrayUnsorted(ipamDb);
unlockIpamDbMutex();
// sort result after mutex, as DB read operation is complete
ctx.body = JSON.stringify(unsortedIpToInstanceArray.sort((ipMapA, ipMapB) => sortCidrIps(ipMapA.ip, ipMapB.ip)));
});
appRouter.post('/IpamDriver.GetCapabilities', ctx => respondJsonBody(ctx, {
'RequiresMACAddress': false,
'RequiresRequestReplay': false
}));
appRouter.post('/IpamDriver.GetDefaultAddressSpaces', ctx => respondJsonBody(ctx, {
'LocalDefaultAddressSpace': 'LOCAL',
'GlobalDefaultAddressSpace': 'GLOBAL'
}));
appRouter.post('/IpamDriver.RequestPool', ctx => {
let poolName;
if (typeof ctx.request.body.Options === 'object' && typeof ctx.request.body.Options.poolName == 'string') {
poolName = ctx.request.body.Options.poolName;
} else {
return logAndRespondWithStatus(ctx, 400, `IpamDriver.RequestPool: request.body.Options.poolName not provided`);
}
const poolDetails = getPoolDetailsByName(poolName);
if (poolDetails === null) {
return logAndRespondWithStatus(ctx, 400, `IpamDriver.RequestPool: pool details could not be obtained for pool: ${poolName}`);
}
return respondJsonBody(ctx, {
'PoolID': poolDetails.name,
'Pool': poolDetails.cidrNet,
'Data': { 'conflictCheck': 'skip' }
});
});
appRouter.post('/IpamDriver.RequestAddress', async ctx => {
let poolName;
if (typeof ctx.request.body.PoolID === 'string') {
poolName = ctx.request.body.PoolID;
} else {
return logAndRespondWithStatus(ctx, 400, `IpamDriver.RequestAddress: request.body.PoolID (pool name) not provided`);
}
// ipamDB operations for this API call start here
const unlockIpamDbMutex = await ipamDbMutex.lock();
if (typeof ctx.request.body.Options !== 'object') {
const nextCidrIp = await asyncGetPoolNextCidrIp(poolName);
unlockIpamDbMutex();
return respondWithAddressJson(ctx, nextCidrIp);
}
const poolDetails = getPoolDetailsByName(poolName);
if (poolDetails === null) {
unlockIpamDbMutex();
return logAndRespondWithStatus(ctx, 400, `IpamDriver.RequestAddress: pool details could not be obtained for pool: ${poolName}`);
}
if (typeof ctx.request.body.Options.RequestAddressType === 'string' && ctx.request.body.Options.RequestAddressType == 'com.docker.network.gateway') {
unlockIpamDbMutex();
return respondJsonBody(ctx, {
'Address': poolDetails.gateway
});
}
const instanceName = getInstanceNameFromRequestBodyOptions(ctx.request.body.Options);
if (instanceName === null) {
unlockIpamDbMutex();
return logAndRespondWithStatus(ctx, 400, `IpamDriver.RequestAddress: request.Body.Options did not have either of the following: instanceName, Hostname`);
}
const cidrIpLookup = await asyncGetCidrIpByInstanceName(instanceName);
if (cidrIpLookup !== null) {
unlockIpamDbMutex();
return respondWithAddressJson(ctx, cidrIpLookup.cidrIp);
}
let cidrIpToReturn;
const reclaimedCidrIp = await asyncReclaimFirstPoolCidrIp(poolName);
if (reclaimedCidrIp !== null) {
// if we found a CIDR IP to return, then use it
cidrIpToReturn = reclaimedCidrIp;
} else {
// if we did not find a CIDR IP to return, then get the next un-used CIDR IP for this pool, and use it
const nextCidrIp = await asyncGetPoolNextCidrIp(poolName);
if (nextCidrIp === null) {
unlockIpamDbMutex();
return logAndRespondWithStatus(ctx, 400, `IpamDriver.RequestAddress: could not get next CIDR IP for pool ${poolName}`);
}
cidrIpToReturn = nextCidrIp;
}
ipToReturn = splitCidrIpAndNetBits(cidrIpToReturn).ip;
const instanceNameToCidrIpKey = `instanceNameToCidrIp:${instanceName}`,
ipToInstanceKey = `ipToInstance:${ipToReturn}`;
// testing start
const currentTimeInMs = Date.now(),
requestingIp = ctx.ip,
ipLeaseLogKey = `ipLeaseLog:${ipToReturn}:${currentTimeInMs}`,
ipToLeasedEpochKey = `ipToLeasedEpoch:${ipToReturn}`;
try {
await ipamDbPromise.batch([
{ type: 'put', key: instanceNameToCidrIpKey, value: cidrIpToReturn },
{ type: 'put', key: ipToInstanceKey, value: instanceName },
{ type: 'put', key: ipLeaseLogKey, value: `${requestingIp} requested` },
{ type: 'put', key: ipToLeasedEpochKey, value: currentTimeInMs }
])
} catch (e) {
unlockIpamDbMutex();
return logAndRespondWithStatus(ctx, 400, `IpamDriver.RequestAddress: could not write ${instanceNameToCidrIpKey} and/or ${ipToInstanceKey}`);
}
// testing end
unlockIpamDbMutex();
// return IP in CIDR format
return respondWithAddressJson(ctx, cidrIpToReturn);
});
appRouter.post('/IpamDriver.ReleaseAddress', async ctx => {
if (typeof ctx.request.body.Address !== 'string') {
return logAndRespondWithStatus(ctx, 400, `IpamDriver.ReleaseAddress: request.Body.Address was not provided`);
}
const ipToRelease = ctx.request.body.Address;
if (typeof ctx.request.body.PoolID !== 'string') {
return logAndRespondWithStatus(ctx, 400, `IpamDriver.ReleaseAddress: request.body.PoolID (pool name) not provided`);
}
const poolName = ctx.request.body.PoolID;
const poolDetails = getPoolDetailsByName(poolName);
if (poolDetails === null) {
return logAndRespondWithStatus(ctx, 400, `IpamDriver.ReleaseAddress: pool details could not be obtained for pool: ${poolName}`);
}
// ipamDB operations for this API call start here
const unlockIpamDbMutex = await ipamDbMutex.lock();
const instanceNameLookup = await asyncGetInstanceNameByIp(ipToRelease);
if (instanceNameLookup === null) {
unlockIpamDbMutex();
return logAndRespondWithStatus(ctx, 400, `IpamDriver.ReleaseAddress: could not find instance with IP ${ipToRelease}`);
}
const instanceName = instanceNameLookup.instanceName;
console.log(`* IpamDriver.ReleaseAddress found instance name ${instanceName} from IP ${ipToRelease}`);
const delKeyA = `instanceNameToCidrIp:${instanceName}`,
delKeyB = `ipToInstance:${ipToRelease}`,
delKeyC = `ipToLeasedEpoch:${ipToRelease}`;
// testing start
const currentTimeInMs = Date.now(),
requestingIp = ctx.ip,
ipLeaseLogKey = `ipLeaseLog:${ipToRelease}:${currentTimeInMs}`;
try {
await ipamDbPromise.batch([
{ type: 'del', key: delKeyA },
{ type: 'del', key: delKeyB },
{ type: 'del', key: delKeyC },
{ type: 'put', key: ipLeaseLogKey, value: `${requestingIp} released` }
])
} catch (e) {
unlockIpamDbMutex();
return logAndRespondWithStatus(ctx, 400, `IpamDriver.ReleaseAddress: could not delete ${delKeyA} and/or ${delKeyB}${ipToRelease}`);
}
// testing end
const ipToReleaseNetBits = splitCidrIpAndNetBits(poolDetails.cidrNet).netBits;
const lookupCidrIp = `${ipToRelease}/${ipToReleaseNetBits}`;
console.log(`* IpamDriver.ReleaseAddress: lookCidrIp = ${lookupCidrIp}`);
await asyncAddPoolReclaimableCidrIp(poolName, lookupCidrIp)
unlockIpamDbMutex();
return respondJsonEmptySuccess(ctx);
});
appRouter.post('/IpamDriver.ReleasePool', ctx => respondJsonEmptySuccess(ctx));
appHandlers
.use(appRouter.routes())
.use(appRouter.allowedMethods());
// static content mapping of /static -> /opt/ipam/static
appStatic.use(koaStatic('static'));
appHandlers.use(koaMount('/static', appStatic));
appHandlers.listen(8080);
//// REPL
const net = require('net');
const repl = require('repl');
net.createServer((socket) => {
socket.write('\nExported objects: levelup, ipamDb, ipamDbPromise\n');
const replInstance = repl.start({
prompt: 'ipam> ',
input: socket,
output: socket,
useColors: true,
terminal: true,
replMode: repl.REPL_MODE_STRICT,
ignoreUndefined: true
});
replInstance.context.levelup = levelup;
replInstance.context.ipamDb = ipamDb;
replInstance.context.ipamDbPromise = ipamDbPromise;
//socket.on('error', (err) => {
//console.log("REPL error:", err);
//});
replInstance.on('exit', () => {
socket.end();
});
}).listen(5002);
package.json
-
{
"name": "docker-ipam-http-server",
"version": "1.0.0",
"main": "server.js",
"devDependencies": {},
"author": "isaac@eyz.us",
"dependencies": {
"koa": "*",
"koa-log-requests": "*",
"koa-json-body": "*",
"koa-static": "*",
"koa-mount": "*",
"koa-send": "*",
"koa-router": "*",
"level-rocksdb": "^1.0.1",
"level-promisify": "*",
"ip-address": "*",
"repl": "*",
"await-stream-ready": "*",
"vue": "*"
}
}
test.sh
#!/bin/bash
BASE_URL="$1"
if [[ -z "${BASE_URL}" ]]; then
echo "Usage: $0 BASE_URL"
echo " $0 http://localhost:8080"
exit 1
fi
TEST=0
SUCCESS=true
dumpDb() {
# to show the current state of the database, without comparing a checksum for success/fail; status only, for debugging
RESPONSE="$(curl -s -H "Content-Type: application/json" -X GET "${BASE_URL}/dumpDb" | sed '/^\s*$/d')"
# if a response was returned (aftere removing blank lines) then print the response and pad a blank line
if [[ -n "${RESPONSE}" ]]; then
echo -e "${RESPONSE}"
echo
fi
}
runTest() {
((TEST++))
dumpDb
local VERB="$1"
local URL="$2"
local REQUEST="$3"
local MD5SUM_EXPECTED="$4"
local INCLUDE_REGEX="$5"
if [[ -n "${INCLUDE_REGEX}" ]]; then
local RESPONSE="$(curl -s -H "Content-Type: application/json" -X "${VERB}" -d "$REQUEST" "$URL" | grep -P "${INCLUDE_REGEX}")"
else
local RESPONSE="$(curl -s -H "Content-Type: application/json" -X "${VERB}" -d "$REQUEST" "$URL")"
fi
local MD5SUM_RESULT="$(md5sum <<<"$RESPONSE" | cut -d ' ' -f 1)"
echo -e "${TEST}) URL: ${URL}"
echo -e "${TEST}) REQUEST BODY: ${REQUEST}"
if [[ "${VERB}" == 'GET' ]]; then
echo -e "${TEST}) RESPONSE BODY:\n${RESPONSE}"
else
echo -e "${TEST}) RESPONSE BODY: ${RESPONSE}"
fi
echo -e "${TEST}) RESPONSE BODY MD5SUM: ${MD5SUM_RESULT}"
if [[ "$MD5SUM_EXPECTED" == "${MD5SUM_RESULT}" ]]; then
echo "${TEST}) RESULT: PASS"
else
echo "${TEST}) RESULT: FAIL"
SUCCESS=false
fi
echo
}
echo -n "Waiting for ${BASE_URL} to respond ... "
while true; do
if curl -Is "${BASE_URL}" >/dev/null 2>&1; then
break
fi
sleep 2
done
echo 'Done'
echo
# test gateway
runTest 'POST' "${BASE_URL}/IpamDriver.RequestAddress" '{ "PoolID": "test1", "Address": "", "Options": { "RequestAddressType": "com.docker.network.gateway" } }' '82e0c8fce66fb39dc2c54c53035e3b7a'
# pool create
runTest 'POST' "${BASE_URL}/IpamDriver.RequestPool" '{ "Options": { "poolName": "test1" } }' 'c820bf5f44c2687093e0cb5d922e9e1d'
# pool remove (noop)
runTest 'POST' "${BASE_URL}/IpamDriver.ReleasePool" '' '8a80554c91d9fca8acb82f023de02f11'
# test invalid request
runTest 'POST' "${BASE_URL}/IpamDriver.RequestAddress" '{ "PoolID": "test1", "Address": "", "Options": { } }' '6842a7ee7c964ccf50042f0e5d08e09b'
# add test-1 and test-2.test.local
runTest 'POST' "${BASE_URL}/IpamDriver.RequestAddress" '{ "PoolID": "test1", "Address": "", "Options": { "Hostname": "test-1" } }' '63aa00d0a6d8f7ad6a11fbe0128d1513'
runTest 'POST' "${BASE_URL}/IpamDriver.RequestAddress" '{ "PoolID": "test1", "Address": "", "Options": { "Hostname": "test-2", "Domainname": "test.local" } }' 'eff5b05e22b68f6a02f01391c08c4b29'
# add test-3 and test-4.test.local
runTest 'POST' "${BASE_URL}/IpamDriver.RequestAddress" '{ "PoolID": "test1", "Address": "", "Options": { "Hostname": "test-3" } }' 'c3cdbb5a9543e44617c67c63b3d78748'
runTest 'POST' "${BASE_URL}/IpamDriver.RequestAddress" '{ "PoolID": "test1", "Address": "", "Options": { "Hostname": "test-4", "Domainname": "test.local" } }' 'b118534ef0e4d49e90d3f71d61fae750'
# del test-1 and test-2.test.local
runTest 'POST' "${BASE_URL}/IpamDriver.ReleaseAddress" '{ "PoolID": "test1", "Address": "10.100.1.0" }' '8a80554c91d9fca8acb82f023de02f11'
runTest 'POST' "${BASE_URL}/IpamDriver.ReleaseAddress" '{ "PoolID": "test1", "Address": "10.100.1.1" }' '8a80554c91d9fca8acb82f023de02f11'
# add test-5 and test-6.test.local
runTest 'POST' "${BASE_URL}/IpamDriver.RequestAddress" '{ "PoolID": "test1", "Address": "", "Options": { "Hostname": "test-5" } }' 'b52095cd2770cc4c224fb83179c50580'
runTest 'POST' "${BASE_URL}/IpamDriver.RequestAddress" '{ "PoolID": "test1", "Address": "", "Options": { "Hostname": "test-6", "Domainname": "test.local" } }' '781f355c6bdbc6778be7a402440adf8d'
# NOTE: not currently testing IP reuse cool down
sleep 124
# add test-7 and test-8.test.local
runTest 'POST' "${BASE_URL}/IpamDriver.RequestAddress" '{ "PoolID": "test1", "Address": "", "Options": { "Hostname": "test-7" } }' '63aa00d0a6d8f7ad6a11fbe0128d1513'
runTest 'POST' "${BASE_URL}/IpamDriver.RequestAddress" '{ "PoolID": "test1", "Address": "", "Options": { "Hostname": "test-8", "Domainname": "test.local" } }' 'eff5b05e22b68f6a02f01391c08c4b29'
# del test-3 and test-4.test.local
runTest 'POST' "${BASE_URL}/IpamDriver.ReleaseAddress" '{ "PoolID": "test1", "Address": "10.100.1.2" }' '8a80554c91d9fca8acb82f023de02f11'
runTest 'POST' "${BASE_URL}/IpamDriver.ReleaseAddress" '{ "PoolID": "test1", "Address": "10.100.1.3" }' '8a80554c91d9fca8acb82f023de02f11'
# verify database contents are as expected
runTest 'GET' "${BASE_URL}/dumpDb" '' 'f8006ae1a78e6c19aeab3dc0445dfc4a' '^(ipToInstance|poolNextCidrIp|instanceNameToCidrIp):'
if [[ "$SUCCESS" == 'true' ]]; then
echo 'OVERALL = PASS'
exit 0
else
echo 'OVERALL = FAIL'
exit 1
fi
index.html
-
<!DOCTYPE html>
<html>
<head>
<title>IPAM Server 0.2</title>
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta http-equiv="Content-Type" content="text/html;charset=utf-8">
<link rel="shortcut icon" href="/favicon.png">
<link href='https://fonts.googleapis.com/css?family=Hammersmith One' rel='stylesheet'>
<script src='/static/vendor/vue-dist/vue.js'></script>
<style>
body {
background-image: url("/static/subtle-grunge.png");
}
span.infoHeader {
margin: 3px 3px 3px 3px;
padding: 4px 4px 4px 4px;
border: 2px solid rgba(71,132,163,.9);
background-color: rgba(166,211,234,.6);
display: inline-block;
font-family: 'Hammersmith One';
font-size: 18px;
vertical-align: top;
}
div.service {
border-style: solid none none none;
border-width: 2px;
border-color: rgb(160,160,160);
padding: 4px 0px 2px 0px;
margin: 3px 0px 0px 0px;
}
span.service {
margin: 3px 0px 3px 3px;
padding: 4px 4px 4px 4px;
border: 2px solid rgba(122,102,85,.9);
background-color: rgba(242,190,146,.6);
display: inline-block;
font-family: 'Hammersmith One';
font-size: 18px;
vertical-align: top;
}
span.ipToInstance {
margin: 3px 3px 3px 3px;
padding: 4px 4px 4px 4px;
border: 2px solid rgba(134,179,0,.9);
background-color: rgba(236,255,179,.45);
display: inline-block;
font-family: 'Hammersmith One';
font-size: 18px;
vertical-align: top;
}
a.noUnderline {
text-decoration: none;
}
.fade-enter-active {
animation: fade-in 2s;
}
.fade-leave-active {
animation: fade-out 1.5s;
}
@keyframes fade-in {
0% {
transform: scale(.4);
opacity: 0;
}
50% {
transform: scale(1);
opacity: 1;
}
100% {
transform: scale(1);
opacity: 1;
background-color: rgba(236,255,179,1);
}
}
@keyframes fade-out {
100% {
transform: scale(.4);
opacity: 0;
}
50% {
transform: scale(1);
opacity: 1;
}
0% {
transform: scale(1);
opacity: 1;
}
}
.fade-move {
transition: transform .2s;
}
.appear-enter-active {
animation: appear 2s;
background-color: rgba(243, 252, 172, .5);
}
.appear-leave-active {
animation: appear 1.5s reverse;
}
@keyframes appear {
0% { opacity: 0; }
100% { opacity: 1; }
}
</style>
</head>
<body>
<span class="infoHeader"><a class="noUnderline" href="/dumpDb">View the current contents of the IPAM database</a></span>
<div id="app">
<transition-group name="appear">
<div class="service" v-for="service in instancesGroupedByServiceName" v-bind:key="service.serviceName">
<span class="service">{{ service.serviceName }}</span>
<transition-group name="fade">
<span class="ipToInstance" v-for="instance in service.instances" v-bind:key="instance.name">{{ instance.name }}<br/><strong>{{ instance.ip }}</strong>
<svg class="chart" v-bind:width="lifecycleWidth" v-bind:height="lifecycleHeight" xmlns="http://www.w3.org/2000/svg">
<!-- odd intermittent rendering issue of the gradient in Chrome, not using gradient for now (Isaac, 20170103)
<defs>
<linearGradient id="monthGradient">
<stop offset="0%" stop-color="rgba(223,175,66,.5)"/>
<stop offset="100%" stop-color="rgba(243,91,76,.65)"/>
</linearGradient>
</defs>
<rect x="0" y="0" v-bind:width="lifecycleWidth" v-bind:height="lifecycleHeight" fill="url(#monthGradient)"/>
-->
<rect x="0" y="0" v-bind:width="lifecycleSeperators.oneMonthX" v-bind:height="lifecycleHeight" fill="rgba(149,210,73,.5)"/>
<rect v-bind:x="lifecycleSeperators.oneMonthX" y="0" v-bind:width="lifecycleWidth - lifecycleSeperators.oneMonthX" v-bind:height="lifecycleHeight" fill="rgba(243,91,76,.65)"/>
<line y1="0" v-bind:y2="lifecycleHeight" v-bind:x1="getCurrentAdjustedPositionByLeasedEpoch(instance.leasedEpoch)" v-bind:x2="getCurrentAdjustedPositionByLeasedEpoch(instance.leasedEpoch)" stroke-width="8" stroke="rgba(73, 154, 193, 1)"/>
<text v-bind:y="lifecycleHeight" v-bind:x="lifecycleSeperators.oneMinX" text-anchor="middle" fill="rgba(0,0,0,.45)">1</text> <!-- 1 min -->
<text v-bind:y="lifecycleHeight" v-bind:x="lifecycleSeperators.fiveMinsX" text-anchor="middle" fill="rgba(0,0,0,.45)">5</text> <!-- 5 mins -->
<text v-bind:y="lifecycleHeight" v-bind:x="lifecycleSeperators.oneHourX" text-anchor="middle" fill="rgba(0,0,0,.45)">H</text> <!-- 1 hour -->
<text v-bind:y="lifecycleHeight" v-bind:x="lifecycleSeperators.oneDayX" text-anchor="middle" fill="rgba(0,0,0,.45)">D</text> <!-- 1 day -->
<text v-bind:y="lifecycleHeight" v-bind:x="lifecycleSeperators.oneWeekX" text-anchor="middle" fill="rgba(0,0,0,.45)">W</text> <!-- 1 week -->
<text v-bind:y="lifecycleHeight" v-bind:x="lifecycleSeperators.oneMonthX" text-anchor="middle" fill="rgba(0,0,0,.45)">M</text> <!-- 1 month -->
</svg>
</span>
</transition-group>
</div>
</transition-group>
</div>
<script>
let hidden,
visibilityChange,
updateUiSetIntervalTimer;
const fetchIpToInstance = async () => {
const response = await fetch('/instances');
return await response.json();
}
let mockFetchIpToInstance = async () => {
return [
{
"ip": "10.100.1.0",
"name": "org-gitlab-runner-latest-3-s4z7etb9w5u23b4wad9hffcbz"
},
{
"ip": "10.100.1.1",
"name": "org-gitlab-runner-latest-2-sq8i9adaex1urd8pf0zjiqwbj"
},
{
"ip": "10.100.1.2",
"name": "org-gitlab-runner-latest-4-n8ww0ux4zfpc83pup5lmfuvdl"
},
{
"ip": "10.100.1.3",
"name": "org-gitlab-runner-latest-5-nlwzzf821i01989l5j40at5u4"
},
{
"ip": "10.100.1.4",
"name": "org-gitlab-runner-latest-1-yc9w1hfpissdmb6qf3cep2sd4"
},
{
"ip": "10.100.1.5",
"name": "portainer-1"
}
]
}
const updateIpToInstanceCardUi = async (fetchJson) => {
let newInstancesGroupedByServiceName;
try {
const jsonResponse = await fetchJson();
newInstancesGroupedByServiceName = getInstancesGroupedByService(jsonResponse);
} catch (error) {
newInstancesGroupedByServiceName = [
{
"serviceName": "Errored / offline",
"instances": []
}
];
}
app.instancesGroupedByServiceName = newInstancesGroupedByServiceName;
}
let app = new Vue({
el: "#app",
data: {
instancesGroupedByServiceName: [
{
"serviceName": "Loading",
"instances": []
}
],
lifecycleWidth: 210,
lifecycleHeight: 11,
lifecycleLogBase: Math.log(6.9196)
},
methods: {
getLifecycleBySecs: function(secs) {
if (secs < 0) {
return 0;
}
const lifecycle = Math.log(secs) / this.lifecycleLogBase / 8;
if (lifecycle < 0) {
return 0;
}
if (lifecycle > 1) {
return 1;
}
return lifecycle;
},
getAdjustedLifecycleBySecs: function(secs) {
const minLifecycle = this.getLifecycleBySecs(20),
adjustmentMultipler = 1 / (1 - minLifecycle),
adjustedLifecycle = (this.getLifecycleBySecs(secs) - minLifecycle) * adjustmentMultipler;
if (adjustedLifecycle < 0) {
return 0;
}
if (adjustedLifecycle > 1) {
return 1;
}
return adjustedLifecycle;
},
getCurrentPositionBySecs: function(secs) {
return Math.round(this.getLifecycleBySecs(secs) * this.lifecycleWidth);
},
getCurrentAdjustedPositionBySecs: function(secs) {
return Math.round(this.getAdjustedLifecycleBySecs(secs) * this.lifecycleWidth);
},
getCurrentAdjustedPositionByLeasedEpoch: function(leasedEpoch) {
return this.getCurrentAdjustedPositionBySecs(this.getSecsSinceLeasedEpoch(leasedEpoch));
},
getLeasedEpochDate: function(leasedEpoch) {
return new Date(parseInt(leasedEpoch));
},
getLeasedEpochLocaleString: function(leasedEpoch) {
return this.getLeasedEpochDate(leasedEpoch).toLocaleString();
},
getSecsSinceLeasedEpoch: function(leasedEpoch) {
return (new Date().getTime()-parseInt(leasedEpoch)) / 1000;
}
},
computed: {
lifecycleSeperators: function () {
return {
oneMinX: this.getCurrentAdjustedPositionBySecs(60),
fiveMinsX: this.getCurrentAdjustedPositionBySecs(60*5),
oneHourX: this.getCurrentAdjustedPositionBySecs(60*60),
oneDayX: this.getCurrentAdjustedPositionBySecs(60*60*24),
oneWeekX: this.getCurrentAdjustedPositionBySecs(60*60*24*7),
oneMonthX: this.getCurrentAdjustedPositionBySecs(365/12*60*60*24),
oneMonthPercentString: `${this.getAdjustedLifecycleBySecs(365/12*60*60*24) * 100}%`
}
}
}
});
const getServiceLabel = (item) => {
const [serviceName, slotId, taskId, domainSuffix] = item.name.split(/(-\d+)(-\w+)?(\.\w+)?$/)
if (typeof domainSuffix === 'string' && domainSuffix != '' && domainSuffix != '.nostack') {
const domain = domainSuffix.split('.')[1];
return `${domain}/${serviceName}`;
}
if (typeof slotId === 'undefined') {
return 'individual'
}
return serviceName
}
const sortServicesByName = (a, b) => {
if (a === 'individual') { return 1; }
if (b === 'individual') { return -1; }
if (a > b) { return 1; }
if (a < b) { return -1; }
return 0;
}
const getInstancesGroupedByService = (json) => {
let groupedInstances = {},
groupedInstancesResult = [];
for (item of json) {
const serviceName = getServiceLabel(item)
if (typeof groupedInstances[serviceName] === 'undefined') {
groupedInstances[serviceName] = [item]
} else {
groupedInstances[serviceName].push(item)
}
}
const sortedServiceNames = Object.keys(groupedInstances).sort(sortServicesByName);
for (serviceName of sortedServiceNames) {
const serviceInstances = groupedInstances[serviceName];
groupedInstancesResult.push({
serviceName: serviceName,
instances: serviceInstances
});
}
return groupedInstancesResult;
}
const updateUi = () => updateIpToInstanceCardUi(fetchIpToInstance);
const scheduleUpdateUiAndRunNow = () => {
updateUi();
updateUiSetIntervalTimer = setInterval(updateUi, 2000);
}
scheduleUpdateUiAndRunNow();
if (typeof document.hidden !== "undefined") { // Opera 12.10 and Firefox 18 and later support
hidden = "hidden";
visibilityChange = "visibilitychange";
} else if (typeof document.msHidden !== "undefined") {
hidden = "msHidden";
visibilityChange = "msvisibilitychange";
} else if (typeof document.webkitHidden !== "undefined") {
hidden = "webkitHidden";
visibilityChange = "webkitvisibilitychange";
}
const handleVisibilityChange = () => {
if (document[hidden]) {
if (typeof updateUiSetIntervalTimer === "number") {
clearInterval(updateUiSetIntervalTimer);
}
} else {
scheduleUpdateUiAndRunNow();
}
}
document.addEventListener(visibilityChange, handleVisibilityChange, false);
</script>
</body>
</html>
Dockerfile
-
ARG IMAGE_TAG=latest
FROM registry.org.com:5000/docker-ipam-http-server-module-build:${IMAGE_TAG} as moduleBuild
####################
FROM node:9-alpine
RUN apk --no-cache upgrade && \
apk --no-cache add bash nano curl grep && \
adduser -D -u 10000 ipam ipam && \
mkdir -p /opt/ipam/db && \
mkdir -p /home/ipam/.pm2 && \
chown -R ipam:ipam /home/ipam/.pm2
COPY --from=moduleBuild /opt/ipam /opt/ipam
WORKDIR /opt/ipam
COPY ./tls /opt/ipam/tls
RUN cat ./tls/star-org-com-Cert.crt ./tls/star-org-com-Int.crt > ./tls/star-org-com-Bundle.crt
RUN yarn install && \
yarn global add pm2
RUN mkdir /opt/ipam/log && \
chown -R ipam:ipam /opt/ipam
COPY ./static /opt/ipam/static
RUN chown -R ipam:ipam /opt/ipam/static
EXPOSE 8080
COPY ./bin /opt/ipam/bin
# To speed up changes to server.js and start.sh
RUN ln -s /opt/ipam/node_modules/vue/dist /opt/ipam/static/vendor/vue-dist && \
chown -R ipam:ipam /opt/ipam && \
chmod -R ug+rX /opt/ipam
COPY ./server.js /opt/ipam/
RUN chown ipam:ipam /opt/ipam/server.js && \
chmod ug+r /opt/ipam/server.js
WORKDIR /opt/ipam
USER 10000
CMD ["bin/start.sh"]
docker-ipam-pdns-server/Dockerfile
-
FROM debian:latest
RUN apt-get -y update && \
apt-get -y install pdns-server pdns-backend-remote libterm-readline-perl-perl && \
echo 'remote-connection-string=http:url=http://docker-ipam-http-server:8080/dns' >> /etc/powerdns/pdns.conf && \
sed -ri 's/^\s*#?\s*launch\s*.*$/launch=remote/g' /etc/powerdns/pdns.conf && \
sed -ri 's/^\s*#?\s*socket-dir\s*=.*$/socket-dir=\/var\/run\/pdns/g' /etc/powerdns/pdns.conf && \
mkdir -p /var/run/pdns
CMD ["/usr/sbin/pdns_server"]
Thanks for sharing this.
How ever I wondering that did you ever implement actual IPAM service which is compatible with this pluging?