Repository: kubernetes-client/python (python)

Issue:
Client doesn't honor tls-server-name setting in kubeconfig
**What happened (please include outputs or screenshots)**:

Tried to connect to my cluster with a utility that uses your library. As far as I can tell, the utility's code is correct and the k8s-client python code does not honor the tls-server-name and propagate it down the stack to urllib3.



Received this exception:

```
Traceback (most recent call last):
  File "/home/nicholas/code/k8spurger-venv/lib/python3.9/site-packages/urllib3/connectionpool.
py", line 703, in urlopen
    httplib_response = self._make_request(
  File "/home/nicholas/code/k8spurger-venv/lib/python3.9/site-packages/urllib3/connectionpool.
py", line 386, in _make_request
    self._validate_conn(conn)
  File "/home/nicholas/code/k8spurger-venv/lib/python3.9/site-packages/urllib3/connectionpool.
py", line 1042, in _validate_conn
    conn.connect()
  File "/home/nicholas/code/k8spurger-venv/lib/python3.9/site-packages/urllib3/connection.py",
 line 467, in connect
    _match_hostname(cert, self.assert_hostname or server_hostname)
  File "/home/nicholas/code/k8spurger-venv/lib/python3.9/site-packages/urllib3/connection.py",
 line 540, in _match_hostname
    match_hostname(cert, asserted_hostname)
  File "/home/nicholas/code/k8spurger-venv/lib/python3.9/site-packages/urllib3/util/ssl_match_
hostname.py", line 150, in match_hostname
    raise CertificateError(
urllib3.util.ssl_match_hostname.CertificateError: hostname 'CNAME-THAT-I-USE' doesn
't match either of 'LIST', 'OF', 'OTHER', 'NAMES'
```

This is explicitly why I set tls-server-name in my kubeconfig.

**What you expected to happen**:

TLS connection to my cluster, with the proper SNI sent.

**How to reproduce it (as minimally and precisely as possible)**:

Set your cluster.server property to some IP/CNAME not in the cert. Set tls-server-name in kubeconfig correctly.

**Anything else we need to know?**:

Looks like it is pretty easy to support. Just need to change https://github.com/kubernetes-client/python/blob/master/kubernetes/base/config/kube_config.py#L544 to read the tls-server-name, and make sure that gets propagated to https://github.com/kubernetes-client/python/blob/master/kubernetes/client/rest.py#L73 as `assert_hostname`


Pull request description:
Adds support for custom Server Name Indication (SNI)
#### What type of PR is this?
/kind bug

#### What this PR does / why we need it:

This PR adds support for custom SNI values. If `tls-server-name` is set in kubeconfig, the client did not use it resulting in SSL/TLS errors if the property is required to connect to the server.

#### Which issue(s) this PR fixes:
Fixes #1889

#### Special notes for your reviewer:

#### Does this PR introduce a user-facing change?
```release-note
Fixed `tls-server-name` (SNI)  property usage when explicitly set in kubeconfig.
```

#### Additional documentation e.g., KEPs (Kubernetes Enhancement Proposals), usage docs, etc.:



Patch:
diff --git a/kubernetes/base/config/kube_config.py b/kubernetes/base/config/kube_config.py
index a62df4105..d8c63a826 100644
--- a/kubernetes/base/config/kube_config.py
+++ b/kubernetes/base/config/kube_config.py
@@ -567,6 +567,8 @@ class KubeConfigLoader(object):
                         temp_file_path=self._temp_file_path).as_file()
         if 'insecure-skip-tls-verify' in self._cluster:
             self.verify_ssl = not self._cluster['insecure-skip-tls-verify']
+        if 'tls-server-name' in self._cluster:
+            self.tls_server_name = self._cluster['tls-server-name']
 
     def _set_config(self, client_configuration):
         if 'token' in self.__dict__:
@@ -578,7 +580,7 @@ class KubeConfigLoader(object):
                 self._set_config(client_configuration)
             client_configuration.refresh_api_key_hook = _refresh_api_key
         # copy these keys directly from self to configuration object
-        keys = ['host', 'ssl_ca_cert', 'cert_file', 'key_file', 'verify_ssl']
+        keys = ['host', 'ssl_ca_cert', 'cert_file', 'key_file', 'verify_ssl','tls_server_name']
         for key in keys:
             if key in self.__dict__:
                 setattr(client_configuration, key, getattr(self, key))
