Skip to content

Commit e47e5f5

Browse files
committed
Add cert generation script and harden sample client
generate-certs.sh creates the certs/ directory with all certificates needed to run the proxy locally. README quick-start section documents the workflow. Sample client hardened against livelock (bounded fast-recv loops), SSLWantWriteError during handshake/send/recv, and unexpected ConnectionTerminated frames from the proxy.
1 parent 4fcd2b7 commit e47e5f5

3 files changed

Lines changed: 153 additions & 9 deletions

File tree

README.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,16 @@ The proxy accepts incoming mTLS connections, extracts a custom extension value f
1010
cargo build --release
1111
```
1212

13+
## Quick start
14+
15+
```bash
16+
./examples/generate-certs.sh # creates certs/ directory
17+
cp config.example.toml config.toml # edit to taste
18+
cargo run -- --config config.toml
19+
```
20+
21+
The generated filenames match `config.example.toml` so no editing is needed for local development. Pass a custom extension value as an argument: `./examples/generate-certs.sh agent-beta`.
22+
1323
## Configuration
1424

1525
Copy `config.example.toml` to `config.toml` and edit it. Key sections:

examples/generate-certs.sh

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
#!/usr/bin/env bash
2+
# Generate a self-signed CA, proxy server cert, and client cert for local
3+
# development. Writes PEM files to a certs/ directory (created if absent).
4+
#
5+
# Usage:
6+
# ./examples/generate-certs.sh # extension_value defaults to "agent-alpha"
7+
# ./examples/generate-certs.sh agent-beta # custom extension value
8+
#
9+
# The client cert contains a custom X.509 extension at OID 1.3.6.1.4.1.57264.1.1
10+
# with the given value encoded as a DER UTF8String.
11+
12+
set -euo pipefail
13+
14+
EXT_VALUE="${1:-agent-alpha}"
15+
DIR="certs"
16+
17+
mkdir -p "$DIR"
18+
19+
der_utf8string() {
20+
local val="$1"
21+
local len=${#val}
22+
local result
23+
result=$(printf '0c:%02x' "$len")
24+
for (( i=0; i<len; i++ )); do
25+
result=$(printf '%s:%02x' "$result" "'${val:$i:1}")
26+
done
27+
printf '%s' "$result"
28+
}
29+
30+
echo "==> Generating CA"
31+
openssl req -x509 -newkey ec -pkeyopt ec_paramgen_curve:prime256v1 \
32+
-keyout "$DIR/client-ca-key.pem" -out "$DIR/client-ca.pem" \
33+
-days 365 -nodes -subj "/CN=agent-gateway CA" 2>/dev/null
34+
35+
echo "==> Generating proxy server cert (localhost / 127.0.0.1)"
36+
openssl req -newkey ec -pkeyopt ec_paramgen_curve:prime256v1 \
37+
-keyout "$DIR/proxy-key.pem" -out "$DIR/proxy.csr" \
38+
-nodes -subj "/CN=localhost" 2>/dev/null
39+
openssl x509 -req -in "$DIR/proxy.csr" \
40+
-CA "$DIR/client-ca.pem" -CAkey "$DIR/client-ca-key.pem" -CAcreateserial \
41+
-out "$DIR/proxy.pem" -days 365 \
42+
-extfile <(printf 'subjectAltName=DNS:localhost,IP:127.0.0.1') 2>/dev/null
43+
rm -f "$DIR/proxy.csr"
44+
45+
echo "==> Generating client cert (extension_value=$EXT_VALUE)"
46+
DER_HEX=$(der_utf8string "$EXT_VALUE")
47+
openssl req -newkey ec -pkeyopt ec_paramgen_curve:prime256v1 \
48+
-keyout "$DIR/client-key.pem" -out "$DIR/client.csr" \
49+
-nodes -subj "/CN=agent-client" 2>/dev/null
50+
openssl x509 -req -in "$DIR/client.csr" \
51+
-CA "$DIR/client-ca.pem" -CAkey "$DIR/client-ca-key.pem" -CAcreateserial \
52+
-out "$DIR/client.pem" -days 365 \
53+
-extfile <(printf '1.3.6.1.4.1.57264.1.1=DER:%s' "$DER_HEX") 2>/dev/null
54+
rm -f "$DIR/client.csr" "$DIR/client-ca.srl"
55+
56+
echo ""
57+
echo "Generated in $DIR/:"
58+
ls -1 "$DIR"
59+
echo ""
60+
echo "Client extension: OID 1.3.6.1.4.1.57264.1.1 = \"$EXT_VALUE\""

examples/sample_client.py

Lines changed: 83 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -84,8 +84,24 @@ def send_connect_request(sock, destination):
8484
}
8585

8686

87+
def _ssl_pending(sock):
88+
"""Bytes already decrypted and waiting in the OpenSSL read buffer (may be non-empty)."""
89+
return sock.pending() if isinstance(sock, ssl.SSLSocket) else 0
90+
91+
8792
def wait_for_response(sock, conn, stream_id):
93+
"""Wait for CONNECT response.
94+
95+
Uses SSLSocket.pending(): we only count fast recv iterations (draining the OpenSSL read
96+
buffer without blocking on the network) toward a livelock limit.
97+
"""
98+
fast = 0
99+
max_fast = 100_000
88100
while True:
101+
pending_before = _ssl_pending(sock)
102+
if pending_before == 0:
103+
fast = 0
104+
89105
data = sock.recv(65535)
90106
if not data:
91107
raise ConnectionError("Proxy closed the connection before responding")
@@ -94,6 +110,10 @@ def wait_for_response(sock, conn, stream_id):
94110
sock.sendall(conn.data_to_send())
95111

96112
for event in events:
113+
if isinstance(event, h2.events.ConnectionTerminated):
114+
raise ConnectionError(
115+
f"Proxy closed HTTP/2 connection: error_code={event.error_code!r}"
116+
)
97117
if isinstance(event, h2.events.ResponseReceived) and event.stream_id == stream_id:
98118
headers = dict(event.headers)
99119
status = int(headers[":status"])
@@ -102,25 +122,36 @@ def wait_for_response(sock, conn, stream_id):
102122
if isinstance(event, h2.events.StreamReset):
103123
raise ConnectionError(f"Stream reset by proxy: error code {event.error_code}")
104124

125+
if pending_before > 0:
126+
fast += 1
127+
if fast >= max_fast:
128+
raise ConnectionError(
129+
"Timed out waiting for CONNECT response (excessive HTTP/2 traffic without response)"
130+
)
131+
105132

106133
class H2Tunnel:
107134
"""Bidirectional byte pipe over an HTTP/2 DATA stream."""
108135

136+
# If we keep receiving TLS/plaintext without ever getting DATA for this stream (or EOF),
137+
# something is wrong — bail out instead of burning CPU forever.
138+
_MAX_FAST_RECV_WITHOUT_APP_DATA = 100_000
139+
109140
def __init__(self, sock, h2_conn, stream_id):
110141
self._sock = sock
111142
self._conn = h2_conn
112143
self._sid = stream_id
113144
self._buffer = b""
114145
self._eof = False
115146

116-
def _read_from_tunnel(self):
117-
raw = self._sock.recv(65535)
118-
if not raw:
119-
self._eof = True
120-
return
147+
def _process_raw(self, raw):
121148
events = self._conn.receive_data(raw)
122149
self._sock.sendall(self._conn.data_to_send())
123150
for ev in events:
151+
if isinstance(ev, h2.events.ConnectionTerminated):
152+
raise ConnectionError(
153+
f"Proxy closed HTTP/2 connection: error_code={ev.error_code!r}"
154+
)
124155
if isinstance(ev, h2.events.DataReceived) and ev.stream_id == self._sid:
125156
self._conn.acknowledge_received_data(ev.flow_controlled_length, ev.stream_id)
126157
self._sock.sendall(self._conn.data_to_send())
@@ -129,8 +160,26 @@ def _read_from_tunnel(self):
129160
self._eof = True
130161

131162
def recv(self, bufsize):
163+
fast = 0
132164
while not self._buffer and not self._eof:
133-
self._read_from_tunnel()
165+
pending_before = _ssl_pending(self._sock)
166+
if pending_before == 0:
167+
fast = 0
168+
169+
raw = self._sock.recv(65535)
170+
if not raw:
171+
self._eof = True
172+
break
173+
174+
self._process_raw(raw)
175+
176+
if not self._buffer and not self._eof and pending_before > 0:
177+
fast += 1
178+
if fast >= self._MAX_FAST_RECV_WITHOUT_APP_DATA:
179+
raise ConnectionError(
180+
"HTTP/2 tunnel livelock: received many frames but no payload for CONNECT stream"
181+
)
182+
134183
if not self._buffer:
135184
return b""
136185
out = self._buffer[:bufsize]
@@ -167,6 +216,10 @@ def tunnel_tls_handshake(tunnel, dest_host, dest_ca=None):
167216
if not data:
168217
raise ConnectionError(f"tunnel closed during TLS handshake with {dest_host}")
169218
incoming_bio.write(data)
219+
except ssl.SSLWantWriteError:
220+
out = outgoing_bio.read()
221+
if out:
222+
tunnel.send(out)
170223

171224
out = outgoing_bio.read()
172225
if out:
@@ -175,8 +228,25 @@ def tunnel_tls_handshake(tunnel, dest_host, dest_ca=None):
175228
return ssl_obj, incoming_bio, outgoing_bio
176229

177230

178-
def ssl_send(ssl_obj, outgoing_bio, tunnel, data):
179-
ssl_obj.write(data)
231+
def ssl_send(ssl_obj, incoming_bio, outgoing_bio, tunnel, data):
232+
view = memoryview(data)
233+
while len(view):
234+
try:
235+
n = ssl_obj.write(view)
236+
view = view[n:]
237+
except ssl.SSLWantReadError:
238+
out = outgoing_bio.read()
239+
if out:
240+
tunnel.send(out)
241+
chunk = tunnel.recv(65535)
242+
if not chunk:
243+
raise ConnectionError("tunnel closed while sending to destination TLS")
244+
incoming_bio.write(chunk)
245+
except ssl.SSLWantWriteError:
246+
out = outgoing_bio.read()
247+
if out:
248+
tunnel.send(out)
249+
180250
out = outgoing_bio.read()
181251
if out:
182252
tunnel.send(out)
@@ -200,6 +270,10 @@ def ssl_recv(ssl_obj, incoming_bio, outgoing_bio, tunnel, bufsize=4096):
200270
if not data:
201271
break
202272
incoming_bio.write(data)
273+
except ssl.SSLWantWriteError:
274+
out = outgoing_bio.read()
275+
if out:
276+
tunnel.send(out)
203277
except ssl.SSLZeroReturnError:
204278
break
205279
return b"".join(chunks)
@@ -212,7 +286,7 @@ def send_get_request(ssl_obj, incoming_bio, outgoing_bio, tunnel, host):
212286
f"Connection: close\r\n"
213287
f"\r\n"
214288
).encode()
215-
ssl_send(ssl_obj, outgoing_bio, tunnel, request)
289+
ssl_send(ssl_obj, incoming_bio, outgoing_bio, tunnel, request)
216290

217291
response_parts = []
218292
while True:

0 commit comments

Comments
 (0)