From 7d71b6b83601b541f6335cd06c09cc8e3d169138 Mon Sep 17 00:00:00 2001 From: Jun Ouyang Date: Mon, 4 Nov 2024 14:47:57 +0800 Subject: [PATCH] feat(ai-proxy): disable HTTP/2 ALPN handshake for connections on routes configured with AI-proxy. (#13735) This change will disable HTTP/2 ALPN handshake for connections on routes configured with AI-proxy. The following are the specific changes - move tls related function kong/tls/plugins/certificate.lua and kong/tls/plugins/sni_filter.lua from ee to ce repo - Based on feat(patch): add tls.disable_http2_alpn() function needed patch for disabling HTTP/2 ALPN when tls handshake. #13709 and feat: introduce tls.disable_http2_alpn() function lua-kong-nginx-module#93, we introduce the disable_http2_alpn action in the ai-proxy plugin to solve the ai-proxy plugin did not work in HTTP2 case. After the current PR is merged, HTTP/2 ALPN handshakes will be disabled for requests on routes configured with AI-proxy, and all these connections will fall back to the http1.1 protocol. AG-119 --- .../kong/feat-ai-proxy-disable-h2-alpn.yml | 4 + kong-3.9.0-0.rockspec | 3 + kong/init.lua | 4 + kong/llm/proxy/handler.lua | 73 +++++ kong/pdk/client/tls.lua | 18 ++ kong/pdk/private/phases.lua | 1 + kong/plugins/ai-proxy/handler.lua | 4 +- kong/templates/nginx_kong.lua | 3 + kong/templates/nginx_kong_stream.lua | 3 + kong/tls/plugins/certificate.lua | 128 +++++++++ kong/tls/plugins/sni_filter.lua | 263 ++++++++++++++++++ .../02-openai_integration_spec.lua | 112 +++++--- spec/fixtures/1.2_custom_nginx.template | 3 + t/01-pdk/14-client-tls/00-phase_checks.t | 22 ++ 14 files changed, 608 insertions(+), 33 deletions(-) create mode 100644 changelog/unreleased/kong/feat-ai-proxy-disable-h2-alpn.yml create mode 100644 kong/tls/plugins/certificate.lua create mode 100644 kong/tls/plugins/sni_filter.lua diff --git a/changelog/unreleased/kong/feat-ai-proxy-disable-h2-alpn.yml b/changelog/unreleased/kong/feat-ai-proxy-disable-h2-alpn.yml new file mode 100644 index 000000000000..b884097c6b57 --- /dev/null +++ b/changelog/unreleased/kong/feat-ai-proxy-disable-h2-alpn.yml @@ -0,0 +1,4 @@ +message: | + **ai-proxy**: Disabled HTTP/2 ALPN handshake for connections on routes configured with AI-proxy. +type: feature +scope: Plugin diff --git a/kong-3.9.0-0.rockspec b/kong-3.9.0-0.rockspec index 59645720c5d6..15d81ca2e1c4 100644 --- a/kong-3.9.0-0.rockspec +++ b/kong-3.9.0-0.rockspec @@ -183,6 +183,9 @@ build = { ["kong.status"] = "kong/status/init.lua", ["kong.status.ready"] = "kong/status/ready.lua", + ["kong.tls.plugins.certificate"] = "kong/tls/plugins/certificate.lua", + ["kong.tls.plugins.sni_filter"] = "kong/tls/plugins/sni_filter.lua", + ["kong.tools.dns"] = "kong/tools/dns.lua", ["kong.tools.grpc"] = "kong/tools/grpc.lua", ["kong.tools.utils"] = "kong/tools/utils.lua", diff --git a/kong/init.lua b/kong/init.lua index 81b5d2c3817c..39a39d1737b3 100644 --- a/kong/init.lua +++ b/kong/init.lua @@ -1056,6 +1056,10 @@ function Kong.ssl_certificate() kong.table.clear(ngx.ctx) end +function Kong.ssl_client_hello() + local ctx = get_ctx_table(fetch_table(CTX_NS, CTX_NARR, CTX_NREC)) + ctx.KONG_PHASE = PHASES.client_hello +end function Kong.preread() local ctx = get_ctx_table(fetch_table(CTX_NS, CTX_NARR, CTX_NREC)) diff --git a/kong/llm/proxy/handler.lua b/kong/llm/proxy/handler.lua index 1ae9e1885ec6..17d0593756e2 100644 --- a/kong/llm/proxy/handler.lua +++ b/kong/llm/proxy/handler.lua @@ -13,7 +13,14 @@ local kong_utils = require("kong.tools.gzip") local buffer = require "string.buffer" local strip = require("kong.tools.string").strip local cycle_aware_deep_copy = require("kong.tools.table").cycle_aware_deep_copy +local kong_global = require("kong.global") +local PHASES = kong_global.phases +local certificate = require("kong.tls.plugins.certificate") +local sni_filter = require("kong.tls.plugins.sni_filter") + +local TTL_FOREVER = { ttl = 0 } +-- local SNI_CACHE_KEY = "ai:llm:cert_enabled_snis" local EMPTY = require("kong.tools.table").EMPTY @@ -477,4 +484,70 @@ function _M:access(conf) end + + +function _M:build_http2_alpn_filter(plugin_name) + -- do not execute if the kong configuration doesn't have any http2 listeners + local http2_enabled = false + for _, listener in ipairs(kong.configuration.proxy_listeners) do + if listener.http2 then + http2_enabled = true + break + end + end + + if not http2_enabled then + ngx.log(ngx.INFO, "no http2 listeners found, skipping LLM plugin initialization") + return + end + + local sni_cache_key = "ai:llm:cert_enabled_snis:" .. plugin_name + local orig_ssl_client_hello = Kong.ssl_client_hello -- luacheck: ignore + Kong.ssl_client_hello = function() -- luacheck: ignore + orig_ssl_client_hello() + + local ctx = ngx.ctx + -- ensure phases are set + ctx.KONG_PHASE = PHASES.client_hello + + kong_global.set_namespaced_log(kong, plugin_name) + local snis_set, err = kong.cache:get(sni_cache_key, TTL_FOREVER, + sni_filter.build_ssl_route_filter_set, plugin_name) + + if err then + kong.log.err("unable to request client to present its certificate: ", + err) + return ngx.exit(ngx.ERROR) + end + certificate.execute_client_hello(snis_set, { disable_http2 = true }) + kong_global.reset_log(kong) + + end + + local worker_events = kong.worker_events + if not worker_events or not worker_events.register then + return + end + + local function invalidate_sni_cache() + kong.cache:invalidate(sni_cache_key) + end + + worker_events.register(function(data) + invalidate_sni_cache() + end, "crud", "plugins") + + worker_events.register(function(data) + invalidate_sni_cache() + end, "crud", "routes") + + worker_events.register(function(data) + invalidate_sni_cache() + end, "crud", "services") + + worker_events.register(function(data) + invalidate_sni_cache() + end, "crud", "ca_certificates") +end + return _M diff --git a/kong/pdk/client/tls.lua b/kong/pdk/client/tls.lua index fc8db50106fd..a678976e1bf2 100644 --- a/kong/pdk/client/tls.lua +++ b/kong/pdk/client/tls.lua @@ -166,6 +166,24 @@ local function new() ngx.ctx.CLIENT_VERIFY_OVERRIDE = v end + --- + -- Prevents the TLS handshake from negotiating HTTP/2 ALPN. + -- if successful, the TLS handshake will not negotiate HTTP/2 ALPN to turn to HTTP1.1. + -- + -- @function kong.client.tls.disable_http2_alpn + -- @phases client_hello + -- @treturn true|nil Returns `true` if successful, `nil` if it fails. + -- @treturn nil|err Returns `nil` if successful, or an error message if it fails. + -- + -- @usage + -- local res, err = kong.client.tls.disable_http2_alpn() + -- if not res then + -- -- do something with err + -- end + function _TLS.disable_http2_alpn() + check_phase(PHASES.client_hello) + return kong_tls.disable_http2_alpn() + end return _TLS end diff --git a/kong/pdk/private/phases.lua b/kong/pdk/private/phases.lua index d3a2bca57179..12fcf5de10b3 100644 --- a/kong/pdk/private/phases.lua +++ b/kong/pdk/private/phases.lua @@ -10,6 +10,7 @@ local PHASES = { --init = 0x00000001, init_worker = 0x00000001, certificate = 0x00000002, + client_hello = 0x00000008, --set = 0x00000004, rewrite = 0x00000010, access = 0x00000020, diff --git a/kong/plugins/ai-proxy/handler.lua b/kong/plugins/ai-proxy/handler.lua index 558f4f241989..53b4ae6f8f8a 100644 --- a/kong/plugins/ai-proxy/handler.lua +++ b/kong/plugins/ai-proxy/handler.lua @@ -3,7 +3,9 @@ local deep_copy = require "kong.tools.table".deep_copy local _M = deep_copy(require("kong.llm.proxy.handler")) - +_M.init_worker = function() + _M:build_http2_alpn_filter("ai-proxy") +end _M.PRIORITY = 770 _M.VERSION = kong_meta.version diff --git a/kong/templates/nginx_kong.lua b/kong/templates/nginx_kong.lua index 184ba9370c16..470fa61e4599 100644 --- a/kong/templates/nginx_kong.lua +++ b/kong/templates/nginx_kong.lua @@ -126,6 +126,9 @@ server { ssl_certificate_by_lua_block { Kong.ssl_certificate() } + ssl_client_hello_by_lua_block { + Kong.ssl_client_hello() + } > end # injected nginx_proxy_* directives diff --git a/kong/templates/nginx_kong_stream.lua b/kong/templates/nginx_kong_stream.lua index bfd276f25b45..c07407ad1204 100644 --- a/kong/templates/nginx_kong_stream.lua +++ b/kong/templates/nginx_kong_stream.lua @@ -119,6 +119,9 @@ server { ssl_certificate_by_lua_block { Kong.ssl_certificate() } + ssl_client_hello_by_lua_block { + Kong.ssl_client_hello() + } > end set $upstream_host ''; diff --git a/kong/tls/plugins/certificate.lua b/kong/tls/plugins/certificate.lua new file mode 100644 index 000000000000..bf93298136f8 --- /dev/null +++ b/kong/tls/plugins/certificate.lua @@ -0,0 +1,128 @@ +-- This software is copyright Kong Inc. and its licensors. +-- Use of the software is subject to the agreement between your organization +-- and Kong Inc. If there is no such agreement, use is governed by and +-- subject to the terms of the Kong Master Software License Agreement found +-- at https://konghq.com/enterprisesoftwarelicense/. +-- [ END OF LICENSE 0867164ffc95e54f04670b5169c09574bdbd9bba ] + +--- Copyright 2019 Kong Inc. +local ngx_ssl = require "ngx.ssl" +local ssl_clt = require "ngx.ssl.clienthello" +local sni_filter = require("kong.tls.plugins.sni_filter") +local pl_stringx = require "pl.stringx" +local server_name = ngx_ssl.server_name +local PREFIX_SNIS_PSEUDO_INDEX = sni_filter.PREFIX_SNIS_PSEUDO_INDEX +local POSTFIX_SNIS_PSEUDO_INDEX = sni_filter.POSTFIX_SNIS_PSEUDO_INDEX +local startswith = pl_stringx.startswith +local endswith = pl_stringx.endswith + +local _M = {} + +local kong = kong +local EMPTY_T = {} + + +local function match_sni(snis, server_name) + if server_name then + -- search plain snis + if snis[server_name] then + kong.log.debug("matched the plain sni ", server_name) + return snis[server_name] + end + + -- TODO: use radix tree to accelerate the search once we have an available implementation + -- search snis with the leftmost wildcard + for sni, sni_t in pairs(snis[POSTFIX_SNIS_PSEUDO_INDEX] or EMPTY_T) do + if endswith(server_name, sni_t.value) then + kong.log.debug(server_name, " matched the sni with the leftmost wildcard ", sni) + return sni_t + end + end + + -- search snis with the rightmost wildcard + for sni, sni_t in pairs(snis[PREFIX_SNIS_PSEUDO_INDEX] or EMPTY_T) do + if startswith(server_name, sni_t.value) then + kong.log.debug(server_name, " matched the sni with the rightmost wildcard ", sni) + return sni_t + end + end + end + + if server_name then + kong.log.debug("client sent an unknown sni ", server_name) + + else + kong.log.debug("client didn't send an sni") + end + + if snis["*"] then + kong.log.debug("mTLS is enabled globally") + return snis["*"] + end +end + +function _M.execute(snis_set) + + local server_name = server_name() + + local sni_mapping = match_sni(snis_set, server_name) + + if sni_mapping then + -- TODO: improve detection of ennoblement once we have DAO functions + -- to filter plugin configurations based on plugin name + + kong.log.debug("enabled, will request certificate from client") + + local chain + -- send CA DN list + if sni_mapping.ca_cert_chain then + kong.log.debug("set client ca certificate chain") + chain = sni_mapping.ca_cert_chain.ctx + end + + local res, err = kong.client.tls.request_client_certificate(chain) + if not res then + kong.log.err("unable to request client to present its certificate: ", + err) + end + + -- disable session resumption to prevent inability to access client + -- certificate in later phases + res, err = kong.client.tls.disable_session_reuse() + if not res then + kong.log.err("unable to disable session reuse for client certificate: ", + err) + end + end +end + +function _M.execute_client_hello(snis_set, options) + if not snis_set then + return + end + + if not options then + return + end + + if not options.disable_http2 then + return + end + + local server_name, err = ssl_clt.get_client_hello_server_name() + if err then + kong.log.debug("unable to get client hello server name: ", err) + return + end + + local sni_mapping = match_sni(snis_set, server_name) + + if sni_mapping then + local res, err = kong.client.tls.disable_http2_alpn() + if not res then + kong.log.err("unable to disable http2 alpn: ", err) + end + end +end + +return _M diff --git a/kong/tls/plugins/sni_filter.lua b/kong/tls/plugins/sni_filter.lua new file mode 100644 index 000000000000..bf1e342ba825 --- /dev/null +++ b/kong/tls/plugins/sni_filter.lua @@ -0,0 +1,263 @@ +-- This software is copyright Kong Inc. and its licensors. +-- Use of the software is subject to the agreement between your organization +-- and Kong Inc. If there is no such agreement, use is governed by and +-- subject to the terms of the Kong Master Software License Agreement found +-- at https://konghq.com/enterprisesoftwarelicense/. +-- [ END OF LICENSE 0867164ffc95e54f04670b5169c09574bdbd9bba ] + +local constants = require "kong.constants" +local openssl_x509 = require "resty.openssl.x509" +local chain_lib = require "resty.openssl.x509.chain" + +local _M = {} + +local kong = kong +local ipairs = ipairs +local new_tab = require("table.new") + +local PREFIX_SNIS_PSEUDO_INDEX = -1 +local POSTFIX_SNIS_PSEUDO_INDEX = -2 +_M.PREFIX_SNIS_PSEUDO_INDEX = PREFIX_SNIS_PSEUDO_INDEX +_M.POSTFIX_SNIS_PSEUDO_INDEX = POSTFIX_SNIS_PSEUDO_INDEX +local TTL_FOREVER = { ttl = 0 } + +local ca_cert_cache_opts = { + l1_serializer = function(ca) + local x509, err = openssl_x509.new(ca.cert, "PEM") + if err then + return nil, err + end + + return x509 + end +} + + +-- make the table out side of function to reuse table +local key = new_tab(1, 0) + +local function load_ca(ca_id) + kong.log.debug("cache miss for CA Cert") + + key.id = ca_id + local ca, err = kong.db.ca_certificates:select(key) + if not ca then + if err then + return nil, err + end + + return nil, "CA Certificate '" .. tostring(ca_id) .. "' does not exist" + end + + return ca +end + +local function merge_ca_ids(sni, ca_ids) + sni.ca_ids = sni.ca_ids or {} + local sni_ca_ids = sni.ca_ids + + for _, ca_id in ipairs(ca_ids) do + if not sni_ca_ids[ca_id] then + sni_ca_ids[ca_id] = true + end + end +end + +local function ca_cert_cache_key(ca_id) + return "mtls:cacert:" .. ca_id +end + +local function load_routes_from_db(db, route_id, options) + kong.log.debug("cache miss for route id: " .. route_id.id) + local routes, err = db.routes:select(route_id, options) + if routes == nil then + -- the third value means "do not cache" + return nil, err, -1 + end + + return routes +end + + +local function build_snis_for_route(route, snis, send_ca_dn, ca_ids) + -- every route should have SNI or ask cert on all requests + if not route.snis or #route.snis == 0 then + snis["*"] = snis["*"] or {} + + if send_ca_dn then + merge_ca_ids(snis["*"], ca_ids) + end + + else + for _, sni in ipairs(route.snis) do + local sni_t + local idx = sni:find("*", 1, true) + + if idx == 1 then + -- store snis with the leftmost wildcard in a subtable + snis[POSTFIX_SNIS_PSEUDO_INDEX] = snis[POSTFIX_SNIS_PSEUDO_INDEX] or {} + local postfix_snis = snis[POSTFIX_SNIS_PSEUDO_INDEX] + postfix_snis[sni] = postfix_snis[sni] or { value = sni:sub(2) } + sni_t = postfix_snis[sni] + kong.log.debug("add a postfix sni ", sni) + + elseif idx == #sni then + -- store snis with the rightmost wildcard in a subtable + snis[PREFIX_SNIS_PSEUDO_INDEX] = snis[PREFIX_SNIS_PSEUDO_INDEX] or {} + local prefix_snis = snis[PREFIX_SNIS_PSEUDO_INDEX] + prefix_snis[sni] = prefix_snis[sni] or { value = sni:sub(1, -2) } + sni_t = prefix_snis[sni] + kong.log.debug("add a prefix sni ", sni) + + else + snis[sni] = snis[sni] or {} + sni_t = snis[sni] + kong.log.debug("add a plain sni ", sni) + end + + if send_ca_dn then + merge_ca_ids(sni_t, ca_ids) + end + end + end +end + + +local function get_snis_for_plugin(db, plugin, snis, options) + -- plugin applied on service + local service_pk = plugin.service + local send_ca_dn = plugin.config.send_ca_dn + local ca_ids = plugin.config.ca_certificates + + if service_pk then + for route, err in db.routes:each_for_service(service_pk, nil, options) do + if err then + return err + end + + -- XXX: strictly speaking, if a mtls plugin is also applied on the route, + -- then we should skip the plugin applied on the corresponding service, + -- as the plugin on route has a higher priority. + -- But this requires a plugin iteration on every route. + -- For performance considerations, we choose to continue. + -- Sending a few more ca dn is not a big deal, since we are already doing + -- this by merging the ca dn of mtls plugins with the same sni. + -- After all, sending some extra ca dn is better than sending nothing. + build_snis_for_route(route, snis, send_ca_dn, ca_ids) + end + + return + end + + -- plugin applied on route + local route_pk = plugin.route + if route_pk then + -- since routes entity is workspaceable, workspace id + -- needs to be passed when computing cache key + local cache_key = db.routes:cache_key(route_pk.id, nil, nil, nil, nil, plugin.ws_id) + local cache_obj = kong[constants.ENTITY_CACHE_STORE.routes] + local route, err = cache_obj:get(cache_key, TTL_FOREVER, + load_routes_from_db, db, + route_pk, options) + + if err then + return err + end + + build_snis_for_route(route, snis, send_ca_dn, ca_ids) + + return + end + + -- plugin applied on global scope + snis["*"] = snis["*"] or {} + if send_ca_dn then + merge_ca_ids(snis["*"], ca_ids) + end +end + +-- build ca_cert_chain from sni_t +local function build_ca_cert_chain(sni_t) + local ca_ids = sni_t.ca_ids + + if not ca_ids then + return true + end + + local chain, err = chain_lib.new() + if err then + return nil, err + end + + for ca_id, _ in pairs(ca_ids) do + local x509, err = kong.cache:get(ca_cert_cache_key(ca_id), ca_cert_cache_opts, + load_ca, ca_id) + if err then + return nil, err + end + + local _ + _, err = chain:add(x509) + + if err then + return nil, err + end + end + + sni_t.ca_cert_chain = chain + + return true +end + + +-- build ca_cert_chain for every sni +function _M.sni_cache_l1_serializer(snis) + for k, v in pairs(snis) do + if k == PREFIX_SNIS_PSEUDO_INDEX or + k == POSTFIX_SNIS_PSEUDO_INDEX then + for _, sni_t in pairs(v) do + local res, err = build_ca_cert_chain(sni_t) + if not res then + return nil, err + end + end + + else + local res, err = build_ca_cert_chain(v) + if not res then + return nil, err + end + end + end + + return snis +end + +function _M.build_ssl_route_filter_set(plugin_name) + kong.log.debug("building ssl route filter set for plugin name " .. plugin_name) + local db = kong.db + local snis = {} + local options = {} + + for plugin, err in kong.db.plugins:each() do + if err then + return nil, "could not load plugins: " .. err + end + + if plugin.enabled and plugin.name == plugin_name then + local err = get_snis_for_plugin(db, plugin, snis, options) + if err then + return nil, err + end + + if snis["*"] then + break + end + end + end + + return snis +end + + +return _M diff --git a/spec/03-plugins/38-ai-proxy/02-openai_integration_spec.lua b/spec/03-plugins/38-ai-proxy/02-openai_integration_spec.lua index d0017dd96c2e..42a9d7232bac 100644 --- a/spec/03-plugins/38-ai-proxy/02-openai_integration_spec.lua +++ b/spec/03-plugins/38-ai-proxy/02-openai_integration_spec.lua @@ -1,6 +1,7 @@ local helpers = require "spec.helpers" local cjson = require "cjson" local pl_file = require "pl.file" +local ssl_fixtures = require "spec.fixtures.ssl" local strip = require("kong.tools.string").strip @@ -232,12 +233,24 @@ for _, strategy in helpers.all_strategies() do if strategy ~= "cassandra" then path = "/", }) + local certificate = bp.certificates:insert { + cert = ssl_fixtures.cert_alt_alt, + key = ssl_fixtures.key_alt_alt, + cert_alt = ssl_fixtures.cert_alt_alt_ecdsa, + key_alt = ssl_fixtures.key_alt_alt_ecdsa, + } + bp.snis:insert { + name = "example.test", + certificate = certificate, + } + -- 200 chat good with one option local chat_good = assert(bp.routes:insert { service = empty_service, - protocols = { "http" }, + protocols = { "http", "https" }, strip_path = true, - paths = { "/openai/llm/v1/chat/good" } + paths = { "/openai/llm/v1/chat/good" }, + snis = { "example.test" }, }) bp.plugins:insert { name = PLUGIN_NAME, @@ -287,9 +300,10 @@ for _, strategy in helpers.all_strategies() do if strategy ~= "cassandra" then -- 200 chat good with one option local chat_good_no_allow_override = assert(bp.routes:insert { service = empty_service, - protocols = { "http" }, + protocols = { "http", "https" }, strip_path = true, - paths = { "/openai/llm/v1/chat/good-no-allow-override" } + paths = { "/openai/llm/v1/chat/good-no-allow-override" }, + snis = { "example.test" }, }) bp.plugins:insert { name = PLUGIN_NAME, @@ -323,9 +337,10 @@ for _, strategy in helpers.all_strategies() do if strategy ~= "cassandra" then -- 200 chat good with statistics disabled local chat_good_no_stats = assert(bp.routes:insert { service = empty_service, - protocols = { "http" }, + protocols = { "http", "https" }, strip_path = true, - paths = { "/openai/llm/v1/chat/good-without-stats" } + paths = { "/openai/llm/v1/chat/good-without-stats" }, + snis = { "example.test" }, }) bp.plugins:insert { name = PLUGIN_NAME, @@ -363,9 +378,10 @@ for _, strategy in helpers.all_strategies() do if strategy ~= "cassandra" then -- 200 chat good with all logging enabled local chat_good_log_payloads = assert(bp.routes:insert { service = empty_service, - protocols = { "http" }, + protocols = { "http", "https" }, strip_path = true, - paths = { "/openai/llm/v1/chat/good-with-payloads" } + paths = { "/openai/llm/v1/chat/good-with-payloads" }, + snis = { "example.test" }, }) bp.plugins:insert { name = PLUGIN_NAME, @@ -403,9 +419,10 @@ for _, strategy in helpers.all_strategies() do if strategy ~= "cassandra" then -- 200 chat bad upstream response with one option local chat_bad_upstream = assert(bp.routes:insert { service = empty_service, - protocols = { "http" }, + protocols = { "http", "https" }, strip_path = true, - paths = { "/openai/llm/v1/chat/bad_upstream_response" } + paths = { "/openai/llm/v1/chat/bad_upstream_response" }, + snis = { "example.test" }, }) bp.plugins:insert { name = PLUGIN_NAME, @@ -432,9 +449,10 @@ for _, strategy in helpers.all_strategies() do if strategy ~= "cassandra" then -- 200 completions good with one option local completions_good = assert(bp.routes:insert { service = empty_service, - protocols = { "http" }, + protocols = { "http", "https" }, strip_path = true, - paths = { "/openai/llm/v1/completions/good" } + paths = { "/openai/llm/v1/completions/good" }, + snis = { "example.test" }, }) bp.plugins:insert { name = PLUGIN_NAME, @@ -461,9 +479,10 @@ for _, strategy in helpers.all_strategies() do if strategy ~= "cassandra" then -- 200 completions good using query param key local completions_good_one_query_param = assert(bp.routes:insert { service = empty_service, - protocols = { "http" }, + protocols = { "http", "https" }, strip_path = true, - paths = { "/openai/llm/v1/completions/query-param-auth" } + paths = { "/openai/llm/v1/completions/query-param-auth" }, + snis = { "example.test" }, }) bp.plugins:insert { name = PLUGIN_NAME, @@ -491,9 +510,10 @@ for _, strategy in helpers.all_strategies() do if strategy ~= "cassandra" then -- 200 completions good using query param key with no allow override local completions_good_one_query_param_no_allow_override = assert(bp.routes:insert { service = empty_service, - protocols = { "http" }, + protocols = { "http", "https" }, strip_path = true, - paths = { "/openai/llm/v1/completions/query-param-auth-no-allow-override" } + paths = { "/openai/llm/v1/completions/query-param-auth-no-allow-override" }, + snis = { "example.test" }, }) bp.plugins:insert { name = PLUGIN_NAME, @@ -523,9 +543,10 @@ for _, strategy in helpers.all_strategies() do if strategy ~= "cassandra" then -- 200 embeddings (preserve route mode) good local chat_good = assert(bp.routes:insert { service = empty_service, - protocols = { "http" }, + protocols = { "http", "https" }, strip_path = true, - paths = { "/openai/llm/v1/embeddings/good" } + paths = { "/openai/llm/v1/embeddings/good" }, + snis = { "example.test" }, }) bp.plugins:insert { name = PLUGIN_NAME, @@ -556,9 +577,10 @@ for _, strategy in helpers.all_strategies() do if strategy ~= "cassandra" then -- 200 chat good but no model set in plugin config local chat_good_no_model = assert(bp.routes:insert { service = empty_service, - protocols = { "http" }, + protocols = { "http", "https"}, strip_path = true, - paths = { "/openai/llm/v1/chat/good-no-model-param" } + paths = { "/openai/llm/v1/chat/good-no-model-param" }, + snis = { "example.test" }, }) bp.plugins:insert { name = PLUGIN_NAME, @@ -600,9 +622,10 @@ for _, strategy in helpers.all_strategies() do if strategy ~= "cassandra" then -- 200 completions good using post body key local completions_good_post_body_key = assert(bp.routes:insert { service = empty_service, - protocols = { "http" }, + protocols = { "http", "https"}, strip_path = true, - paths = { "/openai/llm/v1/completions/post-body-auth" } + paths = { "/openai/llm/v1/completions/post-body-auth" }, + snis = { "example.test" }, }) bp.plugins:insert { name = PLUGIN_NAME, @@ -630,9 +653,10 @@ for _, strategy in helpers.all_strategies() do if strategy ~= "cassandra" then -- 200 completions good using post body key local completions_good_post_body_key_no_allow_override = assert(bp.routes:insert { service = empty_service, - protocols = { "http" }, + protocols = { "http", "https" }, strip_path = true, - paths = { "/openai/llm/v1/completions/post-body-auth-no-allow-override" } + paths = { "/openai/llm/v1/completions/post-body-auth-no-allow-override" }, + snis = { "example.test" }, }) bp.plugins:insert { name = PLUGIN_NAME, @@ -662,9 +686,10 @@ for _, strategy in helpers.all_strategies() do if strategy ~= "cassandra" then -- 401 unauthorized local chat_401 = assert(bp.routes:insert { service = empty_service, - protocols = { "http" }, + protocols = { "http", "https" }, strip_path = true, - paths = { "/openai/llm/v1/chat/unauthorized" } + paths = { "/openai/llm/v1/chat/unauthorized" }, + snis = { "example.test" }, }) bp.plugins:insert { name = PLUGIN_NAME, @@ -691,9 +716,10 @@ for _, strategy in helpers.all_strategies() do if strategy ~= "cassandra" then -- 400 bad request chat local chat_400 = assert(bp.routes:insert { service = empty_service, - protocols = { "http" }, + protocols = { "http", "https" }, strip_path = true, - paths = { "/openai/llm/v1/chat/bad_request" } + paths = { "/openai/llm/v1/chat/bad_request" }, + snis = { "example.test" }, }) bp.plugins:insert { name = PLUGIN_NAME, @@ -720,9 +746,10 @@ for _, strategy in helpers.all_strategies() do if strategy ~= "cassandra" then -- 400 bad request completions local chat_400_comp = assert(bp.routes:insert { service = empty_service, - protocols = { "http" }, + protocols = { "http", "https" }, strip_path = true, - paths = { "/openai/llm/v1/completions/bad_request" } + paths = { "/openai/llm/v1/completions/bad_request" }, + snis = { "example.test" }, }) bp.plugins:insert { name = PLUGIN_NAME, @@ -749,9 +776,10 @@ for _, strategy in helpers.all_strategies() do if strategy ~= "cassandra" then -- 500 internal server error local chat_500 = assert(bp.routes:insert { service = empty_service, - protocols = { "http" }, + protocols = { "http", "https" }, strip_path = true, - paths = { "/openai/llm/v1/chat/internal_server_error" } + paths = { "/openai/llm/v1/chat/internal_server_error" }, + snis = { "example.test" }, }) bp.plugins:insert { name = PLUGIN_NAME, @@ -785,6 +813,7 @@ for _, strategy in helpers.all_strategies() do if strategy ~= "cassandra" then plugins = "bundled,ctx-checker-last,ctx-checker," .. PLUGIN_NAME, -- write & load declarative config, only if 'strategy=off' declarative_config = strategy == "off" and helpers.make_yaml_file() or nil, + log_level = "info", }, nil, nil, fixtures)) end) @@ -1054,6 +1083,25 @@ for _, strategy in helpers.all_strategies() do if strategy ~= "cassandra" then assert.equals(r.headers["ctx-checker-last-llm-model-requested"], "gpt-3.5-turbo") end) + it("good request with http2", function() + local curl_command = string.format("curl -X GET -k --resolve example.test:%s:127.0.0.1 -H 'Content-Type: application/json' https://example.test:%s/openai/llm/v1/chat/good -d @spec/fixtures/ai-proxy/openai/llm-v1-chat/requests/good.json", helpers.get_proxy_port(true), helpers.get_proxy_port(true)) + local output = io.popen(curl_command):read("*a") + ngx.log(ngx.ERR, output) + local json = assert(cjson.decode(output)) + + -- in this case, origin is "undxpected error" message + assert.equals(json.message, nil) + assert.equals(json.id, "chatcmpl-8T6YwgvjQVVnGbJ2w8hpOA17SeNy2") + assert.equals(json.model, "gpt-3.5-turbo-0613") + assert.equals(json.object, "chat.completion") + assert.is_table(json.choices) + assert.is_table(json.choices[1].message) + assert.same({ + content = "The sum of 1 + 1 is 2.", + role = "assistant", + }, json.choices[1].message) + end) + it("good request, parses model of cjson.null", function() local body = pl_file.read("spec/fixtures/ai-proxy/openai/llm-v1-chat/requests/good.json") body = cjson.decode(body) diff --git a/spec/fixtures/1.2_custom_nginx.template b/spec/fixtures/1.2_custom_nginx.template index 2f3851d919a3..faa15037ec5c 100644 --- a/spec/fixtures/1.2_custom_nginx.template +++ b/spec/fixtures/1.2_custom_nginx.template @@ -102,6 +102,9 @@ http { ssl_certificate_by_lua_block { Kong.ssl_certificate() } + ssl_client_hello_by_lua_block { + Kong.ssl_client_hello() + } > end # injected nginx_proxy_* directives diff --git a/t/01-pdk/14-client-tls/00-phase_checks.t b/t/01-pdk/14-client-tls/00-phase_checks.t index d43f6519129b..8be8ca746431 100644 --- a/t/01-pdk/14-client-tls/00-phase_checks.t +++ b/t/01-pdk/14-client-tls/00-phase_checks.t @@ -26,6 +26,11 @@ qq{ phase_check_functions(phases.certificate) } + + ssl_client_hello_by_lua_block { + phase_check_functions(phases.client_hello) + } + location / { set \$upstream_uri '/t'; set \$upstream_scheme 'https'; @@ -66,6 +71,7 @@ qq{ args = {}, init_worker = "forced false", certificate = true, + client_hello = "forced false", rewrite = "forced false", access = "forced false", header_filter = "forced false", @@ -78,6 +84,7 @@ qq{ args = {}, init_worker = false, certificate = true, + client_hello = false, rewrite = false, access = false, header_filter = false, @@ -90,6 +97,7 @@ qq{ args = {}, init_worker = false, certificate = false, + client_hello = false, rewrite = true, access = true, response = true, @@ -101,6 +109,7 @@ qq{ method = "set_client_verify", args = { "SUCCESS", }, init_worker = "forced false", + client_hello = "forced false", certificate = "forced false", rewrite = nil, access = nil, @@ -109,6 +118,19 @@ qq{ body_filter = "forced false", log = "forced false", admin_api = false, + }, { + method = "disable_http2_alpn", + args = {}, + init_worker = false, + client_hello = true, + certificate = false, + rewrite = false, + access = false, + header_filter = false, + response = false, + body_filter = false, + log = false, + admin_api = false, }, }