Viewing files at commit d3edd8c5e361Commit detailsView latest

scripts/oakbench/httpcount.py 473 lines · 15.3 KB · python
1
"""Plaintext-to-TLS HTTP/1.1 forwarding proxy that counts requests and bytes.
2
3
Point a client at ``http://127.0.0.1:PORT`` and every request is forwarded to
4
``https://UPSTREAM`` over TLS, with the ``Host`` header rewritten. The proxy
5
records one entry per HTTP request: method, path (query string dropped so no
6
token-bearing query parameters are ever logged), status, request/response
7
bytes (HTTP/1.1 framing: request line + headers + body), and timing.
8
9
Request/response headers are never recorded. An optional ``inject_headers``
10
map lets a caller add headers (e.g. auth for a proxied Git remote) without
11
putting credentials on a command line; injected values are never logged.
12
13
Counts are HTTP/1.1 plaintext bytes. A direct HTTPS client may negotiate HTTP/2
14
(HPACK-compressed headers, multiplexing), so proxied *timing* is diagnostic;
15
request counts and body bytes are the instrument's product.
16
17
Stdlib only (ADR-0001).
18
"""
19
20
from __future__ import annotations
21
22
import socket
23
import ssl
24
import threading
25
import time
26
from dataclasses import dataclass, field, asdict
27
from typing import Optional
28
29
30
@dataclass
31
class RequestRecord:
32
    method: str
33
    path: str
34
    status: Optional[int]
35
    request_bytes: int
36
    response_bytes: int
37
    start_ms: float
38
    ttfb_ms: Optional[float]
39
    end_ms: Optional[float]
40
    connection_id: int
41
    error: Optional[str] = None
42
43
    def to_dict(self) -> dict:
44
        return asdict(self)
45
46
47
@dataclass
48
class TunnelRecord:
49
    """A CONNECT tunnel (opaque TLS): bytes and host only, no request count."""
50
51
    host: str
52
    port: int
53
    client_to_upstream_bytes: int
54
    upstream_to_client_bytes: int
55
    start_ms: float
56
    end_ms: Optional[float]
57
58
    def to_dict(self) -> dict:
59
        return asdict(self)
60
61
62
@dataclass
63
class HttpCountStats:
64
    requests: int
65
    connections: int
66
    request_bytes: int
67
    response_bytes: int
68
    records: list = field(default_factory=list)
69
    tunnels: list = field(default_factory=list)
70
71
    @property
72
    def tunnel_bytes(self) -> int:
73
        return sum(t.client_to_upstream_bytes + t.upstream_to_client_bytes for t in self.tunnels)
74
75
    def tunnel_hosts(self) -> dict:
76
        out: dict = {}
77
        for t in self.tunnels:
78
            h = out.setdefault(t.host, {"connections": 0, "up_bytes": 0, "down_bytes": 0})
79
            h["connections"] += 1
80
            h["up_bytes"] += t.client_to_upstream_bytes
81
            h["down_bytes"] += t.upstream_to_client_bytes
82
        return out
83
84
    @property
85
    def total_bytes(self) -> int:
86
        return self.request_bytes + self.response_bytes
87
88
    def by_route(self) -> dict:
89
        """Histogram of ``METHOD /path-shape`` -> count (hex ids collapsed)."""
90
        out: dict = {}
91
        for r in self.records:
92
            key = f"{r.method} {route_shape(r.path)}"
93
            out[key] = out.get(key, 0) + 1
94
        return dict(sorted(out.items(), key=lambda kv: (-kv[1], kv[0])))
95
96
97
def route_shape(path: str) -> str:
98
    """Collapse long hex/numeric path segments so routes aggregate."""
99
    parts = []
100
    for seg in path.split("/"):
101
        if len(seg) >= 12 and all(c in "0123456789abcdefABCDEF" for c in seg):
102
            parts.append(":hash")
103
        elif seg.isdigit():
104
            parts.append(":n")
105
        else:
106
            parts.append(seg)
107
    return "/".join(parts)
108
109
110
class _Reader:
111
    """Buffered reader over a socket that counts consumed bytes."""
112
113
    def __init__(self, sock: socket.socket) -> None:
