Skip to content

Commit 4fcd2b7

Browse files
committed
Add sample Python client and destination server
Tested end-to-end: client connects to proxy over mTLS h2, sends CONNECT, tunnels TLS to the destination, and receives an echoed JSON response. The client uses h2 + ssl.MemoryBIO to perform TLS-over-h2-tunnel (validate_outbound_headers disabled to work around h2's incorrect CONNECT pseudo-header validation).
1 parent 015e1f2 commit 4fcd2b7

3 files changed

Lines changed: 396 additions & 1 deletion

File tree

.gitignore

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,3 @@
11
/target
22
.cursor/
3-
INBOX.md
3+
__pycache__/

examples/sample_client.py

Lines changed: 292 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,292 @@
1+
#!/usr/bin/env python3
2+
"""
3+
Sample client for the agent_gateway mTLS HTTP/2 CONNECT proxy.
4+
5+
Demonstrates:
6+
- Establishing an mTLS connection to the proxy with ALPN h2
7+
- Sending an HTTP/2 CONNECT request to tunnel to a destination
8+
- Performing a TLS handshake to the destination through the tunnel
9+
- Sending a simple HTTP/1.1 GET request through the tunnel
10+
11+
Requirements:
12+
pip install h2
13+
14+
Usage:
15+
python sample_client.py \\
16+
--proxy-host 127.0.0.1 \\
17+
--proxy-port 8443 \\
18+
--client-cert certs/client.pem \\
19+
--client-key certs/client-key.pem \\
20+
--ca-cert certs/proxy-ca.pem \\
21+
--destination api.example.com:443 \\
22+
--dest-ca certs/dest-ca.pem # optional, uses system CAs if omitted
23+
"""
24+
25+
import argparse
26+
import socket
27+
import ssl
28+
import sys
29+
30+
import h2.connection
31+
import h2.config
32+
import h2.events
33+
34+
35+
def create_proxy_ssl_context(client_cert, client_key, ca_cert):
36+
ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
37+
ctx.load_cert_chain(certfile=client_cert, keyfile=client_key)
38+
ctx.load_verify_locations(cafile=ca_cert)
39+
ctx.set_alpn_protocols(["h2"])
40+
ctx.minimum_version = ssl.TLSVersion.TLSv1_2
41+
return ctx
42+
43+
44+
def connect_to_proxy(host, port, ssl_ctx):
45+
raw = socket.create_connection((host, port))
46+
tls_sock = ssl_ctx.wrap_socket(raw, server_hostname=host)
47+
48+
negotiated = tls_sock.selected_alpn_protocol()
49+
if negotiated != "h2":
50+
tls_sock.close()
51+
raise RuntimeError(f"ALPN negotiation failed: got {negotiated!r}, expected 'h2'")
52+
53+
return tls_sock
54+
55+
56+
def send_connect_request(sock, destination):
57+
config = h2.config.H2Configuration(
58+
client_side=True,
59+
header_encoding="utf-8",
60+
# h2 incorrectly requires :path/:scheme for CONNECT requests (RFC 7540 §8.3
61+
# says they MUST NOT be present). Disable outbound validation to work around this.
62+
validate_outbound_headers=False,
63+
)
64+
conn = h2.connection.H2Connection(config=config)
65+
conn.initiate_connection()
66+
sock.sendall(conn.data_to_send())
67+
68+
headers = [
69+
(":method", "CONNECT"),
70+
(":authority", destination),
71+
]
72+
stream_id = conn.get_next_available_stream_id()
73+
conn.send_headers(stream_id, headers)
74+
sock.sendall(conn.data_to_send())
75+
76+
return conn, stream_id
77+
78+
79+
RESPONSE_MESSAGES = {
80+
400: "Malformed request (missing or invalid authority)",
81+
403: "Policy denied the connection",
82+
405: "Non-CONNECT method used",
83+
502: "Could not reach the destination",
84+
}
85+
86+
87+
def wait_for_response(sock, conn, stream_id):
88+
while True:
89+
data = sock.recv(65535)
90+
if not data:
91+
raise ConnectionError("Proxy closed the connection before responding")
92+
93+
events = conn.receive_data(data)
94+
sock.sendall(conn.data_to_send())
95+
96+
for event in events:
97+
if isinstance(event, h2.events.ResponseReceived) and event.stream_id == stream_id:
98+
headers = dict(event.headers)
99+
status = int(headers[":status"])
100+
return status
101+
102+
if isinstance(event, h2.events.StreamReset):
103+
raise ConnectionError(f"Stream reset by proxy: error code {event.error_code}")
104+
105+
106+
class H2Tunnel:
107+
"""Bidirectional byte pipe over an HTTP/2 DATA stream."""
108+
109+
def __init__(self, sock, h2_conn, stream_id):
110+
self._sock = sock
111+
self._conn = h2_conn
112+
self._sid = stream_id
113+
self._buffer = b""
114+
self._eof = False
115+
116+
def _read_from_tunnel(self):
117+
raw = self._sock.recv(65535)
118+
if not raw:
119+
self._eof = True
120+
return
121+
events = self._conn.receive_data(raw)
122+
self._sock.sendall(self._conn.data_to_send())
123+
for ev in events:
124+
if isinstance(ev, h2.events.DataReceived) and ev.stream_id == self._sid:
125+
self._conn.acknowledge_received_data(ev.flow_controlled_length, ev.stream_id)
126+
self._sock.sendall(self._conn.data_to_send())
127+
self._buffer += ev.data
128+
elif isinstance(ev, (h2.events.StreamEnded, h2.events.StreamReset)) and ev.stream_id == self._sid:
129+
self._eof = True
130+
131+
def recv(self, bufsize):
132+
while not self._buffer and not self._eof:
133+
self._read_from_tunnel()
134+
if not self._buffer:
135+
return b""
136+
out = self._buffer[:bufsize]
137+
self._buffer = self._buffer[bufsize:]
138+
return out
139+
140+
def send(self, data):
141+
self._conn.send_data(self._sid, data)
142+
self._sock.sendall(self._conn.data_to_send())
143+
return len(data)
144+
145+
146+
def tunnel_tls_handshake(tunnel, dest_host, dest_ca=None):
147+
"""Perform a TLS handshake over the HTTP/2 tunnel using MemoryBIO."""
148+
dest_ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
149+
if dest_ca:
150+
dest_ctx.load_verify_locations(cafile=dest_ca)
151+
else:
152+
dest_ctx.load_default_certs()
153+
154+
incoming_bio = ssl.MemoryBIO()
155+
outgoing_bio = ssl.MemoryBIO()
156+
ssl_obj = dest_ctx.wrap_bio(incoming_bio, outgoing_bio, server_hostname=dest_host)
157+
158+
while True:
159+
try:
160+
ssl_obj.do_handshake()
161+
break
162+
except ssl.SSLWantReadError:
163+
out = outgoing_bio.read()
164+
if out:
165+
tunnel.send(out)
166+
data = tunnel.recv(65535)
167+
if not data:
168+
raise ConnectionError(f"tunnel closed during TLS handshake with {dest_host}")
169+
incoming_bio.write(data)
170+
171+
out = outgoing_bio.read()
172+
if out:
173+
tunnel.send(out)
174+
175+
return ssl_obj, incoming_bio, outgoing_bio
176+
177+
178+
def ssl_send(ssl_obj, outgoing_bio, tunnel, data):
179+
ssl_obj.write(data)
180+
out = outgoing_bio.read()
181+
if out:
182+
tunnel.send(out)
183+
184+
185+
def ssl_recv(ssl_obj, incoming_bio, outgoing_bio, tunnel, bufsize=4096):
186+
chunks = []
187+
while True:
188+
try:
189+
chunk = ssl_obj.read(bufsize)
190+
if not chunk:
191+
break
192+
chunks.append(chunk)
193+
except ssl.SSLWantReadError:
194+
out = outgoing_bio.read()
195+
if out:
196+
tunnel.send(out)
197+
if chunks:
198+
break
199+
data = tunnel.recv(65535)
200+
if not data:
201+
break
202+
incoming_bio.write(data)
203+
except ssl.SSLZeroReturnError:
204+
break
205+
return b"".join(chunks)
206+
207+
208+
def send_get_request(ssl_obj, incoming_bio, outgoing_bio, tunnel, host):
209+
request = (
210+
f"GET / HTTP/1.1\r\n"
211+
f"Host: {host}\r\n"
212+
f"Connection: close\r\n"
213+
f"\r\n"
214+
).encode()
215+
ssl_send(ssl_obj, outgoing_bio, tunnel, request)
216+
217+
response_parts = []
218+
while True:
219+
chunk = ssl_recv(ssl_obj, incoming_bio, outgoing_bio, tunnel)
220+
if not chunk:
221+
break
222+
response_parts.append(chunk)
223+
224+
return b"".join(response_parts).decode("utf-8", errors="replace")
225+
226+
227+
def parse_destination(dest):
228+
if dest.startswith("["):
229+
close = dest.find("]")
230+
if close == -1:
231+
raise ValueError(f"invalid IPv6 destination (missing ']'): {dest}")
232+
host = dest[1:close]
233+
rest = dest[close + 1:]
234+
if rest == "":
235+
return host, 443
236+
if rest.startswith(":"):
237+
return host, int(rest[1:])
238+
raise ValueError(f"invalid destination after IPv6 host: {dest}")
239+
if ":" in dest:
240+
host, port_str = dest.rsplit(":", 1)
241+
return host, int(port_str)
242+
return dest, 443
243+
244+
245+
def main():
246+
parser = argparse.ArgumentParser(description="Sample client for agent_gateway mTLS HTTP/2 CONNECT proxy")
247+
parser.add_argument("--proxy-host", required=True, help="Proxy hostname or IP")
248+
parser.add_argument("--proxy-port", type=int, required=True, help="Proxy port")
249+
parser.add_argument("--client-cert", required=True, help="Path to client certificate (PEM)")
250+
parser.add_argument("--client-key", required=True, help="Path to client private key (PEM)")
251+
parser.add_argument("--ca-cert", required=True, help="Path to CA certificate that signed the proxy's cert")
252+
parser.add_argument("--destination", required=True, help="Destination host:port to tunnel to (port defaults to 443)")
253+
parser.add_argument("--dest-ca", default=None, help="Path to CA certificate for the destination (uses system CAs if omitted)")
254+
args = parser.parse_args()
255+
256+
dest_host, dest_port = parse_destination(args.destination)
257+
destination = f"{dest_host}:{dest_port}"
258+
259+
ssl_ctx = create_proxy_ssl_context(args.client_cert, args.client_key, args.ca_cert)
260+
261+
print(f"Connecting to proxy at {args.proxy_host}:{args.proxy_port} ...")
262+
proxy_sock = connect_to_proxy(args.proxy_host, args.proxy_port, ssl_ctx)
263+
print(f"mTLS connection established (ALPN: {proxy_sock.selected_alpn_protocol()})")
264+
265+
print(f"Sending CONNECT request for {destination} ...")
266+
conn, stream_id = send_connect_request(proxy_sock, destination)
267+
268+
status = wait_for_response(proxy_sock, conn, stream_id)
269+
if status != 200:
270+
msg = RESPONSE_MESSAGES.get(status, "Unknown error")
271+
print(f"CONNECT failed: {status}{msg}", file=sys.stderr)
272+
proxy_sock.close()
273+
sys.exit(1)
274+
275+
print(f"Tunnel established (HTTP {status})")
276+
277+
tunnel = H2Tunnel(proxy_sock, conn, stream_id)
278+
279+
print(f"Performing TLS handshake with {dest_host} through tunnel ...")
280+
ssl_obj, in_bio, out_bio = tunnel_tls_handshake(tunnel, dest_host, args.dest_ca)
281+
print(f"Destination TLS established (protocol: {ssl_obj.version()})")
282+
283+
print(f"Sending GET / to {dest_host} ...")
284+
response = send_get_request(ssl_obj, in_bio, out_bio, tunnel, dest_host)
285+
print("--- Response ---")
286+
print(response)
287+
288+
proxy_sock.close()
289+
290+
291+
if __name__ == "__main__":
292+
main()

0 commit comments

Comments
 (0)