diff --git a/kubernetes/base/stream/ws_client.py b/kubernetes/base/stream/ws_client.py
index a1fe80949..5ec8e7d4a 100644
--- a/kubernetes/base/stream/ws_client.py
+++ b/kubernetes/base/stream/ws_client.py
@@ -475,6 +475,8 @@ def create_websocket(configuration, url, headers=None):
         ssl_opts['certfile'] = configuration.cert_file
     if configuration.key_file:
         ssl_opts['keyfile'] = configuration.key_file
+    if configuration.tls_server_name:
+        ssl_opts['server_hostname'] = configuration.tls_server_name
 
     websocket = WebSocket(sslopt=ssl_opts, skip_utf8_validation=False)
     connect_opt = {
diff --git a/kubernetes/client/configuration.py b/kubernetes/client/configuration.py
index cd1ce61d6..51727214c 100644
--- a/kubernetes/client/configuration.py
+++ b/kubernetes/client/configuration.py
@@ -144,6 +144,10 @@ class Configuration(object):
         self.assert_hostname = None
         """Set this to True/False to enable/disable SSL hostname verification.
         """
+        self.tls_server_name = None
+        """SSL/TLS Server Name Indication (SNI)
+           Set this to the SNI value expected by Kubernetes API.
+        """
 
         self.connection_pool_maxsize = multiprocessing.cpu_count() * 5
         """urllib3 connection pool's maximum number of connections saved
diff --git a/kubernetes/client/rest.py b/kubernetes/client/rest.py
index 9c703da3b..debf32e58 100644
--- a/kubernetes/client/rest.py
+++ b/kubernetes/client/rest.py
@@ -77,6 +77,9 @@ class RESTClientObject(object):
         if configuration.retries is not None:
             addition_pool_args['retries'] = configuration.retries
 
+        if configuration.tls_server_name:
+            addition_pool_args['server_hostname'] = configuration.tls_server_name
+
         if maxsize is None:
             if configuration.connection_pool_maxsize is not None:
                 maxsize = configuration.connection_pool_maxsize
diff --git a/scripts/rest_sni_patch.diff b/scripts/rest_sni_patch.diff
new file mode 100644
index 000000000..cdd516e49
--- /dev/null
+++ b/scripts/rest_sni_patch.diff
@@ -0,0 +1,29 @@
+diff --git a/kubernetes/client/configuration.py b/kubernetes/client/configuration.py
+index 2b9dd96a50..ac5a18bf8a 100644
+--- a/kubernetes/client/configuration.py
++++ b/kubernetes/client/configuration.py
+@@ -144,6 +144,10 @@ def __init__(self, host="http://localhost",
+         self.assert_hostname = None
+         """Set this to True/False to enable/disable SSL hostname verification.
+         """
++        self.tls_server_name = None
++        """SSL/TLS Server Name Indication (SNI)
++           Set this to the SNI value expected by the server.
++        """
+
+         self.connection_pool_maxsize = multiprocessing.cpu_count() * 5
+         """urllib3 connection pool's maximum number of connections saved
+diff --git a/kubernetes/client/rest.py b/kubernetes/client/rest.py
+index 48cd2b7752..4f04251bbf 100644
+--- a/kubernetes/client/rest.py
++++ b/kubernetes/client/rest.py
+@@ -77,6 +77,9 @@ def __init__(self, configuration, pools_size=4, maxsize=None):
+         if configuration.retries is not None:
+             addition_pool_args['retries'] = configuration.retries
+
++        if configuration.tls_server_name:
++            addition_pool_args['server_hostname'] = configuration.tls_server_name
++
+         if maxsize is None:
+             if configuration.connection_pool_maxsize is not None:
+                 maxsize = configuration.connection_pool_maxsize
diff --git a/scripts/update-client.sh b/scripts/update-client.sh
index 2348f39b8..87e00ed40 100755
--- a/scripts/update-client.sh
+++ b/scripts/update-client.sh
@@ -59,7 +59,7 @@ else
 fi
 
 echo ">>> Running python generator from the gen repo"
-"${GEN_ROOT}/openapi/python.sh" "${CLIENT_ROOT}" "${SETTING_FILE}" 
+"${GEN_ROOT}/openapi/python.sh" "${CLIENT_ROOT}" "${SETTING_FILE}"
 mv "${CLIENT_ROOT}/swagger.json" "${SCRIPT_ROOT}/swagger.json"
 
 echo ">>> updating version information..."
@@ -73,6 +73,11 @@ sed -i'' "s,^DEVELOPMENT_STATUS = .*,DEVELOPMENT_STATUS = \\\"${DEVELOPMENT_STAT
 # second, this should be ported to swagger-codegen
 echo ">>> patching client..."
 git apply "${SCRIPT_ROOT}/rest_client_patch.diff"
+# The fix this patch is trying to make is already in the upstream swagger-codegen
+# repo but it's not in the version we're using. We can remove this patch
+# once we upgrade to a version of swagger-codegen that includes it (version>= 6.6.0).
+# See https://github.com/OpenAPITools/openapi-generator/pull/15283
+git apply "${SCRIPT_ROOT}/rest_sni_patch.diff"
 
 echo ">>> generating docs..."
 pushd "${DOC_ROOT}" > /dev/null


Test patch:
diff --git a/kubernetes/base/config/kube_config_test.py b/kubernetes/base/config/kube_config_test.py
index da0d2f35c..b41549203 100644
--- a/kubernetes/base/config/kube_config_test.py
+++ b/kubernetes/base/config/kube_config_test.py
@@ -102,7 +102,7 @@ TEST_CLIENT_KEY = "client-key"
 TEST_CLIENT_KEY_BASE64 = _base64(TEST_CLIENT_KEY)
 TEST_CLIENT_CERT = "client-cert"
 TEST_CLIENT_CERT_BASE64 = _base64(TEST_CLIENT_CERT)
-
+TEST_TLS_SERVER_NAME = "kubernetes.io"
 
 TEST_OIDC_TOKEN = "test-oidc-token"
 TEST_OIDC_INFO = "{\"name\": \"test\"}"
@@ -592,7 +592,14 @@ class TestKubeConfigLoader(BaseTestCase):
                     "cluster": "clustertestcmdpath",
                     "user": "usertestcmdpathscope"
                 }