114
        self.sock = sock
115
        self.buf = b""
116
117
    def _fill(self) -> bool:
118
        chunk = self.sock.recv(256 * 1024)
119
        if not chunk:
120
            return False
121
        self.buf += chunk
122
        return True
123
124
    def readline(self) -> bytes:
125
        while b"\r\n" not in self.buf:
126
            if not self._fill():
127
                out, self.buf = self.buf, b""
128
                return out
129
        idx = self.buf.index(b"\r\n") + 2
130
        out, self.buf = self.buf[:idx], self.buf[idx:]
131
        return out
132
133
    def read_some(self, limit: int) -> bytes:
134
        if not self.buf:
135
            if not self._fill():
136
                return b""
137
        out, self.buf = self.buf[:limit], self.buf[limit:]
138
        return out
139
140
    def read_head(self) -> Optional[list]:
141
        lines = []
142
        while True:
143
            line = self.readline()
144
            if not line:
145
                return None if not lines else lines
146
            if line == b"\r\n":
147
                return lines
148
            lines.append(line)
149
150
151
def _header(lines: list, name: bytes) -> Optional[bytes]:
152
    lname = name.lower()
153
    for line in lines[1:]:
154
        k, _, v = line.partition(b":")
155
        if k.strip().lower() == lname:
156
            return v.strip()
157
    return None
158
159
160
class HttpCountingProxy:
161
    def __init__(
162
        self,
163
        upstream_host: str,
164
        *,
165
        upstream_port: int = 443,
166
        listen_host: str = "127.0.0.1",
167
        listen_port: int = 0,
168
        inject_headers: Optional[dict] = None,
169
        use_tls: bool = True,
170
    ) -> None:
171
        self.upstream_host = upstream_host
172
        self.upstream_port = upstream_port
173
        self.listen_host = listen_host
174
        self.listen_port = listen_port
175
        self.inject_headers = dict(inject_headers or {})
176
        self.use_tls = use_tls
177
        self._ctx = ssl.create_default_context()
178
        self._ctx.set_alpn_protocols(["http/1.1"])
179
        self._lock = threading.Lock()
180
        self._stop = threading.Event()
181
        self._listener: Optional[socket.socket] = None
182
        self._threads: list = []
183
        self._socks: set = set()
184
        self._records: list = []
185
        self._tunnels: list = []
186
        self._connections = 0
187
        self._t0 = time.monotonic()
188
189
    # -- lifecycle -----------------------------------------------------
190
    @property
191
    def address(self) -> tuple:
192
        assert self._listener is not None
193
        h, p = self._listener.getsockname()[:2]
194
        return str(h), int(p)
195
196
    @property
197
    def url(self) -> str:
198
        h, p = self.address
199
        return f"http://{h}:{p}"
200
201
    def start(self) -> "HttpCountingProxy":
202
        s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
203
        s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
204
        s.bind((self.listen_host, self.listen_port))
205
        s.listen(64)
206
        s.settimeout(0.2)
207
        self._listener = s
208
        t = threading.Thread(target=self._accept, daemon=True)
209
        t.start()
210
        self._threads.append(t)
211
        return self
212
213
    def stop(self) -> None:
214
        self._stop.set()
215
        if self._listener is not None:
216
            try:
217
                self._listener.close()
218
            except OSError:
219
                pass
220
        with self._lock:
221
            socks = list(self._socks)
222
        for s in socks:
223
            _close(s)
224
225
    def __enter__(self):
226
        return self.start()
227
228
    def __exit__(self, *exc):
229
        self.stop()
230
231
    def reset(self) -> None:
232
        with self._lock:
233
            self._records = []
234
            self._tunnels = []
235
            self._connections = 0
236
            self._t0 = time.monotonic()
237
238
    def snapshot(self) -> HttpCountStats:
239
        with self._lock:
240
            recs = list(self._records)
241
            tuns = list(self._tunnels)
242
            conns = self._connections
243
        return HttpCountStats(
244
            requests=len(recs),
245
            connections=conns,
246
            request_bytes=sum(r.request_bytes for r in recs),
247
            response_bytes=sum(r.response_bytes for r in recs),
248
            records=recs,
249
            tunnels=tuns,
250
        )
