This is an automated email from the ASF dual-hosted git repository. guoqqqi pushed a commit to branch codex/etcd-tls-options-without-client-cert in repository https://gitbox.apache.org/repos/asf/apisix-helm-chart.git
commit 60387f35c3da8a9436e7761b611fa92494e4b164 Author: Qi Guo <[email protected]> AuthorDate: Wed Nov 11 01:33:03 2026 +0800 fix(apisix): allow etcd TLS options without client certificates Signed-off-by: Qi Guo <[email protected]> --- .github/workflows/ci.yaml | 1 + charts/apisix/Chart.yaml | 2 +- charts/apisix/README.md | 47 +++++++++- charts/apisix/README.md.gotmpl | 41 +++++++++ charts/apisix/templates/configmap.yaml | 4 +- charts/apisix/values.yaml | 6 +- test/render/test_etcd_tls.py | 161 +++++++++++++++++++++++++++++++++ 7 files changed, 253 insertions(+), 9 deletions(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index e2f15d3..b2b5e3a 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -33,6 +33,7 @@ jobs: && cd ../.. \ && helm repo add bitnami https://charts.bitnami.com/bitnami \ && helm repo add apisix https://apache.github.io/apisix-helm-chart \ + && python3 test/render/test_etcd_tls.py \ && ct lint \ --validate-maintainers=false \ --charts charts/apisix \ diff --git a/charts/apisix/Chart.yaml b/charts/apisix/Chart.yaml index f442860..b0a1a21 100644 --- a/charts/apisix/Chart.yaml +++ b/charts/apisix/Chart.yaml @@ -31,7 +31,7 @@ type: application # This is the chart version. This version number should be incremented each time you make changes # to the chart and its templates, including the app version. # Versions are expected to follow Semantic Versioning (https://semver.org/) -version: 2.18.2 +version: 2.18.3 # This is the version number of the application being deployed. This version number should be # incremented each time you make changes to the application. Versions are not expected to diff --git a/charts/apisix/README.md b/charts/apisix/README.md index ed6844c..4fb056e 100644 --- a/charts/apisix/README.md +++ b/charts/apisix/README.md @@ -72,6 +72,47 @@ OpenShift that assign a UID, omit the explicit `runAsUser` value. These options do not configure `extraInitContainers` and have no effect when `etcd.enabled` is false. +## Connect to external etcd with server-only TLS + +For external etcd, the URL scheme in `externalEtcd.host` selects HTTP or HTTPS. +`etcd.auth.tls.enabled` controls client certificates; leave it `false` when etcd +does not require a client certificate. The `verify` and `sni` settings apply +independently of that flag. + +For example, to connect to an IP endpoint using a CA stored in a Secret: + +```yaml +apisix: + ssl: + enabled: true + existingCASecret: etcd-ca + certCAFilename: ca.crt +etcd: + enabled: false + auth: + tls: + enabled: false + verify: true + sni: etcd.example.test +externalEtcd: + host: + - https://192.0.2.10:2379 + user: "" # This example assumes etcd username/password authentication is disabled. +``` + +Replace the endpoint, Secret name, CA key and SNI with your actual values. The CA +Secret must be in the same namespace as APISIX. If etcd username/password +authentication is enabled, configure `externalEtcd.user` and its password or +password Secret as usual. + +The SNI must be a DNS name covered by every endpoint's server certificate. It +does not change the connection address or require DNS resolution when the +endpoints are IP addresses. An empty SNI is omitted from the APISIX configuration +so the client can use the endpoint hostname. + +For mTLS, set `etcd.auth.tls.enabled: true` and configure `existingSecret`, +`certFilename` and `certKeyFilename` under `etcd.auth.tls` as usual. + ## Parameters ## Values @@ -255,10 +296,10 @@ These options do not configure `extraInitContainers` and have no effect when | etcd.auth.rbac.rootPassword | string | `""` | root password for etcd. Requires etcd.auth.rbac.create to be true. | | etcd.auth.tls.certFilename | string | `""` | etcd client cert filename using in etcd.auth.tls.existingSecret | | etcd.auth.tls.certKeyFilename | string | `""` | etcd client cert key filename using in etcd.auth.tls.existingSecret | -| etcd.auth.tls.enabled | bool | `false` | enable etcd client certificate | +| etcd.auth.tls.enabled | bool | `false` | Enable etcd client certificates. For external etcd, select HTTPS with externalEtcd.host; verify and sni also apply when client certificates are disabled. | | etcd.auth.tls.existingSecret | string | `""` | name of the secret contains etcd client cert | -| etcd.auth.tls.sni | string | `""` | specify the TLS Server Name Indication extension, the ETCD endpoint hostname will be used when this setting is unset. Set it to a DNS name in the etcd certificate if the etcd endpoints are IP addresses, since IP SANs are not matched. | -| etcd.auth.tls.verify | bool | `true` | whether to verify the etcd endpoint certificate when setup a TLS connection to etcd | +| etcd.auth.tls.sni | string | `""` | TLS server name, independently of client certificate authentication. Omitted when empty so the endpoint hostname is used. For IP endpoints, set a DNS name covered by every endpoint certificate, since IP SANs are not matched. | +| etcd.auth.tls.verify | bool | `true` | Whether to verify the etcd endpoint certificate when setting up TLS, independently of client certificate authentication. | | etcd.autoCompactionMode | string | `"periodic"` | Auto compaction mode (periodic or revision), only used when etcd.enabled is true | | etcd.autoCompactionRetention | string | `"1h"` | Auto compaction retention for mvcc key value store, only used when etcd.enabled is true | | etcd.containerSecurityContext | object | `{"enabled":false}` | added for backward compatibility with old kubernetes versions, as seccompProfile is not supported in kubernetes < 1.19 | diff --git a/charts/apisix/README.md.gotmpl b/charts/apisix/README.md.gotmpl index dad784c..7b9e8fb 100644 --- a/charts/apisix/README.md.gotmpl +++ b/charts/apisix/README.md.gotmpl @@ -72,6 +72,47 @@ OpenShift that assign a UID, omit the explicit `runAsUser` value. These options do not configure `extraInitContainers` and have no effect when `etcd.enabled` is false. +## Connect to external etcd with server-only TLS + +For external etcd, the URL scheme in `externalEtcd.host` selects HTTP or HTTPS. +`etcd.auth.tls.enabled` controls client certificates; leave it `false` when etcd +does not require a client certificate. The `verify` and `sni` settings apply +independently of that flag. + +For example, to connect to an IP endpoint using a CA stored in a Secret: + +```yaml +apisix: + ssl: + enabled: true + existingCASecret: etcd-ca + certCAFilename: ca.crt +etcd: + enabled: false + auth: + tls: + enabled: false + verify: true + sni: etcd.example.test +externalEtcd: + host: + - https://192.0.2.10:2379 + user: "" # This example assumes etcd username/password authentication is disabled. +``` + +Replace the endpoint, Secret name, CA key and SNI with your actual values. The CA +Secret must be in the same namespace as APISIX. If etcd username/password +authentication is enabled, configure `externalEtcd.user` and its password or +password Secret as usual. + +The SNI must be a DNS name covered by every endpoint's server certificate. It +does not change the connection address or require DNS resolution when the +endpoints are IP addresses. An empty SNI is omitted from the APISIX configuration +so the client can use the endpoint hostname. + +For mTLS, set `etcd.auth.tls.enabled: true` and configure `existingSecret`, +`certFilename` and `certKeyFilename` under `etcd.auth.tls` as usual. + ## Parameters {{ template "chart.valuesSection" . }} diff --git a/charts/apisix/templates/configmap.yaml b/charts/apisix/templates/configmap.yaml index a30ec1b..cf96eda 100644 --- a/charts/apisix/templates/configmap.yaml +++ b/charts/apisix/templates/configmap.yaml @@ -438,15 +438,15 @@ data: user: "root" password: "{{ print "${{APISIX_ETCD_PASSWORD}}" }}" {{- end }} - {{- if .Values.etcd.auth.tls.enabled }} tls: + {{- if .Values.etcd.auth.tls.enabled }} cert: "/etcd-ssl/{{ .Values.etcd.auth.tls.certFilename }}" key: "/etcd-ssl/{{ .Values.etcd.auth.tls.certKeyFilename }}" + {{- end }} verify: {{ .Values.etcd.auth.tls.verify }} {{- with .Values.etcd.auth.tls.sni }} sni: {{ . | quote }} {{- end }} - {{- end }} {{- end }} {{- end }} diff --git a/charts/apisix/values.yaml b/charts/apisix/values.yaml index 1f2156a..a5f91d1 100644 --- a/charts/apisix/values.yaml +++ b/charts/apisix/values.yaml @@ -892,7 +892,7 @@ etcd: # -- root password for etcd. Requires etcd.auth.rbac.create to be true. rootPassword: "" tls: - # -- enable etcd client certificate + # -- Enable etcd client certificates. For external etcd, select HTTPS with externalEtcd.host; verify and sni also apply when client certificates are disabled. enabled: false # -- name of the secret contains etcd client cert existingSecret: "" @@ -900,9 +900,9 @@ etcd: certFilename: "" # -- etcd client cert key filename using in etcd.auth.tls.existingSecret certKeyFilename: "" - # -- whether to verify the etcd endpoint certificate when setup a TLS connection to etcd + # -- Whether to verify the etcd endpoint certificate when setting up TLS, independently of client certificate authentication. verify: true - # -- specify the TLS Server Name Indication extension, the ETCD endpoint hostname will be used when this setting is unset. Set it to a DNS name in the etcd certificate if the etcd endpoints are IP addresses, since IP SANs are not matched. + # -- TLS server name, independently of client certificate authentication. Omitted when empty so the endpoint hostname is used. For IP endpoints, set a DNS name covered by every endpoint certificate, since IP SANs are not matched. sni: "" # -- ref: https://kubernetes.io/docs/tasks/configure-pod-container/security-context/#set-the-security-context-for-a-container diff --git a/test/render/test_etcd_tls.py b/test/render/test_etcd_tls.py new file mode 100644 index 0000000..0261ab8 --- /dev/null +++ b/test/render/test_etcd_tls.py @@ -0,0 +1,161 @@ +# 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. + +"""Render etcd TLS settings with Helm and PyYAML; no cluster is required.""" + +import subprocess +import tempfile +import unittest +from pathlib import Path + +import yaml + + +CHART = Path(__file__).resolve().parents[2] / "charts" / "apisix" + + +def external_etcd_values(client_auth=False, sni="etcd.example.test", verify=True): + return { + "apisix": { + "ssl": { + "enabled": True, + "existingCASecret": "etcd-ca", + "certCAFilename": "ca.crt", + } + }, + "etcd": { + "enabled": False, + "auth": { + "tls": { + "enabled": client_auth, + "existingSecret": "etcd-client", + "certFilename": "client.crt", + "certKeyFilename": "client.key", + "verify": verify, + "sni": sni, + } + }, + }, + "externalEtcd": {"host": ["https://192.0.2.10:2379"], "user": ""}, + } + + +def render(values): + with tempfile.TemporaryDirectory() as directory: + path = Path(directory) / "values.yaml" + path.write_text(yaml.safe_dump(values), encoding="utf-8") + result = subprocess.run( + ["helm", "template", "test-etcd", str(CHART), "-f", str(path)], + check=True, + capture_output=True, + text=True, + ) + resources = [resource for resource in yaml.safe_load_all(result.stdout) if resource] + config_map = next( + resource + for resource in resources + if resource.get("kind") == "ConfigMap" + and "config.yaml" in resource.get("data", {}) + ) + workload = next( + resource + for resource in resources + if resource.get("kind") in ("Deployment", "DaemonSet") + ) + return ( + yaml.safe_load(config_map["data"]["config.yaml"]), + workload["spec"]["template"]["spec"], + ) + + +class EtcdTLSRenderingTest(unittest.TestCase): + def assert_client_mount(self, pod, enabled): + container = next(item for item in pod["containers"] if item["name"] == "apisix") + volumes = [item for item in pod["volumes"] if item["name"] == "etcd-ssl"] + mounts = [ + item for item in container["volumeMounts"] if item["mountPath"] == "/etcd-ssl" + ] + if enabled: + self.assertEqual(len(volumes), 1) + self.assertEqual(volumes[0]["secret"]["secretName"], "etcd-client") + self.assertEqual(len(mounts), 1) + self.assertEqual(mounts[0]["name"], "etcd-ssl") + else: + self.assertEqual(volumes, []) + self.assertEqual(mounts, []) + + def test_server_only_tls_keeps_sni_and_verify_without_client_credentials(self): + for daemon_set in (False, True): + for verify in (False, True): + with self.subTest(daemon_set=daemon_set, verify=verify): + values = external_etcd_values(verify=verify) + values["useDaemonSet"] = daemon_set + config, pod = render(values) + self.assertEqual( + config["deployment"]["etcd"]["tls"], + {"verify": verify, "sni": "etcd.example.test"}, + ) + self.assertEqual( + config["apisix"]["ssl"]["ssl_trusted_certificate"], + "/usr/local/apisix/conf/ssl/ca.crt", + ) + self.assertEqual( + config["deployment"]["etcd"]["host"], + ["https://192.0.2.10:2379"], + ) + self.assert_client_mount(pod, False) + + def test_mtls_keeps_client_credentials_and_mounts(self): + for daemon_set in (False, True): + with self.subTest(daemon_set=daemon_set): + values = external_etcd_values(client_auth=True) + values["useDaemonSet"] = daemon_set + config, pod = render(values) + self.assertEqual( + config["deployment"]["etcd"]["tls"], + { + "cert": "/etcd-ssl/client.crt", + "key": "/etcd-ssl/client.key", + "verify": True, + "sni": "etcd.example.test", + }, + ) + self.assert_client_mount(pod, True) + + def test_empty_sni_is_omitted_with_or_without_client_credentials(self): + for client_auth in (False, True): + with self.subTest(client_auth=client_auth): + config, pod = render(external_etcd_values(client_auth=client_auth, sni="")) + self.assertNotIn("sni", config["deployment"]["etcd"]["tls"]) + self.assertTrue(config["deployment"]["etcd"]["tls"]["verify"]) + self.assert_client_mount(pod, client_auth) + + def test_external_http_scheme_is_preserved(self): + values = external_etcd_values(sni="") + values["externalEtcd"]["host"] = ["http://192.0.2.10:2379"] + config, pod = render(values) + self.assertEqual(config["deployment"]["etcd"]["host"], ["http://192.0.2.10:2379"]) + self.assert_client_mount(pod, False) + + def test_default_bundled_etcd_keeps_http_scheme(self): + config, pod = render({}) + self.assertTrue( + all(host.startswith("http://") for host in config["deployment"]["etcd"]["host"]) + ) + self.assert_client_mount(pod, False) + + +if __name__ == "__main__": + unittest.main()
