diff --git a/lua-resty-aws-dev-1.rockspec.template b/lua-resty-aws-dev-1.rockspec.template index 9f05ac9..965518a 100644 --- a/lua-resty-aws-dev-1.rockspec.template +++ b/lua-resty-aws-dev-1.rockspec.template @@ -44,7 +44,8 @@ build = { ["resty.aws.request.signatures.v4"] = "src/resty/aws/request/signatures/v4.lua", ["resty.aws.request.signatures.presign"] = "src/resty/aws/request/signatures/presign.lua", ["resty.aws.request.signatures.none"] = "src/resty/aws/request/signatures/none.lua", - ["resty.aws.service.rds.signer"] = "src/resty/aws/service/rds/signer.lua", + ["resty.aws.service.rds.signer"] = "src/resty/aws/service/rds/signer.lua", + ["resty.aws.service.elasticache.signer"] = "src/resty/aws/service/elasticache/signer.lua", ["resty.aws.credentials.Credentials"] = "src/resty/aws/credentials/Credentials.lua", ["resty.aws.credentials.ChainableTemporaryCredentials"] = "src/resty/aws/credentials/ChainableTemporaryCredentials.lua", ["resty.aws.credentials.CredentialProviderChain"] = "src/resty/aws/credentials/CredentialProviderChain.lua", diff --git a/spec/04-services/06-elasticache_spec.lua b/spec/04-services/06-elasticache_spec.lua new file mode 100644 index 0000000..dda87cd --- /dev/null +++ b/spec/04-services/06-elasticache_spec.lua @@ -0,0 +1,71 @@ +setmetatable(_G, nil) + +-- -- hock request sending +-- package.loaded["resty.aws.request.execute"] = function(...) +-- return ... +-- end + +local AWS = require("resty.aws") +local AWS_global_config = require("resty.aws.config").global + +local config = AWS_global_config +local aws = AWS(config) + +aws.config.credentials = aws:Credentials { + accessKeyId = "test_id", + secretAccessKey = "test_key", +} + +aws.config.region = "test_region" + +local REGION = "ap-northeast-1" +local USER = "test" +local CACHE_NAME = "test-cache" + +describe("Elasticache utils", function() + local cache, signer + local origin_time + setup(function() + origin_time = ngx.time + ngx.time = function () --luacheck: ignore + return 1667543171 + end + end) + + teardown(function () + ngx.time = origin_time --luacheck: ignore + end) + + before_each(function() + cache = aws:ElastiCache() + signer = cache:Signer { + cachename = CACHE_NAME, + username = USER, + region = REGION, -- override aws config + } + end) + + after_each(function() + cache = nil + signer = nil + end) + + it("should generate expected IAM auth token with mock key", function() + local auth_token, err = signer:getAuthToken() + local expected_auth_token = "test-cache/?X-Amz-Signature=606f98b24623c1e25deb70c2e98220cee859c39232ee7585aec51c25cb220882&Action=connect&User=test&X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=test_id%2F20221104%2Fap-northeast-1%2Felasticache%2Faws4_request&X-Amz-Date=20221104T062611Z&X-Amz-Expires=900&X-Amz-SignedHeaders=host" + assert.is_nil(err) + assert.same(auth_token, expected_auth_token) + end) + + it("should generate expected IAM auth token with mock temporary credential", function() + signer.config.credentials = aws:Credentials { + accessKeyId = "test_id2", + secretAccessKey = "test_key2", + sessionToken = "test_token2", + } + local auth_token, err = signer:getAuthToken() + local expected_auth_token = "test-cache/?X-Amz-Signature=88de038c7918f1b733e23d65eb3bc0ee989214c26ed8124cb730ce6e7d7a3e5c&Action=connect&User=test&X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=test_id2%2F20221104%2Fap-northeast-1%2Felasticache%2Faws4_request&X-Amz-Date=20221104T062611Z&X-Amz-Expires=900&X-Amz-Security-Token=test_token2&X-Amz-SignedHeaders=host" + assert.is_nil(err) + assert.same(auth_token, expected_auth_token) + end) +end) diff --git a/src/resty/aws/init.lua b/src/resty/aws/init.lua index 7894c7d..9a1045c 100644 --- a/src/resty/aws/init.lua +++ b/src/resty/aws/init.lua @@ -498,12 +498,19 @@ function AWS:new(config) service_config[k] = service_config[k] or v end + local signer + if service_id == "RDS" then + signer = require("resty.aws.service.rds.signer") + elseif service_id == "ElastiCache" then + signer = require("resty.aws.service.elasticache.signer") + end + local service_instance = { aws = aws_instance, config = service_config, api = api, -- Add service specific methods: - Signer = (service_id == "RDS") and require("resty.aws.service.rds.signer") or nil + Signer = signer } AWS.configureEndpoint(service_instance) diff --git a/src/resty/aws/service/elasticache/signer.lua b/src/resty/aws/service/elasticache/signer.lua new file mode 100644 index 0000000..43a724c --- /dev/null +++ b/src/resty/aws/service/elasticache/signer.lua @@ -0,0 +1,132 @@ +--- Signer class for Elasticache tokens for Valkey and Redis OSS access. + +-- Elasticache services created will get a `Signer` method to create an instance. +-- The `Signer` will inherit its configuration from the `AWS` instance. + +local httpc = require("resty.luasocket.http") +local presign_awsv4_request = require("resty.aws.request.signatures.presign") + +local ELASTICACHE_IAM_AUTH_EXPIRE_TIME = 15 * 60 + + +--- The example shows how to use `getAuthToken` to create an authentication +-- token for connecting to a PostgreSQL database in RDS. +-- @name Signer:getAuthToken +-- @tparam table opts configuration to use, to override the options inherited from the underlying `AWS` instance; +-- @tparam string opts.region The AWS region +-- @tparam string opts.cachename the Elasticache instance name to connect to, eg. `"test-cache"` +-- @tparam string opts.username name of the IAM-enabled user configured in the Elasticache User management page. +-- @tparam boolean opts.is_serverless the deployment mode of the cache cluster. +-- @tparam Credentials opts.credentials aws credentials +-- @return token, err - Returns the token to use as the password for the Redis connection, or nil and error if an error occurs +-- @usage +-- local AWS = require("resty.aws") +-- local AWS_global_config = require("resty.aws.config").global +-- local aws = AWS { region = AWS_global_config.region } +-- local cache = aws:ElastiCache() +-- local redis = require "resty.redis" +-- +-- local hostname = "HOSTNAME, e.g. test-cache-ppl9c2.serverless.apne1.cache.amazonaws.com" +-- local cachename = "CACHE CLUSTER NAME, e.g. test-cache" +-- local port = 6379 +-- local name = "Username e.g. test-user" +-- +-- local signer = cache:Signer { -- create a signer instance +-- cachename = cachename, +-- username = name, +-- is_serverless = true, +-- region = nil, -- will be inherited from `aws` +-- credentials = nil, -- will be inherited from `aws` +-- } +-- +-- -- use the 'signer' to generate the token, whilst overriding some options +-- local auth_token, err = signer:getAuthToken() +-- +-- if err then +-- ngx.log(ngx.ERR, "Failed to build auth token: ", err) +-- return +-- end +-- print(auth_token) +-- +-- local red = redis:new() +-- --red:set_timeouts(1000, 1000, 1000) +-- +-- local ok, err = red:connect(hostname, port, { ssl = true }) +-- if not ok then +-- print("failed to connect: ", err) +-- return +-- end +-- +-- local res, err = red:auth(name, auth_token) +-- if not res then +-- print("failed to authenticate: ", err) +-- return +-- end +-- +-- print("OK") + + +local function getAuthToken(self, opts) --cachename, region, username, is_serverless) + opts = setmetatable(opts or {}, { __index = self.config }) -- lookup missing params in inherited config + + local region = assert(opts.region, "parameter 'region' not set") + local cachename = assert(opts.cachename, "parameter 'cachename' not set") + local username = assert(opts.username, "parameter 'username' not set") + + local endpoint = cachename + if endpoint:sub(1,7) ~= "http://" then + endpoint = "http://" .. endpoint + end + + local query_args = "Action=connect&User=" .. username + if opts.is_serverless then + query_args = query_args .. "&ResourceType=ServerlessCache" + end + + local canonical_request_url = endpoint .. "/?" .. query_args + local scheme, host, port, path, query = unpack(httpc:parse_uri(canonical_request_url, false)) + local req_data = { + method = "GET", + scheme = scheme, + tls = scheme == "https", + host = host, + port = port, + path = path, + query = query, + headers = { + ["Host"] = host, + }, + } + + local presigned_request, err = presign_awsv4_request(self.config, req_data, opts.signingName, region, ELASTICACHE_IAM_AUTH_EXPIRE_TIME) + if err then + return nil, err + end + + return presigned_request.host .. presigned_request.path .. "?" .. presigned_request.query +end + + +-- signature: intended to be a method on the Elasticache service object, cache_instance == self in that case +return function(cache_instance, config) + local token_instance = { + config = {}, + getAuthToken = getAuthToken, -- injected method for token generation + } + + -- first copy the inherited config elements NOTE: inherits from AWS, not the cache_instance!!! + for k,v in pairs(cache_instance.aws.config) do + token_instance.config[k] = v + end + + -- service specifics + token_instance.config.signatureVersion = "v4" + token_instance.config.signingName = "elasticache" + + -- then add/overwrite with provided config + for k,v in pairs(config or {}) do + token_instance.config[k] = v + end + + return token_instance +end