From 77225d7d60d99b5522b7f7084d072287450a44a6 Mon Sep 17 00:00:00 2001 From: Nic Date: Thu, 8 Oct 2026 10:48:27 +0000 Subject: [PATCH 1/5] feat(consul): support https consul server addresses Consul server addresses may now use the https scheme, per server and mixed with http ones. TLS goes through lua-resty-consul (ssl, sni_host, ssl_verify), with the certificate verified against the host of the address and the trust store from lua_ssl_trusted_certificate. Any other scheme is still rejected, now with an error instead of a raise from unpack() on the nil parse result. --- apisix/discovery/consul/client.lua | 18 ++- conf/config.yaml.example | 2 +- docs/en/latest/discovery/consul.md | 12 ++ t/discovery/consul-tls.t | 172 +++++++++++++++++++++++++++++ 4 files changed, 199 insertions(+), 5 deletions(-) create mode 100644 t/discovery/consul-tls.t diff --git a/apisix/discovery/consul/client.lua b/apisix/discovery/consul/client.lua index c5afd20e8e6e..e98abb9e169a 100644 --- a/apisix/discovery/consul/client.lua +++ b/apisix/discovery/consul/client.lua @@ -120,6 +120,9 @@ local function get_opts(consul_server, is_catalog) local opts = { host = consul_server.host, port = consul_server.port, + ssl = consul_server.ssl, + ssl_verify = true, + sni_host = consul_server.host, connect_timeout = consul_server.connect_timeout, read_timeout = consul_server.read_timeout, default_args = { @@ -249,15 +252,19 @@ function _M.format_consul_params(consul_conf) local consul_server_list = core.table.new(0, #servers) for _, v in pairs(servers) do - local scheme, host, port, path = unpack(http.parse_uri(nil, v)) - if scheme ~= "http" then - return nil, "only support consul http schema address, eg: http://address:port" + -- parse_uri only accepts http and https, any other scheme yields nil + local scheme, host, port, path = unpack(http.parse_uri(nil, v) or {}) + if scheme ~= "http" and scheme ~= "https" then + return nil, "only support consul http or https schema address, " + .. "eg: http://address:port or https://address:port" elseif path ~= "/" or core.string.has_suffix(v, '/') then - return nil, "invalid consul server address, the valid format: http://address:port" + return nil, "invalid consul server address, the valid format: " + .. "http://address:port or https://address:port" end core.table.insert(consul_server_list, { host = host, port = port, + ssl = scheme == "https", token = consul_conf.token, connect_timeout = consul_conf.timeout.connect, read_timeout = consul_conf.timeout.read, @@ -303,6 +310,9 @@ function _M.fetch_services_from_server(consul_server, options) local consul_client = resty_consul:new({ host = consul_server.host, port = consul_server.port, + ssl = consul_server.ssl, + ssl_verify = true, + sni_host = consul_server.host, connect_timeout = consul_server.connect_timeout, read_timeout = consul_server.read_timeout, default_args = { diff --git a/conf/config.yaml.example b/conf/config.yaml.example index 6a1361aec496..fbf96c0d8c52 100644 --- a/conf/config.yaml.example +++ b/conf/config.yaml.example @@ -439,7 +439,7 @@ nginx_config: # Config for render the template to generate n # path: "logs/consul_kv.dump" # Location of the dump file. # expire: 2592000 # Specify the expiration time of the dump file in units of seconds. # consul: # Consul -# servers: # Consul address(es) +# servers: # Consul address(es), http or https # - "http://127.0.0.1:8500" # - "http://127.0.0.1:8600" # skip_services: # Skip services during service discovery. diff --git a/docs/en/latest/discovery/consul.md b/docs/en/latest/discovery/consul.md index fd6758bc9abb..b3a24fa20d26 100644 --- a/docs/en/latest/discovery/consul.md +++ b/docs/en/latest/discovery/consul.md @@ -69,6 +69,18 @@ discovery: - "http://127.0.0.1:8500" ``` +Each server address uses the `http` or `https` scheme, and the two can be mixed in one list. An `https` address without a port connects to port 443. APISIX verifies the certificate of an `https` Consul server against the host in the address, so configure the CA bundle that signed it with `apisix.ssl.ssl_trusted_certificate`: + +```yaml +apisix: + ssl: + ssl_trusted_certificate: /path/to/consul-ca.crt +discovery: + consul: + servers: + - "https://consul.example.com:8501" +``` + The `keepalive` has two optional values: - `true`, default and recommend value, use the long pull way to query consul servers diff --git a/t/discovery/consul-tls.t b/t/discovery/consul-tls.t new file mode 100644 index 000000000000..a1b8773d5736 --- /dev/null +++ b/t/discovery/consul-tls.t @@ -0,0 +1,172 @@ +# +# 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); +log_level('info'); +no_root_location(); +no_shuffle(); + + +add_block_preprocessor(sub { + my ($block) = @_; + + # a TLS endpoint fronting the consul agent on 8500, its certificate + # (localhost) is signed by apisix.crt; the trust store is the http level + # lua_ssl_trusted_certificate, as apisix.ssl.ssl_trusted_certificate renders it + my $http_config = $block->http_config // <<_EOC_; + lua_ssl_trusted_certificate ../../certs/apisix.crt; + + server { + listen 18501 ssl; + ssl_certificate ../../certs/localhost_slapd_cert.pem; + ssl_certificate_key ../../certs/localhost_slapd_key.pem; + + location / { + proxy_pass http://127.0.0.1:8500; + proxy_read_timeout 70s; + } + } + + server { + listen 30511; + + location /hello { + content_by_lua_block { + ngx.say("server 1") + } + } + } +_EOC_ + + $block->set_value("http_config", $http_config); +}); + + +sub main::yaml_config { + my ($servers, $keepalive) = @_; + return <<_EOC_; +apisix: + node_listen: 1984 +deployment: + role: data_plane + role_data_plane: + config_provider: yaml +discovery: + consul: + servers: + - "$servers" + timeout: + connect: 1000 + read: 1000 + wait: 60 + weight: 1 + fetch_interval: 1 + keepalive: $keepalive +_EOC_ +} + +our $apisix_yaml = <<_EOC_; +routes: + - + uri: /hello + upstream: + service_name: service_tls + discovery_type: consul + type: roundrobin +#END +_EOC_ + + +run_tests(); + +__DATA__ + +=== TEST 1: prepare consul catalog register nodes +--- config +location /v1/agent { + proxy_pass http://127.0.0.1:8500; +} +--- request eval +"PUT /v1/agent/service/register\n" . "{\"ID\":\"service_tls1\",\"Name\":\"service_tls\",\"Address\":\"127.0.0.1\",\"Port\":30511}" +--- error_code: 200 + + + +=== TEST 2: https consul server (long connect type) +--- yaml_config eval: main::yaml_config("https://localhost:18501", "true") +--- apisix_yaml eval: $::apisix_yaml +--- request +GET /hello +--- response_body +server 1 +--- no_error_log +[error] + + + +=== TEST 3: https consul server (short connect type) +--- yaml_config eval: main::yaml_config("https://localhost:18501", "false") +--- apisix_yaml eval: $::apisix_yaml +--- request +GET /hello +--- response_body +server 1 +--- no_error_log +[error] + + + +=== TEST 4: scheme per server address, other schemes are still rejected +--- config + location /t { + content_by_lua_block { + local client = require("apisix.discovery.consul.client") + local timeout = {connect = 1000, read = 1000, wait = 60} + + local servers, err = client.format_consul_params({ + servers = {"http://127.0.0.1:8500", "https://consul.local"}, + timeout = timeout, + }) + for _, s in ipairs(servers or {}) do + ngx.say(s.host, ":", s.port, " ssl: ", s.ssl) + end + + servers, err = client.format_consul_params({ + servers = {"https://127.0.0.1:8501", "tcp://127.0.0.1:8500"}, + timeout = timeout, + }) + ngx.say(err) + } + } +--- request +GET /t +--- response_body +127.0.0.1:8500 ssl: false +consul.local:443 ssl: true +only support consul http or https schema address, eg: http://address:port or https://address:port + + + +=== TEST 5: clean nodes +--- config +location /v1/agent { + proxy_pass http://127.0.0.1:8500; +} +--- request +PUT /v1/agent/service/deregister/service_tls1 +--- error_code: 200 From fc4cddfa1024fb2a6a422c3f888784f6098838a9 Mon Sep 17 00:00:00 2001 From: Nic Date: Thu, 8 Oct 2026 11:32:58 +0000 Subject: [PATCH 2/5] fix(consul): set TLS options only for https servers, surface URI parse errors --- apisix/discovery/consul/client.lua | 29 +++++++++++++++++++---------- t/discovery/consul-tls.t | 2 +- 2 files changed, 20 insertions(+), 11 deletions(-) diff --git a/apisix/discovery/consul/client.lua b/apisix/discovery/consul/client.lua index e98abb9e169a..0f8db25352e2 100644 --- a/apisix/discovery/consul/client.lua +++ b/apisix/discovery/consul/client.lua @@ -116,19 +116,27 @@ end -- ─── resty.consul options ───────────────────────────────────────────── +local function set_tls_opts(opts, consul_server) + if consul_server.ssl then + opts.ssl = true + opts.ssl_verify = true + opts.sni_host = consul_server.host + end + return opts +end + + local function get_opts(consul_server, is_catalog) local opts = { host = consul_server.host, port = consul_server.port, - ssl = consul_server.ssl, - ssl_verify = true, - sni_host = consul_server.host, connect_timeout = consul_server.connect_timeout, read_timeout = consul_server.read_timeout, default_args = { token = consul_server.token, } } + set_tls_opts(opts, consul_server) if not consul_server.keepalive then return opts end @@ -252,8 +260,12 @@ function _M.format_consul_params(consul_conf) local consul_server_list = core.table.new(0, #servers) for _, v in pairs(servers) do - -- parse_uri only accepts http and https, any other scheme yields nil - local scheme, host, port, path = unpack(http.parse_uri(nil, v) or {}) + local parsed, err = http.parse_uri(nil, v) + if not parsed then + return nil, "invalid consul server address: " .. err + .. ", the valid format: http://address:port or https://address:port" + end + local scheme, host, port, path = unpack(parsed) if scheme ~= "http" and scheme ~= "https" then return nil, "only support consul http or https schema address, " .. "eg: http://address:port or https://address:port" @@ -307,18 +319,15 @@ function _M.fetch_services_from_server(consul_server, options) local preserve_metadata = options.preserve_metadata or false local key_builder = options.key_builder - local consul_client = resty_consul:new({ + local consul_client = resty_consul:new(set_tls_opts({ host = consul_server.host, port = consul_server.port, - ssl = consul_server.ssl, - ssl_verify = true, - sni_host = consul_server.host, connect_timeout = consul_server.connect_timeout, read_timeout = consul_server.read_timeout, default_args = { token = consul_server.token } - }) + }, consul_server)) -- fetch catalog local catalog_success, catalog_res, catalog_err = pcall(function() diff --git a/t/discovery/consul-tls.t b/t/discovery/consul-tls.t index a1b8773d5736..18c3690f3869 100644 --- a/t/discovery/consul-tls.t +++ b/t/discovery/consul-tls.t @@ -158,7 +158,7 @@ GET /t --- response_body 127.0.0.1:8500 ssl: false consul.local:443 ssl: true -only support consul http or https schema address, eg: http://address:port or https://address:port +invalid consul server address: bad uri: tcp://127.0.0.1:8500, the valid format: http://address:port or https://address:port From 47c32098af0c7a16273ec993167f4fa430cb5e84 Mon Sep 17 00:00:00 2001 From: Nic Date: Thu, 8 Oct 2026 12:03:58 +0000 Subject: [PATCH 3/5] test(consul): an untrusted https consul server certificate is rejected --- t/discovery/consul-tls.t | 31 +++++++++++++++++++++++++++++-- 1 file changed, 29 insertions(+), 2 deletions(-) diff --git a/t/discovery/consul-tls.t b/t/discovery/consul-tls.t index 18c3690f3869..5495bf013976 100644 --- a/t/discovery/consul-tls.t +++ b/t/discovery/consul-tls.t @@ -131,7 +131,34 @@ server 1 -=== TEST 4: scheme per server address, other schemes are still rejected +=== TEST 4: the certificate of an https consul server is verified +--- http_config + # same endpoint as above, but its certificate is not signed by the trusted CA + lua_ssl_trusted_certificate ../../certs/mtls_ca.crt; + + server { + listen 18501 ssl; + ssl_certificate ../../certs/localhost_slapd_cert.pem; + ssl_certificate_key ../../certs/localhost_slapd_key.pem; + + location / { + proxy_pass http://127.0.0.1:8500; + } + } +--- yaml_config eval: main::yaml_config("https://localhost:18501", "false") +--- apisix_yaml eval: $::apisix_yaml +--- request +GET /hello +--- error_code: 503 +--- error_log eval +[ + qr/connect consul: https:\/\/localhost:18501\/v1 .* with error: \d+: unable to (get local issuer|verify the first) certificate/, + qr/consul service not found: default\/service_tls/, +] + + + +=== TEST 5: scheme per server address, other schemes are still rejected --- config location /t { content_by_lua_block { @@ -162,7 +189,7 @@ invalid consul server address: bad uri: tcp://127.0.0.1:8500, the valid format: -=== TEST 5: clean nodes +=== TEST 6: clean nodes --- config location /v1/agent { proxy_pass http://127.0.0.1:8500; From cf748dc90f5931e7fe82c1f381627ac1f46198a6 Mon Sep 17 00:00:00 2001 From: Nic Date: Wed, 14 Oct 2026 03:01:36 +0000 Subject: [PATCH 4/5] feat(consul): make ssl_verify configurable for https consul servers --- apisix/discovery/consul/client.lua | 3 +- apisix/discovery/consul/schema.lua | 1 + t/discovery/consul-tls.t | 59 ++++++++++++++++++++++++++---- 3 files changed, 55 insertions(+), 8 deletions(-) diff --git a/apisix/discovery/consul/client.lua b/apisix/discovery/consul/client.lua index 14e8a79c0154..3146dd7f917f 100644 --- a/apisix/discovery/consul/client.lua +++ b/apisix/discovery/consul/client.lua @@ -119,7 +119,7 @@ end local function set_tls_opts(opts, consul_server) if consul_server.ssl then opts.ssl = true - opts.ssl_verify = true + opts.ssl_verify = consul_server.ssl_verify opts.sni_host = consul_server.host end return opts @@ -279,6 +279,7 @@ function _M.format_consul_params(consul_conf) host = host, port = port, ssl = scheme == "https", + ssl_verify = consul_conf.ssl_verify ~= false, token = consul_conf.token, connect_timeout = consul_conf.timeout.connect, read_timeout = consul_conf.timeout.read, diff --git a/apisix/discovery/consul/schema.lua b/apisix/discovery/consul/schema.lua index 57080ee7b717..0462974023b9 100644 --- a/apisix/discovery/consul/schema.lua +++ b/apisix/discovery/consul/schema.lua @@ -30,6 +30,7 @@ return { default = "64m", }, token = {type = "string", default = ""}, + ssl_verify = {type = "boolean", default = true}, fetch_interval = {type = "integer", minimum = 1, default = 3}, keepalive = { type = "boolean", diff --git a/t/discovery/consul-tls.t b/t/discovery/consul-tls.t index 5495bf013976..3dd9308094c8 100644 --- a/t/discovery/consul-tls.t +++ b/t/discovery/consul-tls.t @@ -58,7 +58,8 @@ _EOC_ sub main::yaml_config { - my ($servers, $keepalive) = @_; + my ($servers, $keepalive, $ssl_verify) = @_; + my $ssl_verify_conf = defined $ssl_verify ? " ssl_verify: $ssl_verify\n" : ""; return <<_EOC_; apisix: node_listen: 1984 @@ -77,6 +78,7 @@ discovery: weight: 1 fetch_interval: 1 keepalive: $keepalive +$ssl_verify_conf _EOC_ } @@ -131,7 +133,7 @@ server 1 -=== TEST 4: the certificate of an https consul server is verified +=== TEST 4: the certificate of an https consul server is verified by default --- http_config # same endpoint as above, but its certificate is not signed by the trusted CA lua_ssl_trusted_certificate ../../certs/mtls_ca.crt; @@ -158,7 +160,42 @@ GET /hello -=== TEST 5: scheme per server address, other schemes are still rejected +=== TEST 5: ssl_verify false accepts a certificate the DP does not trust +--- http_config + # same endpoint as above, but its certificate is not signed by the trusted CA + lua_ssl_trusted_certificate ../../certs/mtls_ca.crt; + + server { + listen 18501 ssl; + ssl_certificate ../../certs/localhost_slapd_cert.pem; + ssl_certificate_key ../../certs/localhost_slapd_key.pem; + + location / { + proxy_pass http://127.0.0.1:8500; + } + } + + server { + listen 30511; + + location /hello { + content_by_lua_block { + ngx.say("server 1") + } + } + } +--- yaml_config eval: main::yaml_config("https://localhost:18501", "false", "false") +--- apisix_yaml eval: $::apisix_yaml +--- request +GET /hello +--- response_body +server 1 +--- no_error_log +[error] + + + +=== TEST 6: scheme per server address, other schemes are still rejected --- config location /t { content_by_lua_block { @@ -170,9 +207,16 @@ GET /hello timeout = timeout, }) for _, s in ipairs(servers or {}) do - ngx.say(s.host, ":", s.port, " ssl: ", s.ssl) + ngx.say(s.host, ":", s.port, " ssl: ", s.ssl, " ssl_verify: ", s.ssl_verify) end + servers, err = client.format_consul_params({ + servers = {"https://consul.local:8501"}, + timeout = timeout, + ssl_verify = false, + }) + ngx.say(servers[1].host, ":", servers[1].port, " ssl_verify: ", servers[1].ssl_verify) + servers, err = client.format_consul_params({ servers = {"https://127.0.0.1:8501", "tcp://127.0.0.1:8500"}, timeout = timeout, @@ -183,13 +227,14 @@ GET /hello --- request GET /t --- response_body -127.0.0.1:8500 ssl: false -consul.local:443 ssl: true +127.0.0.1:8500 ssl: false ssl_verify: true +consul.local:443 ssl: true ssl_verify: true +consul.local:8501 ssl_verify: false invalid consul server address: bad uri: tcp://127.0.0.1:8500, the valid format: http://address:port or https://address:port -=== TEST 6: clean nodes +=== TEST 7: clean nodes --- config location /v1/agent { proxy_pass http://127.0.0.1:8500; From 34a1b470bf4874d3997d09efd44356cf614995bd Mon Sep 17 00:00:00 2001 From: Nic Date: Wed, 14 Oct 2026 03:02:20 +0000 Subject: [PATCH 5/5] docs(consul): document ssl_verify --- conf/config.yaml.example | 1 + docs/en/latest/discovery/consul.md | 5 ++++- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/conf/config.yaml.example b/conf/config.yaml.example index 63450e777185..d07a8d0563b9 100644 --- a/conf/config.yaml.example +++ b/conf/config.yaml.example @@ -442,6 +442,7 @@ nginx_config: # Config for render the template to generate n # servers: # Consul address(es), http or https # - "http://127.0.0.1:8500" # - "http://127.0.0.1:8600" +# ssl_verify: true # Default true. Verify the certificate of https servers. # skip_services: # Skip services during service discovery. # - "service_a" # timeout: diff --git a/docs/en/latest/discovery/consul.md b/docs/en/latest/discovery/consul.md index b3a24fa20d26..a00c439020c5 100644 --- a/docs/en/latest/discovery/consul.md +++ b/docs/en/latest/discovery/consul.md @@ -38,6 +38,7 @@ discovery: - "http://127.0.0.1:8500" # `http://127.0.0.1:8500` and `http://127.0.0.1:8600` are different clusters - "http://127.0.0.1:8600" # `consul` service is default skip service token: "..." # if your consul cluster has enabled acl access control, you need to specify the token + ssl_verify: true # default true, verify the certificate of https consul servers skip_services: # if you need to skip special services - "service_a" timeout: @@ -69,7 +70,7 @@ discovery: - "http://127.0.0.1:8500" ``` -Each server address uses the `http` or `https` scheme, and the two can be mixed in one list. An `https` address without a port connects to port 443. APISIX verifies the certificate of an `https` Consul server against the host in the address, so configure the CA bundle that signed it with `apisix.ssl.ssl_trusted_certificate`: +Each server address uses the `http` or `https` scheme, and the two can be mixed in one list. An `https` address without a port connects to port 443. By default APISIX verifies the certificate of an `https` Consul server against the host in the address, so configure the CA bundle that signed it with `apisix.ssl.ssl_trusted_certificate`: ```yaml apisix: @@ -81,6 +82,8 @@ discovery: - "https://consul.example.com:8501" ``` +Set `ssl_verify` to `false` to skip the certificate verification of `https` servers, for example while testing against a self-signed certificate. It has no effect on `http` servers. + The `keepalive` has two optional values: - `true`, default and recommend value, use the long pull way to query consul servers