-            }
+            },
+            {
+                "name": "tls-server-name",
+                "context": {
+                    "cluster": "tls-server-name",
+                    "user": "ssl"
+                }
+            },
         ],
         "clusters": [
             {
@@ -634,7 +641,17 @@ class TestKubeConfigLoader(BaseTestCase):
             {
                 "name": "clustertestcmdpath",
                 "cluster": {}
-            }
+            },
+            {
+                "name": "tls-server-name",
+                "cluster": {
+                    "server": TEST_SSL_HOST,
+                    "certificate-authority-data":
+                        TEST_CERTIFICATE_AUTH_BASE64,
+                    "insecure-skip-tls-verify": False,
+                    "tls-server-name": TEST_TLS_SERVER_NAME,
+                }
+            },
         ],
         "users": [
             {
@@ -1251,6 +1268,22 @@ class TestKubeConfigLoader(BaseTestCase):
             active_context="no_ssl_verification").load_and_set(actual)
         self.assertEqual(expected, actual)
 
+    def test_tls_server_name(self):
+        expected = FakeConfig(
+            host=TEST_SSL_HOST,
+            token=BEARER_TOKEN_FORMAT % TEST_DATA_BASE64,
+            cert_file=self._create_temp_file(TEST_CLIENT_CERT),
+            key_file=self._create_temp_file(TEST_CLIENT_KEY),
+            ssl_ca_cert=self._create_temp_file(TEST_CERTIFICATE_AUTH),
+            verify_ssl=True,
+            tls_server_name=TEST_TLS_SERVER_NAME
+        )
+        actual = FakeConfig()
+        KubeConfigLoader(
+            config_dict=self.TEST_KUBE_CONFIG,
+            active_context="tls-server-name").load_and_set(actual)
+        self.assertEqual(expected, actual)
+
     def test_list_contexts(self):
         loader = KubeConfigLoader(
             config_dict=self.TEST_KUBE_CONFIG,