Verified Commit df1447ca authored by Sebastiaan Deckers's avatar Sebastiaan Deckers 馃悜

feat: server DDNS Constellix API

parent a57b8f53
Pipeline #60687891 passed with stage
in 2 minutes and 49 seconds
......@@ -7,6 +7,14 @@ module.exports = {
// Directory where uploaded files will be stored before processing
upload: process.env.COMMONSHOST_CORE_UPLOAD,
// Constellix DNS
constellix: {
domain: process.env.COMMONSHOST_CORE_CONSTELLIX_DOMAIN,
origin: process.env.COMMONSHOST_CORE_CONSTELLIX_ORIGIN,
apiKey: process.env.COMMONSHOST_CORE_CONSTELLIX_API_KEY,
secretKey: process.env.COMMONSHOST_CORE_CONSTELLIX_SECRET_KEY
},
// Object Storage
s3: {
s3cmd: process.env.COMMONSHOST_CORE_S3_S3CMD,
......
# Servers
## `PUT /v2/servers/{id}/ddns`
Scope: `ddns`
Only available for machine-to-machine applications using [client credentials](https://auth0.com/docs/api-auth/tutorials/client-credentials), i.e. client ID and client secret.
Dynamic DNS for edge servers. The server's DNS type A record is updated to point at the IPv4 source address of the API request.
Responds with `200` status code and the ipv4 address.
Response:
```js
{
ipv4: '10.20.30.40'
}
```
const crypto = require('crypto')
const fetch = require('node-fetch')
function createSecurityToken (apiKey, secretKey) {
const requestDate = String(Date.now())
const hmac = crypto.createHmac('sha1', secretKey)
hmac.update(requestDate)
const securityToken = `${apiKey}:${hmac.digest('base64')}:${requestDate}`
return securityToken
}
async function constellix (endpoint, {
apiKey,
secretKey,
origin,
version = 'v1',
...fetchOptions
}) {
const url = `${origin}/${version}/${endpoint}`
const securityToken = createSecurityToken(apiKey, secretKey)
const headers = {
'Content-Type': 'application/json',
'x-cns-security-token': securityToken
}
const response = fetch(url, {
...fetchOptions,
headers
})
return response
}
module.exports.constellix = constellix
const jwtPermissions = require('express-jwt-permissions')
const { constellix } = require('../../../../helpers/constellix')
const { isIPv4 } = require('net')
const { BadRequest, Forbidden } = require('http-errors')
const serverIdRegex = /^(?<country>[a-z]{2})-(?<iata>[a-z]{3})-(?<index>\d+)$/
let domainId
async function getDomainId (domain, options) {
if (domainId) return domainId
const response = await constellix('domains', options)
const domains = await response.json()
for (const { name, id } of domains) {
if (name === domain) {
return id
}
}
throw new Error(`Domain not found: ${domain}`)
}
const recordIds = new Map()
async function getRecordId (subdomain, domainId, options) {
if (recordIds.has(subdomain)) {
return recordIds.get(subdomain)
}
const endpoint = `domains/${domainId}/records/A`
const response = await constellix(endpoint, options)
const records = await response.json()
for (const record of records) {
if (record.name === subdomain) {
recordIds.set(subdomain, record.id)
return record.id
}
}
throw new Error(`Record not found: ${subdomain}`)
}
module.exports = async (fastify, options) => {
fastify.use(
jwtPermissions({ permissionsProperty: 'scope' })
.check('ddns')
)
fastify.route({
method: 'PUT',
url: '/servers/:id/ddns',
handler: async (request, reply) => {
const { ip, params: { id: serverId } } = request
if (!serverIdRegex.test(serverId)) {
throw new BadRequest('Invalid server ID')
}
if (!isIPv4(ip)) {
throw new BadRequest('Source address is not IPv4')
}
const { db } = fastify.mongo
const server = await db.collection('servers')
.findOne({ serverId, allowDdns: true })
if (!server || server.allowDdns === false) {
throw new Forbidden('DDNS is not allowed for this server')
}
if (server.ipv4 === ip) {
return { ipv4: ip }
}
const apiOptions = fastify.configuration.constellix
const domainId = await getDomainId(apiOptions.domain, apiOptions)
const recordId = await getRecordId(serverId, domainId, apiOptions)
const endpoint = `domains/${domainId}/records/A/${recordId}`
const method = 'PUT'
const body = {
recordOption: 'roundRobin',
name: serverId,
ttl: 60,
roundRobin: [{
disableFlag: false,
value: ip
}]
}
const options = {
body: JSON.stringify(body),
method,
...apiOptions
}
constellix(endpoint, options)
await db.collection('servers').updateOne(
{ serverId, allowDdns: true },
{
$set: { ipv4: ip },
$currentDate: { modified: true }
},
{ upsert: true }
)
return { ipv4: ip }
}
})
}
const createError = require('http-errors')
const { patch } = require('request')
const tls = require('tls')
const { constants } = require('http2')
const jwtPermissions = require('express-jwt-permissions')
......@@ -14,8 +13,6 @@ const headerBlacklist = [
constants.HTTP2_HEADER_TRANSFER_ENCODING
]
tls.DEFAULT_ECDH_CURVE = 'P-384:P-256'
module.exports = async (fastify, options) => {
fastify.use(
jwtPermissions({ permissionsProperty: 'scope' })
......
......@@ -29,9 +29,15 @@ module.exports = async (configuration) => {
{ useNewUrlParser: true }
)
const db = client.db(configuration.mongodb.database)
const collections = ['hosts', 'configurations', 'certificates']
await Promise.all(collections.map((collection) => {
return db.collection(collection).createIndex('domain', { unique: true })
await Promise.all([
['hosts', 'domain'],
['configurations', 'domain'],
['certificates', 'domain'],
['servers', 'serverId'],
['users', 'userId']
].map(([collection, index]) => {
return db.collection(collection)
.createIndex(index, { unique: true })
}))
await client.close()
}
......@@ -55,6 +61,7 @@ module.exports = async (configuration) => {
require('./routes/configurations/get'),
require('./routes/domains/available/get'),
require('./routes/domains/suggestions/get'),
require('./routes/servers/id/ddns/put'),
require('./routes/sites/domain/certificate/get'),
// require('./routes/sites/domain/certificate/put'),
require('./routes/sites/domain/configuration/get'),
......
......@@ -16,6 +16,13 @@ const credentials = {
scope: ['global_read'].join(' '),
client_id: process.env.COMMONSHOST_EDGE_AUTH0_CLIENT_ID,
client_secret: process.env.COMMONSHOST_EDGE_AUTH0_CLIENT_SECRET
},
ddns: {
grant_type: 'client_credentials',
audience: process.env.COMMONSHOST_DDNS_AUTH0_AUDIENCE,
scope: ['ddns'].join(' '),
client_id: process.env.COMMONSHOST_DDNS_AUTH0_CLIENT_ID,
client_secret: process.env.COMMONSHOST_DDNS_AUTH0_CLIENT_SECRET
}
}
......@@ -55,6 +62,10 @@ async function getEdgeAccessToken () {
return getAccessToken(credentials.edge)
}
async function getDdnsAccessToken () {
return getAccessToken(credentials.ddns)
}
async function getUserId () {
const accessToken = await getUserAccessToken()
const jwt = accessToken.split('.')[1]
......@@ -65,4 +76,5 @@ async function getUserId () {
module.exports.getUserAccessToken = getUserAccessToken
module.exports.getEdgeAccessToken = getEdgeAccessToken
module.exports.getDdnsAccessToken = getDdnsAccessToken
module.exports.getUserId = getUserId
const test = require('blue-tape')
const { fetch } = require('./helpers/fetch')
const configuration = require('../core.conf.example.js')
const { getDdnsAccessToken } = require('./helpers/credentials')
const { createServer } = require('http')
const { constants: {
HTTP_STATUS_BAD_REQUEST,
HTTP_STATUS_FORBIDDEN,
HTTP_STATUS_OK
} } = require('http2')
const mongo = require('./helpers/database')(configuration)
const server = require('..')
let constellix
test('Mock Constellix API', async (t) => {
constellix = createServer(async (request, response) => {
const { method, url } = request
console.log(method, url)
if (method === 'GET' && url === '/v1/domains') {
const body = [
{ id: 123, name: 'example.com' }
]
response.end(JSON.stringify(body))
} else if (method === 'GET' && url === '/v1/domains/123/records/A') {
const body = [
{ id: 1, name: 'aa-zzz-1' },
{ id: 2, name: 'aa-zzz-2' },
{ id: 3, name: 'aa-zzz-3' }
]
response.end(JSON.stringify(body))
} else if (method === 'PUT' && url === '/v1/domains/123/records/A/1') {
const body = []
for await (const chunk of request) {
body.push(chunk)
}
const raw = Buffer.concat(body)
const data = JSON.parse(raw)
t.deepEqual(data, {
recordOption: 'roundRobin',
name: 'aa-zzz-1',
ttl: 60,
roundRobin: [{
disableFlag: false,
value: '127.0.0.1'
}]
})
response.end()
} else {
response.end()
t.fail('Unknown endpoint')
}
})
constellix.listen(0)
})
let fastify
test('Start server', async (t) => {
const { port } = constellix.address()
configuration.constellix.origin = `http://localhost:${port}`
configuration.constellix.domain = 'example.com'
fastify = await server(configuration)
await fastify.listen(configuration.port, configuration.host)
})
test('Setup database fixtures', async (t) => {
await mongo.db.collection('servers').deleteMany()
await mongo.db.collection('servers').insertMany([
{
// IP will change to 127.0.0.1
serverId: 'aa-zzz-1',
modified: new Date(),
allowDdns: true,
ipv4: '10.20.30.40'
},
{
// DDNS disabled
serverId: 'aa-zzz-2',
modified: new Date(),
allowDdns: false,
ipv4: '127.0.0.1'
},
{
// Same IP already set
serverId: 'aa-zzz-3',
modified: new Date(),
allowDdns: true,
ipv4: '127.0.0.1'
}
])
})
test('Update DDNS', async (t) => {
const { port } = fastify.server.address()
const origin = `https://localhost:${port}`
const url = `${origin}/v2/servers/aa-zzz-1/ddns`
const response = await fetch(url, {
method: 'PUT',
headers: {
'authorization': `Bearer ${await getDdnsAccessToken()}`
}
})
t.is(response.status, HTTP_STATUS_OK)
const body = await response.json()
t.deepEqual(body, { ipv4: '127.0.0.1' })
})
test('Reject if DDNS is not enabled', async (t) => {
const { port } = fastify.server.address()
const origin = `https://localhost:${port}`
const url = `${origin}/v2/servers/aa-zzz-2/ddns`
const response = await fetch(url, {
method: 'PUT',
headers: { 'authorization': `Bearer ${await getDdnsAccessToken()}` }
})
t.is(response.status, HTTP_STATUS_FORBIDDEN)
})
test('Idempotent if same IP is already set', async (t) => {
const { port } = fastify.server.address()
const origin = `https://localhost:${port}`
const url = `${origin}/v2/servers/aa-zzz-3/ddns`
const response = await fetch(url, {
method: 'PUT',
headers: { 'authorization': `Bearer ${await getDdnsAccessToken()}` }
})
t.is(response.status, HTTP_STATUS_OK)
const body = await response.json()
t.deepEqual(body, { ipv4: '127.0.0.1' })
})
test('Reject if not a valid server ID', async (t) => {
const { port } = fastify.server.address()
const origin = `https://localhost:${port}`
const url = `${origin}/v2/servers/foo-bar-baz/ddns`
const response = await fetch(url, {
method: 'PUT',
headers: { 'authorization': `Bearer ${await getDdnsAccessToken()}` }
})
t.is(response.status, HTTP_STATUS_BAD_REQUEST)
})
test('Stop server', (t) => fastify.close())
test('Stop mock Constellix', (t) => constellix.close(t.end))
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment