From ef524033757d35cf99c4137a8095adf8b08fdc9d Mon Sep 17 00:00:00 2001 From: Marco Palladino Date: Wed, 28 Dec 2016 15:54:58 -0800 Subject: [PATCH] feat(plugins) AWS Lambda (#1777) --- kong-0.9.7-0.rockspec | 4 + kong/constants.lua | 3 +- kong/plugins/aws-lambda/handler.lua | 113 +++++++++ kong/plugins/aws-lambda/schema.lua | 17 ++ kong/plugins/aws-lambda/v4.lua | 228 ++++++++++++++++++ kong/tools/responses.lua | 11 +- .../17-aws-lambda/01-access_spec.lua | 168 +++++++++++++ 7 files changed, 539 insertions(+), 5 deletions(-) create mode 100644 kong/plugins/aws-lambda/handler.lua create mode 100644 kong/plugins/aws-lambda/schema.lua create mode 100644 kong/plugins/aws-lambda/v4.lua create mode 100644 spec/03-plugins/17-aws-lambda/01-access_spec.lua diff --git a/kong-0.9.7-0.rockspec b/kong-0.9.7-0.rockspec index 440fafe277e..c3909872b3f 100644 --- a/kong-0.9.7-0.rockspec +++ b/kong-0.9.7-0.rockspec @@ -268,5 +268,9 @@ build = { ["kong.plugins.bot-detection.rules"] = "kong/plugins/bot-detection/rules.lua", ["kong.plugins.bot-detection.cache"] = "kong/plugins/bot-detection/cache.lua", ["kong.plugins.bot-detection.hooks"] = "kong/plugins/bot-detection/hooks.lua", + + ["kong.plugins.aws-lambda.handler"] = "kong/plugins/aws-lambda/handler.lua", + ["kong.plugins.aws-lambda.schema"] = "kong/plugins/aws-lambda/schema.lua", + ["kong.plugins.aws-lambda.v4"] = "kong/plugins/aws-lambda/v4.lua", } } diff --git a/kong/constants.lua b/kong/constants.lua index 41d97d6a774..84ba1de10ee 100644 --- a/kong/constants.lua +++ b/kong/constants.lua @@ -3,7 +3,8 @@ local plugins = { "file-log", "http-log", "key-auth", "hmac-auth", "basic-auth", "ip-restriction", "galileo", "request-transformer", "response-transformer", "request-size-limiting", "rate-limiting", "response-ratelimiting", "syslog", - "loggly", "datadog", "runscope", "ldap-auth", "statsd", "bot-detection" + "loggly", "datadog", "runscope", "ldap-auth", "statsd", "bot-detection", + "aws-lambda" } local plugin_map = {} diff --git a/kong/plugins/aws-lambda/handler.lua b/kong/plugins/aws-lambda/handler.lua new file mode 100644 index 00000000000..40a3c372da5 --- /dev/null +++ b/kong/plugins/aws-lambda/handler.lua @@ -0,0 +1,113 @@ +-- Copyright (C) Mashape, Inc. + +local BasePlugin = require "kong.plugins.base_plugin" +local aws_v4 = require "kong.plugins.aws-lambda.v4" +local responses = require "kong.tools.responses" +local utils = require "kong.tools.utils" +local Multipart = require "multipart" +local http = require "resty.http" +local cjson = require "cjson.safe" + +local string_find = string.find +local ngx_req_get_headers = ngx.req.get_headers +local ngx_req_read_body = ngx.req.read_body +local ngx_req_get_post_args = ngx.req.get_post_args +local ngx_req_get_uri_args = ngx.req.get_uri_args +local ngx_req_get_body_data = ngx.req.get_body_data + +local CONTENT_TYPE = "content-type" + +local AWSLambdaHandler = BasePlugin:extend() + +function AWSLambdaHandler:new() + AWSLambdaHandler.super.new(self, "aws-lambda") +end + +local function retrieve_parameters() + ngx_req_read_body() + local body_parameters, err + local content_type = ngx_req_get_headers()[CONTENT_TYPE] + if content_type and string_find(content_type:lower(), "multipart/form-data", nil, true) then + body_parameters = Multipart(ngx_req_get_body_data(), content_type):get_all() + elseif content_type and string_find(content_type:lower(), "application/json", nil, true) then + body_parameters, err = cjson.decode(ngx_req_get_body_data()) + if err then + body_parameters = {} + end + else + body_parameters = ngx_req_get_post_args() + end + + return utils.table_merge(ngx_req_get_uri_args(), body_parameters) +end + +function AWSLambdaHandler:access(conf) + AWSLambdaHandler.super.access(self) + + local bodyJson = cjson.encode(retrieve_parameters()) + + local host = string.format("lambda.%s.amazonaws.com", conf.aws_region) + local path = string.format("/2015-03-31/functions/%s/invocations", + conf.function_name) + + local opts = { + region = conf.aws_region, + service = "lambda", + method = "POST", + headers = { + ["X-Amz-Target"] = "invoke", + ["X-Amz-Invocation-Type"] = conf.invocation_type, + ["X-Amx-Log-Type"] = conf.log_type, + ["Content-Type"] = "application/x-amz-json-1.1", + ["Content-Length"] = tostring(#bodyJson) + }, + body = bodyJson, + path = path, + access_key = conf.aws_key, + secret_key = conf.aws_secret, + query = conf.qualifier and "Qualifier="..conf.qualifier + } + + local request, err = aws_v4(opts) + if err then + return responses.send_HTTP_INTERNAL_SERVER_ERROR(err) + end + + -- Trigger request + local client = http.new() + client:connect(host, 443) + client:set_timeout(conf.timeout) + local ok, err = client:ssl_handshake() + if not ok then + return responses.send_HTTP_INTERNAL_SERVER_ERROR(err) + end + + local res, err = client:request { + method = "POST", + path = request.url, + body = request.body, + headers = request.headers + } + if not res then + return responses.send_HTTP_INTERNAL_SERVER_ERROR(err) + end + + local status = res.status + local body = res:read_body() + local headers = res.headers + + local ok, err = client:set_keepalive(conf.keepalive) + if not ok then + return responses.send_HTTP_INTERNAL_SERVER_ERROR(err) + end + + -- Send response to client + for k, v in pairs(headers) do + ngx.header[k] = v + end + responses.send(status, body, headers, true) +end + +AWSLambdaHandler.PRIORITY = 750 + +return AWSLambdaHandler diff --git a/kong/plugins/aws-lambda/schema.lua b/kong/plugins/aws-lambda/schema.lua new file mode 100644 index 00000000000..3e881772a79 --- /dev/null +++ b/kong/plugins/aws-lambda/schema.lua @@ -0,0 +1,17 @@ +return { + fields = { + timeout = {type = "number", default = 60000, required = true }, + keepalive = {type = "number", default = 60000, required = true }, + aws_key = {type = "string", required = true}, + aws_secret = {type = "string", required = true}, + aws_region = {type = "string", required = true, enum = { + "us-east-1", "us-east-2", "ap-northeast-1", "ap-northeast-2", + "ap-southeast-1", "ap-southeast-2", "eu-central-1", "eu-west-1"}}, + function_name = {type="string", required = true}, + qualifier = {type = "string"}, + invocation_type = {type = "string", required = true, default = "RequestResponse", + enum = {"RequestResponse", "Event", "DryRun"}}, + log_type = {type = "string", required = true, default = "Tail", + enum = {"Tail", "None"}} + } +} diff --git a/kong/plugins/aws-lambda/v4.lua b/kong/plugins/aws-lambda/v4.lua new file mode 100644 index 00000000000..d843808d3ad --- /dev/null +++ b/kong/plugins/aws-lambda/v4.lua @@ -0,0 +1,228 @@ +-- Performs AWSv4 Signing +-- http://docs.aws.amazon.com/general/latest/gr/sigv4_signing.html + +local resty_sha256 = require "resty.sha256" +local pl_string = require "pl.stringx" +local crypto = require "crypto" + +local ALGORITHM = "AWS4-HMAC-SHA256" + +local CHAR_TO_HEX = {}; +for i = 0, 255 do + local char = string.char(i) + local hex = string.format("%02x", i) + CHAR_TO_HEX[char] = hex +end + +local function hmac(key, msg) + return crypto.hmac.digest("sha256", msg, key, true) +end + +local function hash(str) + local sha256 = resty_sha256:new() + sha256:update(str) + return sha256:final() +end + +local function hex_encode(str) -- From prosody's util.hex + return (str:gsub(".", CHAR_TO_HEX)) +end + +local function percent_encode(char) + return string.format("%%%02X", string.byte(char)) +end + +local function urldecode(str) + return (str:gsub("%%(%x%x)", function(c) + return string.char(tonumber(c, 16)) + end)) +end + +local function canonicalise_path(path) + local segments = {} + for segment in path:gmatch("/([^/]*)") do + if segment == "" or segment == "." then + segments = segments -- do nothing and avoid lint + elseif segment == ".." then + -- intentionally discards components at top level + segments[#segments] = nil + else + segments[#segments+1] = urldecode(segment):gsub("[^%w%-%._~]", percent_encode) + end + end + local len = #segments + if len == 0 then return "/" end + -- If there was a slash on the end, keep it there. + if path:sub(-1, -1) == "/" then + len = len + 1 + segments[len] = "" + end + segments[0] = "" + segments = table.concat(segments, "/", 0, len) + return segments +end + +local function canonicalise_query_string(query) + local q = {} + for key, val in query:gmatch("([^&=]+)=?([^&]*)") do + key = urldecode(key):gsub("[^%w%-%._~]", percent_encode) + val = urldecode(val):gsub("[^%w%-%._~]", percent_encode) + q[#q+1] = key .. "=" .. val + end + table.sort(q) + return table.concat(q, "&") +end + +local function derive_signing_key(kSecret, date, region, service) + local kDate = hmac("AWS4" .. kSecret, date) + local kRegion = hmac(kDate, region) + local kService = hmac(kRegion, service) + local kSigning = hmac(kService, "aws4_request") + return kSigning +end + +local function prepare_awsv4_request(tbl) + local domain = tbl.domain or "amazonaws.com" + local region = tbl.region + local service = tbl.service + local request_method = tbl.method + local canonicalURI = tbl.canonicalURI + local path = tbl.path + if path and not canonicalURI then + canonicalURI = canonicalise_path(path) + elseif canonicalURI == nil or canonicalURI == "" then + canonicalURI = "/" + end + local canonical_querystring = tbl.canonical_querystring + local query = tbl.query + if query and not canonical_querystring then + canonical_querystring = canonicalise_query_string(query) + end + local req_headers = tbl.headers or {} + local req_payload = tbl.body + local access_key = tbl.access_key + local signing_key = tbl.signing_key + local secret_key + if not signing_key then + secret_key = tbl.secret_key + if secret_key == nil then + return nil, "either 'signing_key' or 'secret_key' must be provided" + end + end + local timestamp = tbl.timestamp or os.time() + local tls = tbl.tls + if tls == nil then tls = true end + local port = tbl.port or (tls and 443 or 80) + local req_date = os.date("!%Y%m%dT%H%M%SZ", timestamp) + local date = os.date("!%Y%m%d", timestamp) + + local host = service .. "." .. region .. "." .. domain + local host_header do -- If the "standard" port is not in use, the port should be added to the Host header + local with_port + if tls then + with_port = port ~= 443 + else + with_port = port ~= 80 + end + if with_port then + host_header = string.format("%s:%d", host, port) + else + host_header = host + end + end + + local headers = { + ["X-Amz-Date"] = req_date; + Host = host_header; + } + local add_auth_header = true + for k, v in pairs(req_headers) do + k = k:gsub("%f[^%z-]%w", string.upper) -- convert to standard header title case + if k == "Authorization" then + add_auth_header = false + elseif v == false then -- don't allow a default value for this header + v = nil + end + headers[k] = v + end + + -- Task 1: Create a Canonical Request For Signature Version 4 + -- http://docs.aws.amazon.com/general/latest/gr/sigv4-create-canonical-request.html + local canonical_headers, signed_headers do + -- We structure this code in a way so that we only have to sort once. + canonical_headers, signed_headers = {}, {} + local i = 0 + for name, value in pairs(headers) do + if value then -- ignore headers with 'false', they are used to override defaults + i = i + 1 + local name_lower = name:lower() + signed_headers[i] = name_lower + if canonical_headers[name_lower] ~= nil then + return nil, "header collision" + end + canonical_headers[name_lower] = pl_string.strip(value) + end + end + table.sort(signed_headers) + for j=1, i do + local name = signed_headers[j] + local value = canonical_headers[name] + canonical_headers[j] = name .. ":" .. value .. "\n" + end + signed_headers = table.concat(signed_headers, ";", 1, i) + canonical_headers = table.concat(canonical_headers, nil, 1, i) + end + local canonical_request = + request_method .. '\n' .. + canonicalURI .. '\n' .. + (canonical_querystring or "") .. '\n' .. + canonical_headers .. '\n' .. + signed_headers .. '\n' .. + hex_encode(hash(req_payload or "")) + + local hashed_canonical_request = hex_encode(hash(canonical_request)) + -- Task 2: Create a String to Sign for Signature Version 4 + -- http://docs.aws.amazon.com/general/latest/gr/sigv4-create-string-to-sign.html + local credential_scope = date .. "/" .. region .. "/" .. service .. "/aws4_request" + local string_to_sign = + ALGORITHM .. '\n' .. + req_date .. '\n' .. + credential_scope .. '\n' .. + hashed_canonical_request + + -- Task 3: Calculate the AWS Signature Version 4 + -- http://docs.aws.amazon.com/general/latest/gr/sigv4-calculate-signature.html + if signing_key == nil then + signing_key = derive_signing_key(secret_key, date, region, service) + end + local signature = hex_encode(hmac(signing_key, string_to_sign)) + -- Task 4: Add the Signing Information to the Request + -- http://docs.aws.amazon.com/general/latest/gr/sigv4-add-signature-to-request.html + local authorization = ALGORITHM + .. " Credential=" .. access_key .."/" .. credential_scope + .. ", SignedHeaders=" .. signed_headers + .. ", Signature=" .. signature + if add_auth_header then + headers.Authorization = authorization + end + + local target = path or canonicalURI + if query or canonical_querystring then + target = target .. "?" .. (query or canonical_querystring) + end + local scheme = tls and "https" or "http" + local url = scheme .. "://" .. host_header .. target + + return { + url = url, + host = host, + port = port, + tls = tls, + method = request_method, + target = target, + headers = headers, + body = req_payload, + } +end + +return prepare_awsv4_request \ No newline at end of file diff --git a/kong/tools/responses.lua b/kong/tools/responses.lua index ad610b830ee..f0f0e2b6482 100644 --- a/kong/tools/responses.lua +++ b/kong/tools/responses.lua @@ -95,7 +95,7 @@ local function send_response(status_code) -- @see https://github.com/openresty/lua-nginx-module -- @param content (Optional) The content to send as a response. -- @return ngx.exit (Exit current context) - return function(content, headers) + return function(content, headers, raw_body) if status_code >= _M.status_codes.HTTP_INTERNAL_SERVER_ERROR then if content then ngx.log(ngx.ERR, tostring(content)) @@ -116,7 +116,9 @@ local function send_response(status_code) content = response_default_content[status_code](content) end - if type(content) == "table" then + if raw_body then + ngx.say(content) + elseif type(content) == "table" then ngx.say(cjson.encode(content)) elseif content then ngx.say(cjson.encode {message = content}) @@ -143,15 +145,16 @@ local closure_cache = {} -- @param[type=number] status_code HTTP status code to send -- @param body A string or table which will be the body of the sent response. If table, the response will be encoded as a JSON object. If string, the response will be a JSON object and the string will be contained in the `message` property. -- @param[type=table] headers Response headers to send. +-- @param[type=boolean] raw_body Sent as it is if set to true. -- @return ngx.exit (Exit current context) -function _M.send(status_code, body, headers) +function _M.send(status_code, body, headers, raw_body) local res = closure_cache[status_code] if not res then res = send_response(status_code) closure_cache[status_code] = res end - return res(body, headers) + return res(body, headers, raw_body) end return _M diff --git a/spec/03-plugins/17-aws-lambda/01-access_spec.lua b/spec/03-plugins/17-aws-lambda/01-access_spec.lua new file mode 100644 index 00000000000..591cd06f52b --- /dev/null +++ b/spec/03-plugins/17-aws-lambda/01-access_spec.lua @@ -0,0 +1,168 @@ +local helpers = require "spec.helpers" + +describe("Plugin: AWS Lambda (access)", function() + local client, api_client + setup(function() + assert(helpers.start_kong()) + + local api1 = assert(helpers.dao.apis:insert { + request_host = "lambda.com", + upstream_url = "http://httpbin.org" + }) + local api2 = assert(helpers.dao.apis:insert { + request_host = "lambda2.com", + upstream_url = "http://httpbin.org" + }) + local api3 = assert(helpers.dao.apis:insert { + request_host = "lambda3.com", + upstream_url = "http://httpbin.org" + }) + + assert(helpers.dao.plugins:insert { + name = "aws-lambda", + api_id = api1.id, + config = { + aws_key = "AKIAIDPNYYGMJOXN26SQ", + aws_secret = "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", + aws_region = "us-east-1", + function_name = "kongLambdaTest" + } + }) + + assert(helpers.dao.plugins:insert { + name = "aws-lambda", + api_id = api2.id, + config = { + aws_key = "AKIAIDPNYYGMJOXN26SQ", + aws_secret = "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", + aws_region = "us-east-1", + function_name = "kongLambdaTest", + invocation_type = "Event" + } + }) + + assert(helpers.dao.plugins:insert { + name = "aws-lambda", + api_id = api3.id, + config = { + aws_key = "AKIAIDPNYYGMJOXN26SQ", + aws_secret = "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", + aws_region = "us-east-1", + function_name = "kongLambdaTest", + invocation_type = "DryRun" + } + }) + end) + before_each(function() + client = helpers.proxy_client() + api_client = helpers.admin_client() + end) + after_each(function () + client:close() + api_client:close() + end) + teardown(function() + helpers.stop_kong() + end) + + it("invokes a Lambda function with GET", function() + local res = assert(client:send { + method = "GET", + path = "/get?key1=some_value1&key2=some_value2&key3=some_value3", + headers = { + ["Host"] = "lambda.com" + } + }) + local body = assert.res_status(200, res) + assert.is_string(res.headers["x-amzn-RequestId"]) + assert.equal([["some_value1"]], body) + end) + it("invokes a Lambda function with POST params", function() + local res = assert(client:send { + method = "POST", + path = "/post", + headers = { + ["Host"] = "lambda.com", + ["Content-Type"] = "application/x-www-form-urlencoded" + }, + body = { + key1 = "some_value_post1", + key2 = "some_value_post2", + key3 = "some_value_post3" + } + }) + local body = assert.res_status(200, res) + assert.is_string(res.headers["x-amzn-RequestId"]) + assert.equal([["some_value_post1"]], body) + end) + it("invokes a Lambda function with POST json body", function() + local res = assert(client:send { + method = "POST", + path = "/post", + headers = { + ["Host"] = "lambda.com", + ["Content-Type"] = "application/json" + }, + body = { + key1 = "some_value_json1", + key2 = "some_value_json2", + key3 = "some_value_json3" + } + }) + local body = assert.res_status(200, res) + assert.is_string(res.headers["x-amzn-RequestId"]) + assert.equal([["some_value_json1"]], body) + end) + it("invokes a Lambda function with POST and both querystring and body params", function() + local res = assert(client:send { + method = "POST", + path = "/post?key1=from_querystring", + headers = { + ["Host"] = "lambda.com", + ["Content-Type"] = "application/x-www-form-urlencoded" + }, + body = { + key2 = "some_value_post2", + key3 = "some_value_post3" + } + }) + local body = assert.res_status(200, res) + assert.is_string(res.headers["x-amzn-RequestId"]) + assert.equal([["from_querystring"]], body) + end) + it("invokes a Lambda function with POST params and Event invocation_type", function() + local res = assert(client:send { + method = "POST", + path = "/post", + headers = { + ["Host"] = "lambda2.com", + ["Content-Type"] = "application/x-www-form-urlencoded" + }, + body = { + key1 = "some_value_post1", + key2 = "some_value_post2", + key3 = "some_value_post3" + } + }) + assert.res_status(202, res) + assert.is_string(res.headers["x-amzn-RequestId"]) + end) + it("invokes a Lambda function with POST params and DryRun invocation_type", function() + local res = assert(client:send { + method = "POST", + path = "/post", + headers = { + ["Host"] = "lambda3.com", + ["Content-Type"] = "application/x-www-form-urlencoded" + }, + body = { + key1 = "some_value_post1", + key2 = "some_value_post2", + key3 = "some_value_post3" + } + }) + assert.res_status(204, res) + assert.is_string(res.headers["x-amzn-RequestId"]) + end) + +end)