dynalite
Version:
An implementation of Amazon's DynamoDB built on LevelDB
270 lines (231 loc) • 11.8 kB
JavaScript
var http = require('http'),
https = require('https'),
fs = require('fs'),
path = require('path'),
url = require('url'),
crypto = require('crypto'),
crc32 = require('buffer-crc32'),
validations = require('./validations'),
db = require('./db')
var MAX_REQUEST_BYTES = 16 * 1024 * 1024
var validApis = ['DynamoDB_20111205', 'DynamoDB_20120810'],
validOperations = ['BatchGetItem', 'BatchWriteItem', 'CreateTable', 'DeleteItem', 'DeleteTable',
'DescribeTable', 'DescribeTimeToLive', 'GetItem', 'ListTables', 'PutItem', 'Query', 'Scan', 'TagResource',
'UntagResource', 'ListTagsOfResource', 'UpdateItem', 'UpdateTable'],
actions = {},
actionValidations = {}
module.exports = dynalite
function dynalite(options) {
options = options || {}
var server, store = db.create(options), requestHandler = httpHandler.bind(null, store)
if (options.ssl) {
options.key = options.key || fs.readFileSync(path.join(__dirname, 'ssl', 'server-key.pem'))
options.cert = options.cert || fs.readFileSync(path.join(__dirname, 'ssl', 'server-crt.pem'))
options.ca = options.ca || fs.readFileSync(path.join(__dirname, 'ssl', 'ca-crt.pem'))
server = https.createServer(options, requestHandler)
} else {
server = http.createServer(requestHandler)
}
// Ensure we close DB when we're closing the server too
var httpServerClose = server.close, httpServerListen = server.listen
server.close = function(cb) {
store.db.close(function(err) {
if (err) return cb(err)
// Recreate the store if the user wants to listen again
server.listen = function() {
store.recreate()
httpServerListen.apply(server, arguments)
}
httpServerClose.call(server, cb)
})
}
return server
}
validOperations.forEach(function(action) {
action = validations.toLowerFirst(action)
actions[action] = require('./actions/' + action)
actionValidations[action] = require('./validations/' + action)
})
function rand52CharId() {
// 39 bytes turns into 52 base64 characters
var bytes = crypto.randomBytes(39)
// Need to replace + and / so just choose 0, obvs won't be truly random, whatevs
return bytes.toString('base64').toUpperCase().replace(/\+|\//g, '0')
}
function sendData(req, res, data, statusCode) {
var body = JSON.stringify(data)
req.removeAllListeners()
res.statusCode = statusCode || 200
res.setHeader('x-amz-crc32', crc32.unsigned(body))
res.setHeader('Content-Type', res.contentType)
res.setHeader('Content-Length', Buffer.byteLength(body, 'utf8'))
// AWS doesn't send a 'Connection' header but seems to use keep-alive behaviour
// res.setHeader('Connection', '')
// res.shouldKeepAlive = false
res.end(body)
}
function httpHandler(store, req, res) {
var body
req.on('error', function(err) { throw err })
req.on('data', function(data) {
var newLength = data.length + (body ? body.length : 0)
if (newLength > MAX_REQUEST_BYTES) {
req.removeAllListeners()
res.statusCode = 413
res.setHeader('Transfer-Encoding', 'chunked')
return res.end()
}
body = body ? Buffer.concat([body, data], newLength) : data
})
req.on('end', function() {
body = body ? body.toString() : ''
// All responses after this point have a RequestId
res.setHeader('x-amzn-RequestId', rand52CharId())
if (req.headers.origin) {
res.setHeader('Access-Control-Allow-Origin', '*')
if (req.method == 'OPTIONS') {
if (req.headers['access-control-request-headers'])
res.setHeader('Access-Control-Allow-Headers', req.headers['access-control-request-headers'])
if (req.headers['access-control-request-method'])
res.setHeader('Access-Control-Allow-Methods', req.headers['access-control-request-method'])
res.setHeader('Access-Control-Max-Age', 172800)
res.setHeader('Content-Length', 0)
req.removeAllListeners()
return res.end()
}
}
if (req.method == 'GET') {
req.removeAllListeners()
res.statusCode = 200
res.setHeader('x-amz-crc32', 3128867991)
res.setHeader('Content-Length', 42)
return res.end('healthy: dynamodb.us-east-1.amazonaws.com ')
}
var contentType = (req.headers['content-type'] || '').split(';')[0].trim()
if (req.method != 'POST' ||
(body && contentType != 'application/json' && contentType != 'application/x-amz-json-1.0')) {
req.removeAllListeners()
res.statusCode = 404
res.setHeader('x-amz-crc32', 3552371480)
res.setHeader('Content-Length', 29)
return res.end('<UnknownOperationException/>\n')
}
// TODO: Perhaps don't do this
res.contentType = contentType != 'application/x-amz-json-1.0' ? 'application/json' : contentType
// THEN check body, see if the JSON parses:
var data
if (body) {
try {
data = JSON.parse(body)
} catch (e) {
return sendData(req, res, {__type: 'com.amazon.coral.service#SerializationException'}, 400)
}
}
var target = (req.headers['x-amz-target'] || '').split('.')
if (target.length != 2 || !~validApis.indexOf(target[0]) || !~validOperations.indexOf(target[1]))
return sendData(req, res, {__type: 'com.amazon.coral.service#UnknownOperationException'}, 400)
var authHeader = req.headers.authorization
var query = url.parse(req.url, true).query
var authQuery = 'X-Amz-Algorithm' in query
if (authHeader && authQuery)
return sendData(req, res, {
__type: 'com.amazon.coral.service#InvalidSignatureException',
message: 'Found both \'X-Amz-Algorithm\' as a query-string param and \'Authorization\' as HTTP header.',
}, 400)
if ((!authHeader && !authQuery) || (authHeader && (authHeader.trim().slice(0, 5) != 'AWS4-')))
return sendData(req, res, {
__type: 'com.amazon.coral.service#MissingAuthenticationTokenException',
message: 'Request is missing Authentication Token',
}, 400)
var msg = '', params
if (authHeader) {
// TODO: Go through key-vals first
// "'Credential' not a valid key=value pair (missing equal-sign) in Authorization header: 'AWS4-HMAC-SHA256 \
// Signature=b, Credential, SignedHeaders'."
params = ['Credential', 'Signature', 'SignedHeaders']
var authParams = authHeader.split(/,| /).slice(1).filter(Boolean).reduce(function(obj, x) {
var keyVal = x.trim().split('=')
obj[keyVal[0]] = keyVal[1]
return obj
}, {})
params.forEach(function(param) {
if (!authParams[param])
// TODO: SignedHeaders *is* allowed to be an empty string at this point
msg += 'Authorization header requires \'' + param + '\' parameter. '
})
if (!req.headers['x-amz-date'] && !req.headers.date)
msg += 'Authorization header requires existence of either a \'X-Amz-Date\' or a \'Date\' header. '
if (msg) msg += 'Authorization=' + authHeader
} else {
params = ['X-Amz-Algorithm', 'X-Amz-Credential', 'X-Amz-Signature', 'X-Amz-SignedHeaders', 'X-Amz-Date']
params.forEach(function(param) {
if (!query[param])
msg += 'AWS query-string parameters must include \'' + param + '\'. '
})
if (msg) msg += 'Re-examine the query-string parameters.'
}
if (msg) {
return sendData(req, res, {
__type: 'com.amazon.coral.service#IncompleteSignatureException',
message: msg,
}, 400)
}
// THEN check Date format and expiration
// {"__type":"com.amazon.coral.service#IncompleteSignatureException","message":"Date must be in ISO-8601 'basic format'. \
// Got '201'. See http://en.wikipedia.org/wiki/ISO_8601"}
// {"__type":"com.amazon.coral.service#InvalidSignatureException","message":"Signature expired: 20130301T000000Z is \
// now earlier than 20130609T094515Z (20130609T100015Z - 15 min.)"}
// THEN check Host is in SignedHeaders (not case sensitive)
// {"__type":"com.amazon.coral.service#InvalidSignatureException","message":"'Host' must be a 'SignedHeader' in the AWS Authorization."}
// THEN check Algorithm
// {"__type":"com.amazon.coral.service#IncompleteSignatureException","message":"Unsupported AWS 'algorithm': \
// 'AWS4-HMAC-SHA25' (only AWS4-HMAC-SHA256 for now). "}
// THEN check Credential (trailing slashes are ignored)
// {"__type":"com.amazon.coral.service#IncompleteSignatureException","message":"Credential must have exactly 5 \
// slash-delimited elements, e.g. keyid/date/region/service/term, got 'a/b/c/d'"}
// THEN check Credential pieces, all must match exact case, keyid checking throws different error below
// {"__type":"com.amazon.coral.service#InvalidSignatureException","message":\
// "Credential should be scoped to a valid region, not 'c'. \
// Credential should be scoped to correct service: 'dynamodb'. \
// Credential should be scoped with a valid terminator: 'aws4_request', not 'e'. \
// Date in Credential scope does not match YYYYMMDD from ISO-8601 version of date from HTTP: 'b' != '20130609', from '20130609T095204Z'."}
// THEN check keyid
// {"__type":"com.amazon.coral.service#UnrecognizedClientException","message":"The security token included in the request is invalid."}
// THEN check signature (requires body - will need async)
// {"__type":"com.amazon.coral.service#InvalidSignatureException","message":"The request signature we calculated \
// does not match the signature you provided. Check your AWS Secret Access Key and signing method. \
// Consult the service documentation for details.\n\nThe Canonical String for this request should have \
// been\n'POST\n/\n\nhost:dynamodb.ap-southeast-2.amazonaws.com\n\nhost\ne3b0c44298fc1c149afbf4c8996fb92427ae41e46\
// 49b934ca495991b7852b855'\n\nThe String-to-Sign should have been\n'AWS4-HMAC-SHA256\n20130609T\
// 100759Z\n20130609/ap-southeast-2/dynamodb/aws4_request\n7b8b82a032afd6014771e3375813fc995dd167b7b3a133a0b86e5925cb000ec5'\n"}
// THEN check X-Amz-Security-Token if it exists
// {"__type":"com.amazon.coral.service#UnrecognizedClientException","message":"The security token included in the request is invalid"}
// THEN check types (note different capitalization for Message and poor grammar for a/an):
// THEN validation checks (note different service):
// {"__type":"com.amazon.coral.validate#ValidationException","message":"3 validation errors detected: \
// Value \'2147483647\' at \'limit\' failed to satisfy constraint: \
// Member must have value less than or equal to 100; \
// Value \'89hls;;f;d\' at \'exclusiveStartTableName\' failed to satisfy constraint: \
// Member must satisfy regular expression pattern: [a-zA-Z0-9_.-]+; \
// Value \'89hls;;f;d\' at \'exclusiveStartTableName\' failed to satisfy constraint: \
// Member must have length less than or equal to 255"}
// For some reason, the serialization checks seem to be a bit out of sync
if (!body)
return sendData(req, res, {__type: 'com.amazon.coral.service#SerializationException'}, 400)
var action = validations.toLowerFirst(target[1])
var actionValidation = actionValidations[action]
try {
data = validations.checkTypes(data, actionValidation.types)
validations.checkValidations(data, actionValidation.types, actionValidation.custom, store)
} catch (e) {
if (e.statusCode) return sendData(req, res, e.body, e.statusCode)
throw e
}
actions[action](store, data, function(err, data) {
if (err && err.statusCode) return sendData(req, res, err.body, err.statusCode)
if (err) throw err
sendData(req, res, data)
})
})
}
if (require.main === module) dynalite().listen(4567)