251
252
    # -- internals -----------------------------------------------------
253
    def _now_ms(self) -> float:
254
        return (time.monotonic() - self._t0) * 1000.0
255
256
    def _accept(self) -> None:
257
        assert self._listener is not None
258
        while not self._stop.is_set():
259
            try:
260
                c, _ = self._listener.accept()
261
            except socket.timeout:
262
                continue
263
            except OSError:
264
                break
265
            t = threading.Thread(target=self._serve, args=(c,), daemon=True)
266
            with self._lock:
267
                self._socks.add(c)
268
                self._threads.append(t)
269
            t.start()
270
271
    def _connect_upstream(self) -> socket.socket:
272
        raw = socket.create_connection((self.upstream_host, self.upstream_port), timeout=60)
273
        raw.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1)
274
        if not self.use_tls:
275
            return raw
276
        return self._ctx.wrap_socket(raw, server_hostname=self.upstream_host)
277
278
    def _serve(self, client: socket.socket) -> None:
279
        client.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1)
280
        up: Optional[socket.socket] = None
281
        with self._lock:
282
            self._connections += 1
283
            conn_id = self._connections
284
        cr = _Reader(client)
285
        ur: Optional[_Reader] = None
286
        try:
287
            while not self._stop.is_set():
288
                head = cr.read_head()
289
                if not head:
290
                    break
291
                if head[0].upper().startswith(b"CONNECT "):
292
                    self._tunnel(head, cr, client)
293
                    return
294
                if up is None:
295
                    up = self._connect_upstream()
296
                    with self._lock:
297
                        self._socks.add(up)
298
                    ur = _Reader(up)
299
                assert ur is not None
300
                rec = self._one_exchange(head, cr, client, up, ur, conn_id)
301
                with self._lock:
302
                    self._records.append(rec)
303
                if rec.error or rec.status is None:
304
                    break
305
        except OSError:
306
            pass
307
        finally:
308
            _close(client)
309
            if up is not None:
310
                _close(up)
311
            with self._lock:
312
                self._socks.discard(client)
313
                if up is not None:
314
                    self._socks.discard(up)
315
316
    def _tunnel(self, head, cr: "_Reader", client: socket.socket) -> None:
317
        target = head[0].split(b" ")[1].decode("latin-1")
318
        host, _, port_s = target.rpartition(":")
319
        port = int(port_s or 443)
320
        rec = TunnelRecord(host, port, 0, 0, self._now_ms(), None)
321
        try:
322
            up = socket.create_connection((host, port), timeout=60)
323
        except OSError:
324
            client.sendall(b"HTTP/1.1 502 Bad Gateway\r\n\r\n")
325
            return
326
        with self._lock:
327
            self._socks.add(up)
328
            self._tunnels.append(rec)
329
        client.sendall(b"HTTP/1.1 200 Connection Established\r\n\r\n")
330
        if cr.buf:
331
            up.sendall(cr.buf)
332
            rec.client_to_upstream_bytes += len(cr.buf)
333
            cr.buf = b""
334
335
        def pump(src, dst, attr):
336
            try:
337
                while True:
338
                    b = src.recv(256 * 1024)
339
                    if not b:
340
                        break
341
                    with self._lock:
342
                        setattr(rec, attr, getattr(rec, attr) + len(b))
343
                    dst.sendall(b)
344
            except OSError:
345
                pass
346
            finally:
347
                try:
348
                    dst.shutdown(socket.SHUT_WR)
349
                except OSError:
350
                    pass
351
352
        t = threading.Thread(target=pump, args=(up, client, "upstream_to_client_bytes"), daemon=True)
353
        t.start()
354
        pump(client, up, "client_to_upstream_bytes")
355
        t.join()
356
        rec.end_ms = self._now_ms()
357
        _close(up)
358
        with self._lock:
359
            self._socks.discard(up)
360
361
    def _one_exchange(self, head, cr, client, up, ur, conn_id) -> RequestRecord:
362
        start = self._now_ms()
363
        reqline = head[0].decode("latin-1").rstrip("\r\n")
