feat(plugins): aws lambda serverless (#5594)

This commit is contained in:
Bisakh 2021-12-01 08:09:45 +05:30 committed by GitHub
parent b4921c3a19
commit e90e3b7aa1
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
6 changed files with 642 additions and 1 deletions

View File

@ -0,0 +1,183 @@
--
-- Licensed to the Apache Software Foundation (ASF) under one or more
-- contributor license agreements. See the NOTICE file distributed with
-- this work for additional information regarding copyright ownership.
-- The ASF licenses this file to You under the Apache License, Version 2.0
-- (the "License"); you may not use this file except in compliance with
-- the License. You may obtain a copy of the License at
--
-- http://www.apache.org/licenses/LICENSE-2.0
--
-- Unless required by applicable law or agreed to in writing, software
-- distributed under the License is distributed on an "AS IS" BASIS,
-- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-- See the License for the specific language governing permissions and
-- limitations under the License.
local ngx = ngx
local hmac = require("resty.hmac")
local hex_encode = require("resty.string").to_hex
local resty_sha256 = require("resty.sha256")
local str_strip = require("pl.stringx").strip
local norm_path = require("pl.path").normpath
local pairs = pairs
local tab_concat = table.concat
local tab_sort = table.sort
local os = os
local plugin_name = "aws-lambda"
local plugin_version = 0.1
local priority = -1899
local ALGO = "AWS4-HMAC-SHA256"
local function hmac256(key, msg)
return hmac:new(key, hmac.ALGOS.SHA256):final(msg)
end
local function sha256(msg)
local hash = resty_sha256:new()
hash:update(msg)
local digest = hash:final()
return hex_encode(digest)
end
local function get_signature_key(key, datestamp, region, service)
local kDate = hmac256("AWS4" .. key, datestamp)
local kRegion = hmac256(kDate, region)
local kService = hmac256(kRegion, service)
local kSigning = hmac256(kService, "aws4_request")
return kSigning
end
local aws_authz_schema = {
type = "object",
properties = {
-- API Key based authorization
apikey = {type = "string"},
-- IAM role based authorization, works via aws v4 request signing
-- more at https://docs.aws.amazon.com/general/latest/gr/sigv4_signing.html
iam = {
type = "object",
properties = {
accesskey = {
type = "string",
description = "access key id from from aws iam console"
},
secretkey = {
type = "string",
description = "secret access key from from aws iam console"
},
aws_region = {
type = "string",
default = "us-east-1",
description = "the aws region that is receiving the request"
},
service = {
type = "string",
default = "execute-api",
description = "the service that is receiving the request"
}
},
required = {"accesskey", "secretkey"}
}
}
}
local function request_processor(conf, ctx, params)
local headers = params.headers
-- set authorization headers if not already set by the client
-- we are following not to overwrite the authz keys
if not headers["x-api-key"] then
if conf.authorization and conf.authorization.apikey then
headers["x-api-key"] = conf.authorization.apikey
return
end
end
-- performing aws v4 request signing for IAM authorization
-- visit https://docs.aws.amazon.com/general/latest/gr/sigv4-signed-request-examples.html
-- to look at the pseudocode in python.
if headers["authorization"] or not conf.authorization or not conf.authorization.iam then
return
end
-- create a date for headers and the credential string
local t = ngx.time()
local amzdate = os.date("!%Y%m%dT%H%M%SZ", t)
local datestamp = os.date("!%Y%m%d", t) -- Date w/o time, used in credential scope
headers["X-Amz-Date"] = amzdate
-- computing canonical uri
local canonical_uri = norm_path(params.path)
if canonical_uri ~= "/" then
if canonical_uri:sub(-1, -1) == "/" then
canonical_uri = canonical_uri:sub(1, -2)
end
if canonical_uri:sub(1, 1) ~= "/" then
canonical_uri = "/" .. canonical_uri
end
end
-- computing canonical query string
local canonical_qs = {}
for k, v in pairs(params.query) do
canonical_qs[#canonical_qs+1] = ngx.unescape_uri(k) .. "=" .. ngx.unescape_uri(v)
end
tab_sort(canonical_qs)
canonical_qs = tab_concat(canonical_qs, "&")
-- computing canonical and signed headers
local canonical_headers, signed_headers = {}, {}
for k, v in pairs(headers) do
k = k:lower()
if k ~= "connection" then
signed_headers[#signed_headers+1] = k
-- strip starting and trailing spaces including strip multiple spaces into single space
canonical_headers[k] = str_strip(v)
end
end
tab_sort(signed_headers)
for i = 1, #signed_headers do
local k = signed_headers[i]
canonical_headers[i] = k .. ":" .. canonical_headers[k] .. "\n"
end
canonical_headers = tab_concat(canonical_headers, nil, 1, #signed_headers)
signed_headers = tab_concat(signed_headers, ";")
-- combining elements to form the canonical request (step-1)
local canonical_request = params.method:upper() .. "\n"
.. canonical_uri .. "\n"
.. (canonical_qs or "") .. "\n"
.. canonical_headers .. "\n"
.. signed_headers .. "\n"
.. sha256(params.body or "")
-- creating the string to sign for aws signature v4 (step-2)
local iam = conf.authorization.iam
local credential_scope = datestamp .. "/" .. iam.aws_region .. "/"
.. iam.service .. "/aws4_request"
local string_to_sign = ALGO .. "\n"
.. amzdate .. "\n"
.. credential_scope .. "\n"
.. sha256(canonical_request)
-- calculate the signature (step-3)
local signature_key = get_signature_key(iam.secretkey, datestamp, iam.aws_region, iam.service)
local signature = hex_encode(hmac256(signature_key, string_to_sign))
-- add info to the headers (step-4)
headers["authorization"] = ALGO .. " Credential=" .. iam.accesskey
.. "/" .. credential_scope
.. ", SignedHeaders=" .. signed_headers
.. ", Signature=" .. signature
end
local serverless_obj = require("apisix.plugins.serverless.generic-upstream")
return serverless_obj(plugin_name, plugin_version, priority, request_processor, aws_authz_schema)

View File

@ -355,6 +355,7 @@ plugins: # plugin list (sorted by priority)
# <- recommend to use priority (0, 100) for your custom plugins
- example-plugin # priority: 0
#- skywalking # priority: -1100
- aws-lambda # priority: -1899
- azure-functions # priority: -1900
- openwhisk # priority: -1901
- serverless-post-function # priority: -2000

View File

@ -130,7 +130,8 @@
"items": [
"plugins/serverless",
"plugins/azure-functions",
"plugins/openwhisk"
"plugins/openwhisk",
"plugins/aws-lambda"
]
},
{

View File

@ -0,0 +1,156 @@
---
title: aws-lambda
---
<!--
#
# Licensed to the Apache Software Foundation (ASF) under one or more
# contributor license agreements. See the NOTICE file distributed with
# this work for additional information regarding copyright ownership.
# The ASF licenses this file to You under the Apache License, Version 2.0
# (the "License"); you may not use this file except in compliance with
# the License. You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
-->
## Summary
- [Summary](#summary)
- [Name](#name)
- [Attributes](#attributes)
- [IAM Authorization Schema](#iam-authorization-schema)
- [How To Enable](#how-to-enable)
- [Disable Plugin](#disable-plugin)
## Name
`aws-lambda` is a serverless plugin built into Apache APISIX for seamless integration with [AWS Lambda](https://aws.amazon.com/lambda/), a widely used serverless solution, as a dynamic upstream to proxy all requests for a particular URI to the AWS cloud - one of the highly used public cloud platforms for production environment. If enabled, this plugin terminates the ongoing request to that particular URI and initiates a new request to the AWS lambda gateway uri (the new upstream) on behalf of the client with the suitable authorization details set by the users, request headers, request body, params (all these three components are passed from the original request) and returns the response body, status code and the headers back to the original client that has invoked the request to the APISIX agent.
At present, the plugin supports authorization via AWS api key and AWS IAM Secrets.
## Attributes
| Name | Type | Requirement | Default | Valid | Description |
| ----------- | ------ | ----------- | ------- | ----- | ------------------------------------------------------------ |
| function_uri | string | required | | | The AWS api gateway endpoint which triggers the lambda serverless function code. |
| authorization | object | optional | | | Authorization credentials to access the cloud function. |
| authorization.apikey | string | optional | | | Field inside _authorization_. The generate API Key to authorize requests to that endpoint of the AWS gateway. | |
| authorization.iam | object | optional | | | Field inside _authorization_. AWS IAM role based authorization, performed via AWS v4 request signing. See schema details below ([here](#iam-authorization-schema)). | |
| timeout | integer | optional | 3000 | [100,...] | Proxy request timeout in milliseconds. |
| ssl_verify | boolean | optional | true | true/false | If enabled performs SSL verification of the server. |
| keepalive | boolean | optional | true | true/false | To reuse the same proxy connection in near future. Set to false to disable keepalives and immediately close the connection. |
| keepalive_pool | integer | optional | 5 | [1,...] | The maximum number of connections in the pool. |
| keepalive_timeout | integer | optional | 60000 | [1000,...] | The maximal idle timeout (ms). |
### IAM Authorization Schema
| Name | Type | Requirement | Default | Valid | Description |
| ----------- | ------ | ----------- | ------- | ----- | ------------------------------------------------------------ |
| accesskey | string | required | | | Generated access key ID from AWS IAM console. |
| secret_key | string | required | | | Generated access key secret from AWS IAM console. |
| aws_region | string | optional | "us-east-1" | | The AWS region where the request is being sent. |
| service | string | optional | "execute-api" | | The service that is receiving the request (In case of Http Trigger it is "execute-api"). |
## How To Enable
The following is an example of how to enable the aws-lambda faas plugin for a specific route URI. Calling the APISIX route uri will make an invocation to the lambda function uri (the new upstream). We are assuming your cloud function is already up and running.
```shell
# enable aws lambda for a route via api key authorization
curl http://127.0.0.1:9080/apisix/admin/routes/1 -H 'X-API-KEY: edd1c9f034335f136f87ad84b625c8f1' -X PUT -d '
{
"plugins": {
"aws-lambda": {
"function_uri": "https://x9w6z07gb9.execute-api.us-east-1.amazonaws.com/default/test-apisix",
"authorization": {
"apikey": "<Generated API Key from aws console>",
},
"ssl_verify":false
}
},
"uri": "/aws"
}'
```
Now any requests (HTTP/1.1, HTTPS, HTTP2) to URI `/aws` will trigger an HTTP invocation to the aforesaid function URI and response body along with the response headers and response code will be proxied back to the client. For example (here AWS lambda function just take the `name` query param and returns `Hello $name`) :
```shell
$ curl -i -XGET localhost:9080/aws\?name=APISIX
HTTP/1.1 200 OK
Content-Type: application/json
Connection: keep-alive
Date: Sat, 27 Nov 2021 13:08:27 GMT
x-amz-apigw-id: JdwXuEVxIAMFtKw=
x-amzn-RequestId: 471289ab-d3b7-4819-9e1a-cb59cac611e0
Content-Length: 16
X-Amzn-Trace-Id: Root=1-61a22dca-600c552d1c05fec747fd6db0;Sampled=0
Server: APISIX/2.10.2
"Hello, APISIX!"
```
For requests where the mode of communication between the client and the Apache APISIX gateway is HTTP/2, the example looks like ( make sure you are running APISIX agent with `enable_http2: true` for a port in `config-default.yaml`. You can do it by uncommenting the port 9081 from `apisix.node_listen` field ) :
```shell
$ curl -i -XGET --http2 --http2-prior-knowledge localhost:9081/aws\?name=APISIX
HTTP/2 200
content-type: application/json
content-length: 16
x-amz-apigw-id: JdwulHHrIAMFoFg=
date: Sat, 27 Nov 2021 13:10:53 GMT
x-amzn-trace-id: Root=1-61a22e5d-342eb64077dc9877644860dd;Sampled=0
x-amzn-requestid: a2c2b799-ecc6-44ec-b586-38c0e3b11fe4
server: APISIX/2.10.2
"Hello, APISIX!"
```
Similarly, the lambda can be triggered via AWS API Gateway by using AWS `IAM` permissions to authorize access to your API via APISIX aws-lambda plugin. Plugin includes authentication signatures in their HTTP calls via AWS v4 request signing. Here is an example:
```shell
# enable aws lambda for a route via iam authorization
curl http://127.0.0.1:9080/apisix/admin/routes/1 -H 'X-API-KEY: edd1c9f034335f136f87ad84b625c8f1' -X PUT -d '
{
"plugins": {
"aws-lambda": {
"function_uri": "https://ajycz5e0v9.execute-api.us-east-1.amazonaws.com/default/test-apisix",
"authorization": {
"iam": {
"accesskey": "<access key>",
"secretkey": "<access key secret>"
}
},
"ssl_verify": false
}
},
"uri": "/aws"
}'
```
**Note**: This approach assumes you already have an iam user with the programmatic access enabled and required permissions (`AmazonAPIGatewayInvokeFullAccess`) to access the endpoint.
## Disable Plugin
Remove the corresponding JSON configuration in the plugin configuration to disable the `aws-lambda` plugin and add the suitable upstream configuration.
APISIX plugins are hot-reloaded, therefore no need to restart APISIX.
```shell
$ curl http://127.0.0.1:9080/apisix/admin/routes/1 -H 'X-API-KEY: edd1c9f034335f136f87ad84b625c8f1' -X PUT -d '
{
"uri": "/aws",
"plugins": {},
"upstream": {
"type": "roundrobin",
"nodes": {
"127.0.0.1:1980": 1
}
}
}'
```

1
t/admin/plugins.t vendored
View File

@ -109,6 +109,7 @@ kafka-logger
syslog
udp-logger
example-plugin
aws-lambda
azure-functions
openwhisk
serverless-post-function

299
t/plugin/aws-lambda.t vendored Normal file
View File

@ -0,0 +1,299 @@
#
# Licensed to the Apache Software Foundation (ASF) under one or more
# contributor license agreements. See the NOTICE file distributed with
# this work for additional information regarding copyright ownership.
# The ASF licenses this file to You under the Apache License, Version 2.0
# (the "License"); you may not use this file except in compliance with
# the License. You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
use t::APISIX 'no_plan';
repeat_each(1);
no_long_string();
no_root_location();
no_shuffle();
add_block_preprocessor(sub {
my ($block) = @_;
my $inside_lua_block = $block->inside_lua_block // "";
chomp($inside_lua_block);
my $http_config = $block->http_config // <<_EOC_;
server {
listen 8765;
location /httptrigger {
content_by_lua_block {
ngx.req.read_body()
local msg = "aws lambda invoked"
ngx.header['Content-Length'] = #msg + 1
ngx.header['Connection'] = "Keep-Alive"
ngx.say(msg)
}
}
location /generic {
content_by_lua_block {
$inside_lua_block
}
}
}
_EOC_
$block->set_value("http_config", $http_config);
if (!$block->request) {
$block->set_value("request", "GET /t");
}
if (!$block->no_error_log && !$block->error_log) {
$block->set_value("no_error_log", "[error]\n[alert]");
}
});
run_tests;
__DATA__
=== TEST 1: checking iam schema
--- config
location /t {
content_by_lua_block {
local plugin = require("apisix.plugins.aws-lambda")
local ok, err = plugin.check_schema({
function_uri = "https://api.amazonaws.com",
authorization = {
iam = {
accesskey = "key1",
secretkey = "key2"
}
}
})
if not ok then
ngx.say(err)
else
ngx.say("done")
end
}
}
--- response_body
done
=== TEST 2: missing fields in iam schema
--- config
location /t {
content_by_lua_block {
local plugin = require("apisix.plugins.aws-lambda")
local ok, err = plugin.check_schema({
function_uri = "https://api.amazonaws.com",
authorization = {
iam = {
secretkey = "key2"
}
}
})
if not ok then
ngx.say(err)
else
ngx.say("done")
end
}
}
--- response_body
property "authorization" validation failed: property "iam" validation failed: property "accesskey" is required
=== TEST 3: create route with aws plugin enabled
--- config
location /t {
content_by_lua_block {
local t = require("lib.test_admin").test
local code, body = t('/apisix/admin/routes/1',
ngx.HTTP_PUT,
[[{
"plugins": {
"aws-lambda": {
"function_uri": "http://localhost:8765/httptrigger",
"authorization": {
"apikey" : "testkey"
}
}
},
"uri": "/aws"
}]],
[[{
"node": {
"value": {
"plugins": {
"aws-lambda": {
"keepalive": true,
"timeout": 3000,
"ssl_verify": true,
"keepalive_timeout": 60000,
"keepalive_pool": 5,
"function_uri": "http://localhost:8765/httptrigger",
"authorization": {
"apikey": "testkey"
}
}
},
"uri": "/aws"
},
"key": "/apisix/routes/1"
},
"action": "set"
}]]
)
if code >= 300 then
ngx.status = code
ngx.say("fail")
return
end
ngx.say(body)
}
}
--- response_body
passed
=== TEST 4: test plugin endpoint
--- config
location /t {
content_by_lua_block {
local t = require("lib.test_admin").test
local core = require("apisix.core")
local code, _, body, headers = t("/aws", "GET")
if code >= 300 then
ngx.status = code
ngx.say(body)
return
end
-- headers proxied 2 times -- one by plugin, another by this test case
core.response.set_header(headers)
ngx.print(body)
}
}
--- response_body
aws lambda invoked
--- response_headers
Content-Length: 19
=== TEST 5: check authz header - apikey
--- config
location /t {
content_by_lua_block {
local t = require("lib.test_admin").test
-- passing an apikey
local code, body = t('/apisix/admin/routes/1',
ngx.HTTP_PUT,
[[{
"plugins": {
"aws-lambda": {
"function_uri": "http://localhost:8765/generic",
"authorization": {
"apikey": "test_key"
}
}
},
"uri": "/aws"
}]]
)
if code >= 300 then
ngx.status = code
ngx.say("fail")
return
end
ngx.say(body)
local code, _, body = t("/aws", "GET")
if code >= 300 then
ngx.status = code
ngx.say(body)
return
end
ngx.print(body)
}
}
--- inside_lua_block
local headers = ngx.req.get_headers() or {}
ngx.say("Authz-Header - " .. headers["x-api-key"] or "")
--- response_body
passed
Authz-Header - test_key
=== TEST 6: check authz header - IAM v4 signing
--- config
location /t {
content_by_lua_block {
local t = require("lib.test_admin").test
-- passing the iam access and secret keys
local code, body = t('/apisix/admin/routes/1',
ngx.HTTP_PUT,
[[{
"plugins": {
"aws-lambda": {
"function_uri": "http://localhost:8765/generic",
"authorization": {
"iam": {
"accesskey": "KEY1",
"secretkey": "KeySecret"
}
}
}
},
"uri": "/aws"
}]]
)
if code >= 300 then
ngx.status = code
ngx.say("fail")
return
end
ngx.say(body)
local code, _, body, headers = t("/aws", "GET")
if code >= 300 then
ngx.status = code
ngx.say(body)
return
end
ngx.print(body)
}
}
--- inside_lua_block
local headers = ngx.req.get_headers() or {}
ngx.say("Authz-Header - " .. headers["Authorization"] or "")
ngx.say("AMZ-Date - " .. headers["X-Amz-Date"] or "")
ngx.print("invoked")
--- response_body eval
qr/passed
Authz-Header - AWS4-HMAC-SHA256 [ -~]*
AMZ-Date - [\d]+T[\d]+Z
invoked/