|
| 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