364
        parts = reqline.split(" ")
365
        method = parts[0] if parts else "?"
366
        target = parts[1] if len(parts) > 1 else "/"
367
        path = target.split("?", 1)[0]
368
        out = [head[0]]
369
        injected = {k.lower() for k in self.inject_headers}
370
        for line in head[1:]:
371
            k = line.split(b":", 1)[0].strip().lower()
372
            if k == b"host":
373
                port = "" if self.upstream_port in (80, 443) else f":{self.upstream_port}"
374
                out.append(f"Host: {self.upstream_host}{port}\r\n".encode())
375
            elif k.decode("latin-1") in injected:
376
                continue
377
            else:
378
                out.append(line)
379
        for k, v in self.inject_headers.items():
380
            out.append(f"{k}: {v}\r\n".encode("latin-1"))
381
        out.append(b"\r\n")
382
        blob = b"".join(out)
383
        up.sendall(blob)
384
        req_bytes = len(blob)
385
        req_bytes += _relay_body(head, cr, up, is_response=False, method=method)
386
        # Response (skip 1xx informational heads).
387
        resp_bytes = 0
388
        ttfb = None
389
        status = None
390
        while True:
391
            rhead = ur.read_head()
392
            if not rhead:
393
                return RequestRecord(method, path, None, req_bytes, resp_bytes, start, ttfb, self._now_ms(), conn_id, "upstream closed")
394
            if ttfb is None:
395
                ttfb = self._now_ms() - start
396
            try:
397
                status = int(rhead[0].split(b" ")[1])
398
            except (IndexError, ValueError):
399
                status = None
400
            rblob = b"".join(rhead) + b"\r\n"
401
            client.sendall(rblob)
402
            resp_bytes += len(rblob)
403
            if status is not None and 100 <= status < 200 and status != 101:
404
                continue
405
            break
406
        resp_bytes += _relay_body(rhead, ur, client, is_response=True, method=method, status=status)
407
        return RequestRecord(method, path, status, req_bytes, resp_bytes, start, ttfb, self._now_ms(), conn_id)
408
409
410
def _relay_body(head, reader: _Reader, dest: socket.socket, *, is_response: bool, method: str, status: Optional[int] = None) -> int:
411
    if is_response and (method == "HEAD" or status in (204, 304) or (status is not None and 100 <= status < 200)):
412
        return 0
413
    te = _header(head, b"transfer-encoding")
414
    cl = _header(head, b"content-length")
415
    n = 0
416
    if te is not None and b"chunked" in te.lower():
417
        while True:
418
            size_line = reader.readline()
419
            if not size_line:
420
                return n
421
            dest.sendall(size_line)
422
            n += len(size_line)
423
            size = int(size_line.split(b";")[0].strip() or b"0", 16)
424
            if size == 0:
425
                # trailers until blank line
426
                while True:
427
                    t = reader.readline()
428
                    dest.sendall(t)
429
                    n += len(t)
430
                    if t in (b"\r\n", b""):
431
                        return n
432
            remaining = size + 2  # data + CRLF
433
            while remaining > 0:
434
                chunk = reader.read_some(remaining)
435
                if not chunk:
436
                    return n
437
                dest.sendall(chunk)
438
                n += len(chunk)
439
                remaining -= len(chunk)
440
    if cl is not None:
441
        remaining = int(cl)
442
        while remaining > 0:
443
            chunk = reader.read_some(remaining)
444
            if not chunk:
445
                break
446
            dest.sendall(chunk)
447
            n += len(chunk)
448
            remaining -= len(chunk)
449
        return n
450
    if is_response:
451
        # read until close
452
        while True:
453
            chunk = reader.read_some(256 * 1024)
454
            if not chunk:
455
                break
456
            dest.sendall(chunk)
457
            n += len(chunk)
458
        try:
459
            dest.shutdown(socket.SHUT_WR)
460
        except OSError:
461
            pass
462
    return n
463
464
465
def _close(s: socket.socket) -> None:
466
    try:
467
        s.shutdown(socket.SHUT_RDWR)
468
    except OSError:
469
        pass
470
    try:
471
        s.close()
472
    except OSError:
473
        pass