diff --git a/.github/MAINTAINERS.md b/.github/MAINTAINERS.md index 47d9c8a48..1332c2bf3 100644 --- a/.github/MAINTAINERS.md +++ b/.github/MAINTAINERS.md @@ -97,6 +97,15 @@ This will output a new key, associated with your email address to /tmp/new-ssh-k 5. Update `./bin/split.sh` and `./.github/workflows/split-monorepo.yml` to include the new mirror and SSH key. 6. Update the main README.md to include information about the new package. +### Kong Plugin + +This does not require a release. Users will simply copy the `kong` directory within `packages/kong-plugin` into their Kong image [see this guide](https://docs.konghq.com/gateway/latest/plugin-development/get-started/deploy/). + +In the future: + +- We will build a Docker image for the plugin and release it to the Kong Community plugins site. +- We will offer a zip file with the plugin code. + ## 🧑‍🔬 Integration Testing We have an integration testing layer for both Metrics and webhooks to ensure that the different SDKs, and how they may be used within a variety of HTTP frameworks, are compliant to the Metrics API and all behave the same given certain parameters. diff --git a/packages/kong-plugin/.busted b/packages/kong-plugin/.busted new file mode 100644 index 000000000..ca66496a4 --- /dev/null +++ b/packages/kong-plugin/.busted @@ -0,0 +1,7 @@ +return { + default = { + verbose = true, + coverage = false, + output = "gtest", + }, +} diff --git a/packages/kong-plugin/.editorconfig b/packages/kong-plugin/.editorconfig new file mode 100644 index 000000000..3434e8a8b --- /dev/null +++ b/packages/kong-plugin/.editorconfig @@ -0,0 +1,22 @@ +root = true + +[*] +end_of_line = lf +insert_final_newline = true +trim_trailing_whitespace = true +charset = utf-8 + +[*.lua] +indent_style = space +indent_size = 2 + +[kong/templates/nginx*] +indent_style = space +indent_size = 4 + +[*.template] +indent_style = space +indent_size = 4 + +[Makefile] +indent_style = tab diff --git a/packages/kong-plugin/.gitignore b/packages/kong-plugin/.gitignore new file mode 100644 index 000000000..7e3297f2e --- /dev/null +++ b/packages/kong-plugin/.gitignore @@ -0,0 +1,12 @@ +# servroot typically is the Kong working directory for tests +servroot +# exclude generated packed rocks +*.rock +# exclude Pongo shell history +.pongo/.bash_history +# exclude LuaCov statistics file +luacov.stats.out +# exclude LuaCov report +luacov.report.out +# exclude Pongo containerid file +.containerid diff --git a/packages/kong-plugin/.luacheckrc b/packages/kong-plugin/.luacheckrc new file mode 100644 index 000000000..253fce4c4 --- /dev/null +++ b/packages/kong-plugin/.luacheckrc @@ -0,0 +1,35 @@ +std = "ngx_lua" +unused_args = false +redefined = false +max_line_length = false + + +include_files = { + "**/*.lua", + "*.rockspec", + ".busted", + ".luacheckrc", +} + + +globals = { + "_KONG", + "kong", + "ngx.IS_CLI", +} + + +not_globals = { + "string.len", + "table.getn", +} + + +ignore = { + "6.", -- ignore whitespace warnings +} + + +files["spec/**/*.lua"] = { + std = "ngx_lua+busted", +} diff --git a/packages/kong-plugin/.luacov b/packages/kong-plugin/.luacov new file mode 100644 index 000000000..07c19d8be --- /dev/null +++ b/packages/kong-plugin/.luacov @@ -0,0 +1,7 @@ +include = { + "%/kong%-plugin%/kong%/.+$", +} + +statsfile = "/kong-plugin/luacov.stats.out" +reportfile = "/kong-plugin/luacov.report.out" +runreport = true diff --git a/packages/kong-plugin/.pongo/pongorc b/packages/kong-plugin/.pongo/pongorc new file mode 100644 index 000000000..aaad66f3f --- /dev/null +++ b/packages/kong-plugin/.pongo/pongorc @@ -0,0 +1 @@ +--postgres diff --git a/packages/kong-plugin/Dockerfile b/packages/kong-plugin/Dockerfile new file mode 100644 index 000000000..be75fb992 --- /dev/null +++ b/packages/kong-plugin/Dockerfile @@ -0,0 +1,17 @@ +FROM kong/kong-gateway:3.7 +# Ensure any patching steps are executed as root user +USER root + +# Add custom plugin to the image +COPY ./kong/plugins/readme-plugin /usr/local/share/lua/5.1/kong/plugins/readme-plugin +ENV KONG_PLUGINS=bundled,readme-plugin + +# Ensure kong user is selected for image execution +USER kong + +# Run kong +ENTRYPOINT ["/entrypoint.sh"] +EXPOSE 8000 8443 8001 8444 +STOPSIGNAL SIGQUIT +HEALTHCHECK --interval=10s --timeout=10s --retries=10 CMD kong health +CMD ["kong", "docker-start"] diff --git a/packages/kong-plugin/README.md b/packages/kong-plugin/README.md new file mode 100644 index 000000000..c35196c1f --- /dev/null +++ b/packages/kong-plugin/README.md @@ -0,0 +1,90 @@ +# ReadMe Metrics + Kong + +

