Skip to content

Commit 6c710e4

Browse files
Merge pull request #2658 from emmanuel-adu/feat/proxy-ssl-context
feat: add proxy_ssl_context to Configuration for a separate proxy TLS context
2 parents 5a8b21c + f526d68 commit 6c710e4

3 files changed

Lines changed: 215 additions & 0 deletions

File tree

‎kubernetes/client/configuration.py‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111

1212

1313
import copy
14+
import ssl
1415
import http.client as httplib
1516
import logging
1617
from logging import FileHandler
@@ -173,6 +174,7 @@ class Configuration:
173174
:param proxy: Proxy URL.
174175
:param no_proxy: Comma-separated hosts that bypass the proxy.
175176
:param proxy_headers: Proxy headers.
177+
:param proxy_ssl_context: SSL context used only for the TLS handshake with the proxy itself, independent of the destination TLS settings.
176178
:param safe_chars_for_path_param: Safe characters for path parameter encoding.
177179
:param client_side_validation: Enable client-side validation. Default True.
178180
:param socket_options: Options to pass down to the underlying urllib3 socket.
@@ -228,6 +230,7 @@ def __init__(
228230
proxy: Optional[str]=None,
229231
no_proxy: Optional[str]=None,
230232
proxy_headers: Optional[Any]=None,
233+
proxy_ssl_context: Optional[ssl.SSLContext]=None,
231234
safe_chars_for_path_param: str='',
232235
client_side_validation: bool=True,
233236
socket_options: Optional[Any]=None,
@@ -351,6 +354,11 @@ def __init__(
351354
self.proxy_headers = proxy_headers
352355
"""Proxy headers
353356
"""
357+
self.proxy_ssl_context = proxy_ssl_context
358+
"""SSL context used only for the TLS handshake with
359+
the proxy itself (e.g. an HTTPS CONNECT tunnel),
360+
independent of the destination TLS settings above.
361+
"""
354362
self.safe_chars_for_path_param = safe_chars_for_path_param
355363
"""Safe chars for path_param
356364
"""

‎kubernetes/client/rest.py‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -155,6 +155,10 @@ def __init__(self, configuration) -> None:
155155
else:
156156
pool_args["proxy_url"] = configuration.proxy
157157
pool_args["proxy_headers"] = configuration.proxy_headers
158+
if configuration.proxy_ssl_context is not None:
159+
pool_args["proxy_ssl_context"] = (
160+
configuration.proxy_ssl_context
161+
)
158162
self.pool_manager = urllib3.ProxyManager(**pool_args)
159163
else:
160164
self.pool_manager = urllib3.PoolManager(**pool_args)
Lines changed: 203 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,203 @@
1+
# Licensed under the Apache License, Version 2.0 (the "License"); you may
2+
# not use this file except in compliance with the License. You may obtain
3+
# a copy of the License at
4+
#
5+
# http://www.apache.org/licenses/LICENSE-2.0
6+
#
7+
# Unless required by applicable law or agreed to in writing, software
8+
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
9+
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
10+
# License for the specific language governing permissions and limitations
11+
# under the License.
12+
13+
"""End-to-end reproduction for kubernetes-client/python#2387.
14+
15+
Spins up a mock HTTPS forward proxy and a mock HTTPS Kubernetes API server,
16+
each with their own independent self-signed certificate, matching the
17+
"HTTPS Proxy + HTTPS Destination" scenario from urllib3's advanced usage
18+
docs that the issue links to:
19+
https://urllib3.readthedocs.io/en/stable/advanced-usage.html#https-proxy-https-destination
20+
21+
Unlike the rest of this package, this test does not require a live
22+
Kubernetes cluster - it only requires openssl on PATH to generate throwaway
23+
certificates.
24+
"""
25+
26+
import shutil
27+
import socket
28+
import ssl
29+
import subprocess
30+
import tempfile
31+
import threading
32+
import unittest
33+
from pathlib import Path
34+
35+
import urllib3
36+
37+
from kubernetes import client
38+
39+
40+
def _generate_cert(cert_dir, name):
41+
subprocess.run(
42+
[
43+
"openssl", "req", "-x509", "-newkey", "rsa:2048", "-nodes",
44+
"-keyout", str(cert_dir / f"{name}.key"),
45+
"-out", str(cert_dir / f"{name}.crt"),
46+
"-days", "1", "-subj", f"/CN={name}.test",
47+
"-addext", f"subjectAltName=DNS:localhost,DNS:{name}.test,IP:127.0.0.1",
48+
],
49+
check=True, capture_output=True,
50+
)
51+
52+
53+
def _free_port():
54+
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
55+
s.bind(("127.0.0.1", 0))
56+
return s.getsockname()[1]
57+
58+
59+
def _relay(a, b):
60+
try:
61+
while True:
62+
data = a.recv(4096)
63+
if not data:
64+
break
65+
b.sendall(data)
66+
except OSError:
67+
pass
68+
finally:
69+
for s in (a, b):
70+
try:
71+
s.shutdown(socket.SHUT_RDWR)
72+
except OSError:
73+
pass
74+
75+
76+
def _run_destination_server(cert_dir, port, ready):
77+
import http.server
78+
79+
class Handler(http.server.BaseHTTPRequestHandler):
80+
def do_GET(self):
81+
self.send_response(200)
82+
self.send_header("Content-Type", "application/json")
83+
self.end_headers()
84+
self.wfile.write(
85+
b'{"major": "1", "minor": "31", "gitVersion": "v1.31.0-mock",'
86+
b'"gitCommit": "mock", "gitTreeState": "clean",'
87+
b'"buildDate": "2026-01-01T00:00:00Z", "goVersion": "go1.23",'
88+
b'"compiler": "gc", "platform": "linux/amd64"}'
89+
)
90+
91+
def log_message(self, *args):
92+
pass
93+
94+
ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
95+
ctx.load_cert_chain(f"{cert_dir}/destination.crt", f"{cert_dir}/destination.key")
96+
server = http.server.HTTPServer(("127.0.0.1", port), Handler)
97+
server.socket = ctx.wrap_socket(server.socket, server_side=True)
98+
ready.set()
99+
server.serve_forever()
100+
101+
102+
def _run_proxy_server(cert_dir, port, ready):
103+
def handle(conn):
104+
request_line = b""
105+
while not request_line.endswith(b"\r\n\r\n"):
106+
chunk = conn.recv(1)
107+
if not chunk:
108+
return
109+
request_line += chunk
110+
method, target, _ = request_line.split(b"\r\n", 1)[0].decode().split(" ")
111+
if method != "CONNECT":
112+
conn.sendall(b"HTTP/1.1 405 Method Not Allowed\r\n\r\n")
113+
conn.close()
114+
return
115+
host, port_str = target.split(":")
116+
upstream = socket.create_connection((host, int(port_str)), timeout=5)
117+
conn.sendall(b"HTTP/1.1 200 Connection Established\r\n\r\n")
118+
threading.Thread(target=_relay, args=(conn, upstream), daemon=True).start()
119+
threading.Thread(target=_relay, args=(upstream, conn), daemon=True).start()
120+
121+
def accept_and_handshake(raw_conn, ctx):
122+
try:
123+
tls_conn = ctx.wrap_socket(raw_conn, server_side=True)
124+
except ssl.SSLError:
125+
return # client rejected our cert - expected without proxy_ssl_context
126+
handle(tls_conn)
127+
128+
ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
129+
ctx.load_cert_chain(f"{cert_dir}/proxy.crt", f"{cert_dir}/proxy.key")
130+
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
131+
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
132+
sock.bind(("127.0.0.1", port))
133+
sock.listen(5)
134+
ready.set()
135+
while True:
136+
raw_conn, _ = sock.accept()
137+
threading.Thread(
138+
target=accept_and_handshake, args=(raw_conn, ctx), daemon=True
139+
).start()
140+
141+
142+
@unittest.skipUnless(shutil.which("openssl"), "requires openssl on PATH")
143+
class TestProxySslContext(unittest.TestCase):
144+
"""Regression coverage for #2387: proxy_ssl_context lets the client
145+
trust an HTTPS proxy independently of the destination TLS settings."""
146+
147+
@classmethod
148+
def setUpClass(cls):
149+
cls.cert_dir = Path(tempfile.mkdtemp())
150+
for name in ("proxy", "destination"):
151+
_generate_cert(cls.cert_dir, name)
152+
153+
cls.dest_port = _free_port()
154+
cls.proxy_port = _free_port()
155+
156+
dest_ready = threading.Event()
157+
proxy_ready = threading.Event()
158+
threading.Thread(
159+
target=_run_destination_server,
160+
args=(cls.cert_dir, cls.dest_port, dest_ready), daemon=True,
161+
).start()
162+
threading.Thread(
163+
target=_run_proxy_server,
164+
args=(cls.cert_dir, cls.proxy_port, proxy_ready), daemon=True,
165+
).start()
166+
dest_ready.wait(timeout=5)
167+
proxy_ready.wait(timeout=5)
168+
169+
@classmethod
170+
def tearDownClass(cls):
171+
shutil.rmtree(cls.cert_dir, ignore_errors=True)
172+
173+
def _make_config(self):
174+
config = client.Configuration()
175+
config.host = f"https://127.0.0.1:{self.dest_port}"
176+
config.verify_ssl = True
177+
config.ssl_ca_cert = f"{self.cert_dir}/destination.crt"
178+
config.proxy = f"https://127.0.0.1:{self.proxy_port}"
179+
return config
180+
181+
def test_without_proxy_ssl_context_fails_when_proxy_ca_differs(self):
182+
config = self._make_config()
183+
api_client = client.ApiClient(config)
184+
version_api = client.VersionApi(api_client)
185+
186+
with self.assertRaises(urllib3.exceptions.MaxRetryError) as ctx:
187+
version_api.get_code()
188+
self.assertIn("CERTIFICATE_VERIFY_FAILED", str(ctx.exception))
189+
190+
def test_with_proxy_ssl_context_succeeds_when_proxy_ca_differs(self):
191+
config = self._make_config()
192+
config.proxy_ssl_context = ssl.create_default_context(
193+
cafile=f"{self.cert_dir}/proxy.crt"
194+
)
195+
api_client = client.ApiClient(config)
196+
version_api = client.VersionApi(api_client)
197+
198+
version = version_api.get_code()
199+
self.assertEqual(version.git_version, "v1.31.0-mock")
200+
201+
202+
if __name__ == "__main__":
203+
unittest.main()

0 commit comments

Comments
 (0)