+ +

+ +

+ Track usage of your API and troubleshoot issues faster. +

+ +With [ReadMe's Metrics API](https://readme.com/metrics) your team can get deep insights into your API's usage. If you're a developer, it takes a few small steps to send your API logs to [ReadMe](http://readme.com). Here's an overview of how the integration works: + +- Install this plugin into your Kong Gateway by copying the `kong` directory into your Kong environment or use the provided Dockerfile. see [this](https://docs.konghq.com/gateway/latest/plugin-development/get-started/deploy/) for details. +- The plugin runs during the log phase of the Kong pipeline and sends ReadMe the details of your API's incoming requests and outgoing responses, with the option for you to redact any private headers using the configuration options. +- ReadMe uses these request and response details to create an API Metrics Dashboard which can be used to analyze specific API calls or monitor aggregate usage data. + +### 📦 Deploying locally + +```bash +# Build kong image with the plugin +docker build -t kong-readme-plugin:latest . +# Run kong with the plugin +curl -Ls https://get.konghq.com/quickstart | bash -s -- -r "" -i kong-readme-plugin -t latest +# Enable the plugin +curl -isX POST http://localhost:8001/plugins -d name=readme-plugin -d 'config.api_key=' +``` + +### 🧑‍🔬 Testing + +Requires [pongo](https://github.com/Kong/kong-pongo) to test and develop. + +```bash +pongo up +pongo shell +kms + +# Check if the plugin is available +curl -s localhost:8001 | jq '.plugins.available_on_server."readme-plugin"' +``` + +#### Enable for all services + +```bash +curl -sX POST http://localhost:8001/plugins -d name=readme-plugin -d 'config.api_key=' | jq +``` + +#### Enable for a specific service + +```bash +# Add a new service +curl -isX POST http://localhost:8001/services -d name=example_service -d url='http://httpbin.org' + +# Associate the custom plugin with the `example_service` service +curl -isX POST http://localhost:8001/services/example_service/plugins -d 'name=readme-plugin' -d "config.queue.max_retry_time=1" + +# Add a new route for sending requests through the `example_service` service +curl -iX POST http://localhost:8001/services/example_service/routes -d 'paths[]=/mock' -d name=example_route + +# Test +curl -i http://localhost:8000/mock/anything +``` + +### 🧙 Development tricks + +```bash +# Get plugin config +curl -s http://localhost:8001/plugins | jq '.data | map(select(.name == "readme-plugin")) | first' + +# Retrieve the plugin ID +export PLUGIN_ID=$(curl -s http://localhost:8001/plugins | jq '.data | map(select(.name == "readme-plugin")) | first | .id' | tr -d '"') + +# Configure the plugin with your API key +curl -sX PATCH http://localhost:8001/plugins/$PLUGIN_ID -d "config.api_key=" | jq '.config.api_key' + +# Configure `hide_headers` +curl -sX PATCH -H'Content-Type: application/json' http://localhost:8001/plugins/$PLUGIN_ID -d '{"config": {"hide_headers": {"foo": "", "bar": "default"}}}' | jq '.config.hide_headers' + +# Configure `id_header` +curl -sX PATCH -H'Content-Type: application/json' http://localhost:8001/plugins/$PLUGIN_ID -d '{"config": {"id_header": "email"}}' | jq '.config.id_header' + +# Configure `group_by` +curl -sX PATCH -H'Content-Type: application/json' http://localhost:8001/plugins/$PLUGIN_ID -d '{"config": {"group_by": {"x-user-email": "email", "x-org-name": "label"}}}' | jq '.config.group_by' + +# Delete the plugin +curl -sX DELETE http://localhost:8001/plugins/$PLUGIN_ID +``` + +> 🚧 Any Issues? +> +> Integrations can be tricky! [Contact support](https://docs.readme.com/guides/docs/contact-support) if you have any questions/issues. diff --git a/packages/kong-plugin/kong/plugins/readme-plugin/handler.lua b/packages/kong-plugin/kong/plugins/readme-plugin/handler.lua new file mode 100644 index 000000000..bfa3d2b8d --- /dev/null +++ b/packages/kong-plugin/kong/plugins/readme-plugin/handler.lua @@ -0,0 +1,257 @@ +local Queue = require "kong.tools.queue" +local cjson = require "cjson" +local url = require "socket.url" +local http = require "resty.http" +local openssl_digest = require "resty.openssl.digest" + +local kong = kong +local ngx = ngx +local encode_base64 = ngx.encode_base64 +local tostring = tostring +local tonumber = tonumber +local fmt = string.format +local pairs = pairs +local max = math.max + +-- this is used so we don't have to parse the url every time we log. +local parsed_urls_cache = {} + +-- Parse host url. +-- @param `url` host url +-- @return `parsed_url` a table with host details: +-- scheme, host, port, path, query, userinfo +local function parse_url(host_url) + local parsed_url = parsed_urls_cache[host_url] + + if parsed_url then + return parsed_url + end + + parsed_url = url.parse(host_url) + if not parsed_url.port then + if parsed_url.scheme == "http" then + parsed_url.port = 80 + + elseif parsed_url.scheme == "https" then + parsed_url.port = 443 + end + end + if not parsed_url.path then + parsed_url.path = "/" + end + + parsed_urls_cache[host_url] = parsed_url + + return parsed_url +end + +-- This will generate an integrity hash that looks something like this: +-- sha512-Naxska/M1INY/thefLQ49sExJ8E+89Q2bz/nC4Pet52iSRPtI9w3Cyg0QdZExt0uUbbnfMJZ0qTabiLJxw6Wrg==?1345 +-- With the last 4 digits on the end for us to use to identify it later in a list. +local function hash_value(value) + local last_4_digits = value:sub(-4) + local digest = openssl_digest.new("sha512") + local encoded_value = encode_base64(digest:final(value)) + return encoded_value .. "?" .. last_4_digits +end + + +-- Creates a payload for ReadMe +-- @return JSON string in ReadMe API Log format +local function make_readme_payload(conf, entries) + local payload = {} + for _, entry in ipairs(entries) do + local request_entry = { + pageref = entry.request.url, + startedDateTime = os.date("!%Y-%m-%dT%H:%M:%SZ", entry.started_at / 1000), + time = entry.latencies.request, + request = { + httpVersion = entry.request.httpVersion, + headers = {}, + method = entry.request.method, + queryString = {}, + bodySize = entry.request.size, + url = entry.request.url, + }, + response = { + status = entry.response.status, + headers = {}, + content = { + mimeType = entry.response.mimeType, + size = entry.response.size, + }, + bodySize = entry.response.size + } + } + + + -- Convert headers + for name, value in pairs(entry.request.headers) do + local final_value = value + if conf.hide_headers[name] then + if conf.hide_headers[name] == "" then + kong.log.debug("Hiding request header: ", name) + break + else + final_value = conf.hide_headers[name] + kong.log.debug("Overriding request header: ", name, " with ", final_value) + end + end + table.insert(request_entry.request.headers, {name = name, value = final_value}) + end + + for name, value in pairs(entry.response.headers) do + local final_value = value + if conf.hide_headers[name] then + if conf.hide_headers[name] == "" then + kong.log.debug("Hiding response header: ", name) + break + else + final_value = conf.hide_headers[name] + kong.log.debug("Overriding response header: ", name, " with ", final_value) + end + end + table.insert(request_entry.response.headers, {name = name, value = final_value}) + end + + for name, value in pairs(entry.request.querystring) do + table.insert(request_entry.request.queryString, {name = name, value = value}) + end + + local group = {} + if conf.group_by then + for header_name, group_value in pairs(conf.group_by) do + local header_value = entry.request.headers[header_name] + group[group_value] = header_value or "none" + end + end + + local id_header = (conf.id_header and conf.id_header:lower()) or "authorization" + local id = entry.request.headers[id_header] or "none" + if id_header == "authorization" and entry.raw_auth then + id = entry.raw_auth + end + group.id = hash_value(id) + + local api_log = { + httpVersion = entry.httpVersion, + requestBody = {}, + responseBody = {}, + + clientIPAddress = entry.client_ip, + createdAt = request_entry.startedDateTime, + development = false, + error = nil, + group = group, + _id = entry.request.id, + method = entry.request.method, + normalizedPath = entry.normalized_path, + queryString = request_entry.request.queryString, + request = { + log = { + creator = { + comment = "Kong " .. kong.version, + name = "readme-metrics (kong)", + version = "1.0.0" + }, + entries = {request_entry} + }, + }, + requestHeaders = request_entry.request.headers, + responseHeaders = request_entry.response.headers, + responseTime = entry.latencies.request, + startedDateTime = request_entry.startedDateTime, + status = entry.response.status, + url = entry.request.url + } + + table.insert(payload, api_log) + end + + return cjson.encode(payload) +end + +-- Sends the provided entries to ReadMe +-- @return true if everything was sent correctly, falsy if error +-- @return error message if there was an error +local function send_entries(conf, entries) + local payload = make_readme_payload(conf, entries) + local content_length = #payload + local method = "POST" + local timeout = conf.timeout + local keepalive = conf.keepalive + local proxy_endpoint = conf.proxy_endpoint + local http_endpoint = proxy_endpoint or "https://metrics.readme.io/v1/request" + local api_key = conf.api_key + + local parsed_url = parse_url(http_endpoint) + local host = parsed_url.host + local port = tonumber(parsed_url.port) + + local httpc = http.new() + httpc:set_timeout(timeout) + + local headers = { + ["Content-Type"] = "application/json", + ["Content-Length"] = content_length, + ["Authorization"] = "Basic " ..encode_base64(api_key .. ":") or nil + } + + local log_server_url = fmt("%s://%s:%d%s", parsed_url.scheme, host, port, parsed_url.path) + local res, err = httpc:request_uri(log_server_url, { + method = method, + headers = headers, + body = payload, + keepalive_timeout = keepalive, + ssl_verify = false, + }) + if not res then + return nil, "failed request to " .. host .. ":" .. tostring(port) .. ": " .. err + end + + -- always read response body, even if we discard it without using it on success + local response_body = res.body + + kong.log.debug(fmt("sent data to ReadMe, %s:%s HTTP status %d %s", host, port, res.status, response_body)) + + if res.status < 300 then + return true + + else + return nil, "request to " .. host .. ":" .. tostring(port) + .. " returned status code " .. tostring(res.status) .. " and body " + .. response_body + end +end + + +local HttpLogHandler = { + PRIORITY = 12, + VERSION = "0.0.1", +} + +function HttpLogHandler:log(conf) + local queue_conf = Queue.get_plugin_params("readme-plugin", conf, 'readme-plugin') + -- this creates a object with some request and response details. see https://docs.konghq.com/gateway/latest/plugin-development/pdk/kong.log/#konglogserialize + local info = kong.log.serialize() + -- add more details to the info object + local scheme = kong.request.get_scheme() + local version = kong.request.get_http_version() + info.httpVersion = scheme .. "/" .. version + info.mimeType = kong.response.get_header("Content-Type") + info.normalized_path = kong.request.get_path() + info.raw_auth = kong.request.get_header("Authorization") + + -- This sends configuration and current request data to a queue for processing. will call `send_entries` function. + local ok, err = Queue.enqueue( + queue_conf, + send_entries, + conf, + info + ) + if not ok then + kong.log.err("Failed to enqueue log entry to log server: ", err) + end +end + +return HttpLogHandler diff --git a/packages/kong-plugin/kong/plugins/readme-plugin/schema.lua b/packages/kong-plugin/kong/plugins/readme-plugin/schema.lua new file mode 100644 index 000000000..1181e5331 --- /dev/null +++ b/packages/kong-plugin/kong/plugins/readme-plugin/schema.lua @@ -0,0 +1,82 @@ +local typedefs = require "kong.db.schema.typedefs" +local url = require "socket.url" + +return { + name = "readme-plugin", + fields = { + {protocols = typedefs.protocols}, + { + config = { + type = "record", + fields = { + { + api_key = { + required = true, + description = "ReadMe API key.", + type = "string" + } + }, + { + id_header = { + description = "Select header to be used as a unique identifier for a user. This value will be hashed by ReadMe. The `Authorization` header is used by default. If the configured header was not found then it will be set to `none`.", + type = "string", + default = "Authorization" + } + }, + { + proxy_endpoint = typedefs.url( + { + description = "An optional endpoint to send the request to. If you are proxying, ensure the final endpoint is `https://metrics.readme.io/v1/request`." + } + ) + }, + { + timeout = { + description = "An optional timeout in milliseconds when sending data to the upstream server.", + type = "number", + default = 10000 + } + }, + { + keepalive = { + description = "An optional value in milliseconds that defines how long an idle connection will live before being closed.", + type = "number", + default = 60000 + } + }, + { + hide_headers = { + description = "An optional table of headers to exclude from logging. The header will be set to supplied value. If the supplised value is an empty string, the header will be excluded from logging. This applies to both requests and responses.", + type = "map", + keys = { + type = "string" + }, + values = { + type = "string", + len_min = 0, + }, + default = {} + } + }, + { + group_by = { + description = "A map of headers to group by. The key is the header name and the value is the header value to group by. Applies to request headers only. If the header is not found, it will be set to `none`. `email` and `label` are recommended keys to provide.", + type = "map", + keys = typedefs.header_name, + values = { + type = "string", + match_none = { + { + pattern = "^id$", + err = "cannot map to `id`", + } + } + } + } + }, + {queue = typedefs.queue}, + } + } + } + } +} diff --git a/packages/kong-plugin/spec/readme-plugin/01-integration_spec.lua b/packages/kong-plugin/spec/readme-plugin/01-integration_spec.lua new file mode 100644 index 000000000..0d3310c57 --- /dev/null +++ b/packages/kong-plugin/spec/readme-plugin/01-integration_spec.lua @@ -0,0 +1,98 @@ +-- Helper functions provided by Kong Gateway, see https://github.com/Kong/kong/blob/master/spec/helpers.lua +local helpers = require "spec.helpers" +local http_mock = require "spec.helpers.http_mock" +local tapping = require "spec.helpers.http_mock.tapping" + +-- matches our plugin name defined in the plugins's schema.lua +local PLUGIN_NAME = "readme-plugin" + +-- Run the tests for each strategy. Strategies include "postgres" and "off" +-- which represent the deployment topologies for Kong Gateway +for _, strategy in helpers.all_strategies() do + describe(PLUGIN_NAME .. ": [#" .. strategy .. "]", function() + -- Will be initialized before_each nested test + local client + local mock, mock_port + + setup(function() + mock, mock_port = http_mock.new(nil, { + ["/"] = { + access = [[ + ngx.req.set_header("X-Test", "test") + ngx.print("hello world") + ngx.exit(200) + ]], + }, + }, { + log_opts = { + req = true, + req_body = true, + req_body_large = true, + collect_req = true, + collect_resp = true, + resp = true, + resp_body = true, + } + }) + mock:start() + + -- A BluePrint gives us a helpful database wrapper to + -- manage Kong Gateway entities directly. + -- This function also truncates any existing data in an existing db. + -- The custom plugin name is provided to this function so it mark as loaded + local blue_print = helpers.get_db_utils(strategy, nil, { PLUGIN_NAME }) + + -- Add the custom plugin + blue_print.plugins:insert { + name = PLUGIN_NAME, + config = { + api_key = "test-api-key", + proxy_endpoint = "http://0.0.0.0:" .. mock_port .. "/", + } + } + + -- Using the BluePrint to create a test route, automatically attaches it + -- to the default "echo" service that will be created by the test framework + local test_route = blue_print.routes:insert({ + paths = { "/mock" }, + }) + + + -- start kong + assert(helpers.start_kong({ + -- use the custom test template to create a local mock server + nginx_conf = "spec/fixtures/custom_nginx.template", + -- make sure our plugin gets loaded + plugins = "bundled," .. PLUGIN_NAME, + })) + + end) + + -- teardown runs after its parent describe block + teardown(function() + helpers.stop_kong(nil, true) + mock:stop(true) + end) + + -- before_each runs before each child describe + before_each(function() + client = helpers.proxy_client() + end) + + -- after_each runs after each child describe + after_each(function() + if client then client:close() end + end) + + -- a nested describe defines an actual test on the plugin behavior + describe("The response", function() + + it("does not error", function() + -- invoke a test request + local r = client:get("/mock/anything", {}) + assert.response(r).has.status(200) + mock:wait_until_no_request() + end) + end) + end) +end