"""Wire-level regressions: real TLS sockets, ALPN and HTTP/2 flow control.""" import asyncio import datetime import hashlib import ipaddress import os import ssl import struct import tempfile import time import unittest from pathlib import Path from types import SimpleNamespace from unittest.mock import AsyncMock, patch import httpx from h2.config import H2Configuration from h2.connection import H2Connection from h2.events import DataReceived, RequestReceived, StreamEnded, StreamReset from h2.settings import SettingCodes from proxy.h2_transport import H2Transport, STREAM_RECEIVE_WINDOW, _Connection from proxy.cf_h2 import _HttpChannel, _HttpLane, bridge_h2 from proxy import tg_ws_proxy from proxy._aes import Cipher, algorithms, modes from proxy.config import proxy_config from proxy.utils import PROTO_TAG_ABRIDGED, PROTO_TAG_INTERMEDIATE, PROTO_TAG_SECURE class H2ContextTest(unittest.IsolatedAsyncioTestCase): async def test_lanes_reuse_verified_context_without_changing_websocket_contexts(self): from proxy.h2_transport import _default_ssl_context from proxy.raw_websocket import _ssl_ctx, _ssl_ctx_fronting from proxy.utils import create_ssl_context _default_ssl_context.cache_clear() self.addCleanup(_default_ssl_context.cache_clear) lanes = [] try: with patch('proxy.h2_transport.create_ssl_context', wraps=create_ssl_context) as create: lanes = [_HttpLane('kws4.example.org', 1), _HttpLane('kws2.example.net', 2)] create.assert_called_once_with() first, second = (lane.client._transport.ssl_context for lane in lanes) self.assertIs(first, second) self.assertIsNot(first, _ssl_ctx) self.assertIsNot(first, _ssl_ctx_fronting) self.assertTrue(first.check_hostname) self.assertEqual(first.verify_mode, ssl.CERT_REQUIRED) self.assertTrue(_ssl_ctx.check_hostname) self.assertFalse(_ssl_ctx_fronting.check_hostname) self.assertEqual(set(first.get_ca_certs(binary_form=True)), set(_ssl_ctx.get_ca_certs(binary_form=True))) custom = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT) custom.check_hostname = False transport = H2Transport(custom) self.assertIs(transport.ssl_context, custom) self.assertTrue(first.check_hostname) await transport.aclose() finally: await asyncio.gather(*(lane.close() for lane in lanes)) class H2WireTest(unittest.IsolatedAsyncioTestCase): @classmethod def setUpClass(cls): from cryptography import x509 from cryptography.hazmat.primitives import hashes, serialization from cryptography.hazmat.primitives.asymmetric import rsa from cryptography.x509.oid import NameOID cls.folder = tempfile.TemporaryDirectory(prefix='tg-h2-tests-') cls.addClassCleanup(cls.folder.cleanup) key = rsa.generate_private_key(public_exponent=65537, key_size=2048) name = x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, 'localhost')]) now = datetime.datetime.now(datetime.timezone.utc) cert = (x509.CertificateBuilder().subject_name(name).issuer_name(name) .public_key(key.public_key()).serial_number(x509.random_serial_number()) .not_valid_before(now - datetime.timedelta(minutes=1)) .not_valid_after(now + datetime.timedelta(hours=1)) .add_extension(x509.SubjectAlternativeName([ x509.IPAddress(ipaddress.ip_address('127.0.0.1'))]), critical=False) .sign(key, hashes.SHA256())) cls.cert_path = Path(cls.folder.name) / 'cert.pem' key_path = Path(cls.folder.name) / 'key.pem' cls.cert_path.write_bytes(cert.public_bytes(serialization.Encoding.PEM)) key_path.write_bytes(key.private_bytes(serialization.Encoding.PEM, serialization.PrivateFormat.PKCS8, serialization.NoEncryption())) cls.server_ssl = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER) cls.server_ssl.load_cert_chain(str(cls.cert_path), str(key_path)) cls.server_ssl.set_alpn_protocols(['h2']) async def asyncSetUp(self): self.requests = asyncio.Queue() self.resets = asyncio.Queue() self.connections = [] self.handlers = [] self.tasks = [] self.server_max_streams = 64 self.server = await asyncio.start_server(self.serve, '127.0.0.1', 0, ssl=self.server_ssl) port = self.server.sockets[0].getsockname()[1] self.lane = _HttpLane('127.0.0.1:%d' % port, 1) await self.lane.client.aclose() self.transport = H2Transport(ssl.create_default_context(cafile=str(self.cert_path))) self.lane.client = httpx.AsyncClient(transport=self.transport, trust_env=False, timeout=httpx.Timeout(2, read=1)) async def asyncTearDown(self): for task in self.tasks: task.cancel() await asyncio.gather(*self.tasks, return_exceptions=True) await self.lane.close() self.server.close() await self.server.wait_closed() for connection in self.connections: connection.writer.close() await asyncio.wait_for(asyncio.gather(*self.handlers), 3) async def serve(self, reader, writer): self.handlers.append(asyncio.current_task()) conn = SimpleNamespace(h2=H2Connection(config=H2Configuration(client_side=False)), writer=writer, bodies={}, pending={}) self.connections.append(conn) conn.h2.initiate_connection() conn.h2.update_settings({SettingCodes.MAX_CONCURRENT_STREAMS: self.server_max_streams}) writer.write(conn.h2.data_to_send()) try: while True: data = await reader.read(65536) if not data: break for event in conn.h2.receive_data(data): if isinstance(event, RequestReceived): conn.bodies[event.stream_id] = bytearray() elif isinstance(event, DataReceived): conn.bodies[event.stream_id].extend(event.data) conn.h2.acknowledge_received_data(event.flow_controlled_length, event.stream_id) elif isinstance(event, StreamEnded): await self.requests.put((conn, event.stream_id, bytes(conn.bodies[event.stream_id]))) elif isinstance(event, StreamReset): conn.pending.pop(event.stream_id, None) await self.resets.put((event.stream_id, event.error_code)) self.flush(conn) await writer.drain() except (ConnectionError, ssl.SSLError): pass finally: writer.close() try: await writer.wait_closed() except (ConnectionError, ssl.SSLError): pass def flush(self, conn): for sid, (body, end) in list(conn.pending.items()): while body: count = min(len(body), conn.h2.max_outbound_frame_size, conn.h2.local_flow_control_window(sid)) if count <= 0: break chunk, body = body[:count], body[count:] conn.h2.send_data(sid, chunk, end_stream=end and not body) if body: conn.pending[sid] = (body, end) else: del conn.pending[sid] conn.writer.write(conn.h2.data_to_send()) def respond(self, request, body=b'r' * 40, *, end=True, length=None, status=200): conn, sid, _ = request conn.h2.send_headers(sid, [(b':status', str(status).encode()), (b'content-type', b'application/octet-stream'), (b'content-length', str(len(body) if length is None else length).encode())], end_stream=end and not body) if body: conn.pending[sid] = (body, end) self.flush(conn) async def post(self, body=b'x' * 40): task = asyncio.create_task(self.lane._post(body, len(self.tasks) + 1)) self.tasks.append(task) request = await asyncio.wait_for(self.requests.get(), 2) self.assertEqual(request[2], body) return task, request async def test_fast_response_does_not_wait_for_unrelated_long_polls(self): slow1, request1 = await self.post() slow2, request2 = await self.post() fast, request3 = await self.post() self.respond(request3) self.assertEqual(await asyncio.wait_for(fast, .5), b'r' * 40) self.assertFalse(slow1.done()) self.assertFalse(slow2.done()) self.respond(request1) self.respond(request2) await asyncio.gather(slow1, slow2) self.assertEqual(len(self.connections), 1) async def test_shared_default_context_rejects_untrusted_certificate(self): async with httpx.AsyncClient(transport=H2Transport(), trust_env=False) as client: with self.assertRaises(httpx.ConnectError): await client.get('https://' + self.lane.host + '/api') task, request = await self.post() self.respond(request) self.assertEqual(await asyncio.wait_for(task, 1), b'r' * 40) async def test_debug_trace_follows_real_stream_without_logging_payload(self): channel = _HttpChannel(self.lane, 1, 'trace-test') body = b'private-payload-' + b'x' * 24 with self.assertLogs('tg-mtproto-proxy', level='DEBUG') as captured: await channel.send(body, False) request = await asyncio.wait_for(self.requests.get(), 1) trace = next(iter(channel.request_traces.values())) async def phase(expected): while trace.phase != expected: await asyncio.sleep(.001) await asyncio.wait_for(phase('headers'), .5) self.assertEqual(trace.stream, request[1]) self.assertEqual(trace.packet, 1) self.assertEqual(trace.request, 1) self.assertIsNotNone(trace.sent_at) self.assertIsNone(trace.headers_at) self.respond(request, b'r' * 20, end=False, length=40) await asyncio.wait_for(phase('body'), .5) self.assertIsNotNone(trace.headers_at) conn, sid, _ = request conn.h2.send_data(sid, b'r' * 20, end_stream=True) self.flush(conn) self.assertEqual(await asyncio.wait_for(channel.receive(), .5), b'r' * 40) await asyncio.gather(*list(channel.pending)) self.assertEqual(trace.received, 40) self.assertEqual(trace.phase, 'complete') self.assertFalse(channel.request_traces) self.assertIs(channel.recent_traces[-1], trace) self.lane.log_flow(trace.finished_at) await channel.close() output = '\n'.join(captured.output) self.assertIn('http_down=40', output) self.assertIn('small=1 large=0', output) self.assertNotIn('private-payload', output) self.assertFalse(channel.recent_traces) async def test_stale_recovery_slot_replaced_without_resetting_original_request(self): channel = _HttpChannel(self.lane, 1, 'recovery-wire-test') bodies = [b'samekey!' + bytes([index]) * 32 for index in range(3)] originals = [] for body in bodies[:2]: await channel.send(body, False) originals.append(await asyncio.wait_for(self.requests.get(), 1)) with patch('proxy.cf_h2.REPLAY_REQUEST_SECONDS', .02), \ patch('proxy.cf_h2.REPLAY_CHECK_SECONDS', .005), \ patch('proxy.cf_h2.REPLAY_SLOT_SECONDS', .1): recovery = asyncio.create_task(channel._recover()) self.tasks.append(recovery) old_poll = await asyncio.wait_for(self.requests.get(), 1) other_poll = await asyncio.wait_for(self.requests.get(), 1) self.assertEqual([old_poll[2], other_poll[2]], bodies[:2]) # The two duplicate requests remain silent. A new native request # also gets no reply until its own duplicate solicits one. await channel.send(bodies[2], False) original = await asyncio.wait_for(self.requests.get(), 1) probe = await asyncio.wait_for(self.requests.get(), 1) self.assertEqual(probe[2], bodies[2]) reset_sid, reset_code = await asyncio.wait_for(self.resets.get(), 1) self.assertEqual((reset_sid, reset_code), (old_poll[1], 8)) self.assertIn(original[1], self.transport.connection.streams) for request in originals: self.assertIn(request[1], self.transport.connection.streams) self.assertIn(other_poll[1], self.transport.connection.streams) self.assertEqual(len(channel.replay_pending), 2) self.respond(probe, b'r' * 40) self.assertEqual(await asyncio.wait_for(channel.receive(), 1), b'r' * 40) # The original stream remains usable, even after recovery. self.respond(original, b'o' * 40) self.assertEqual(await asyncio.wait_for(channel.receive(), 1), b'o' * 40) self.assertEqual(channel.replay_rotations, 1) self.assertEqual(self.lane.failed_until, 0) self.assertEqual(len(self.connections), 1) async def test_normal_long_poll_survives_six_seconds_and_delivers_late_reply(self): channel = _HttpChannel(self.lane, 1, 'normal-long-poll-test') bodies = [b'samekey!' + bytes([index]) * 32 for index in range(3)] for body in bodies[:2]: await channel.send(body, False) self.respond(await asyncio.wait_for(self.requests.get(), 1)) await asyncio.wait_for(channel.receive(), 1) with patch('proxy.cf_h2.REPLAY_IDLE_SECONDS', .01), \ patch('proxy.cf_h2.REPLAY_RETRY_SECONDS', .01), \ patch('proxy.cf_h2.REPLAY_CHECK_SECONDS', .005): recovery = asyncio.create_task(channel._recover()) self.tasks.append(recovery) poll = await asyncio.wait_for(self.requests.get(), 1) # Advance request ages, keeping the real TLS/H2 streams open. # Six seconds is still inside Telegram's default 25s HTTP wait. for task in channel.replay_pending: channel.pending_since[task] = time.monotonic() - 6 channel.sent_since[task] = time.monotonic() - 6 await asyncio.sleep(.03) self.assertTrue(self.resets.empty(), 'normal HTTP wait was reset before its delayed result') await channel.send(bodies[2], False) fresh = await asyncio.wait_for(self.requests.get(), 1) self.assertEqual(fresh[2], bodies[2]) self.respond(fresh, b'n' * 40) self.assertEqual(await asyncio.wait_for(channel.receive(), 1), b'n' * 40) self.respond(poll, b'd' * (128 * 1024)) self.assertEqual(await asyncio.wait_for(channel.receive(), 1), b'd' * (128 * 1024)) self.assertEqual(channel.replay_rotations, 0) self.assertEqual(channel.replay_requests, 1) async def test_replay_rotation_does_not_interrupt_upload(self): channel = _HttpChannel(self.lane, 1, 'replay-upload-test') body = b'samekey!' + b'p' * 32 await channel.send(body, False) self.respond(await asyncio.wait_for(self.requests.get(), 1)) await asyncio.wait_for(channel.receive(), 1) upload_started, resume = asyncio.Event(), asyncio.Event() send_body = _Connection.send_body async def paused_send(connection, request, stream): upload_started.set() await resume.wait() await send_body(connection, request, stream) with patch.object(_Connection, 'send_body', paused_send), \ patch('proxy.cf_h2.REPLAY_IDLE_SECONDS', .01), \ patch('proxy.cf_h2.REPLAY_RETRY_SECONDS', .01), \ patch('proxy.cf_h2.REPLAY_CHECK_SECONDS', .005): recovery = asyncio.create_task(channel._recover()) self.tasks.append(recovery) await asyncio.wait_for(upload_started.wait(), 1) probe = next(iter(channel.replay_pending)) channel.pending_since[probe] = time.monotonic() - 40 await asyncio.sleep(.03) self.assertFalse(probe.done()) self.assertTrue(self.resets.empty()) self.assertEqual(channel.replay_rotations, 0) resume.set() request = await asyncio.wait_for(self.requests.get(), 1) self.respond(request) self.assertEqual(await asyncio.wait_for(channel.receive(), 1), b'r' * 40) async def test_recovery_deadline_starts_after_original_upload_finishes(self): channel = _HttpChannel(self.lane, 1, 'unfinished-upload-test') body = b'samekey!' + b'q' * 32 started, resume = asyncio.Event(), asyncio.Event() send_body = _Connection.send_body async def paused_send(connection, request, stream): started.set() await resume.wait() await send_body(connection, request, stream) with patch.object(_Connection, 'send_body', paused_send), \ patch('proxy.cf_h2.REPLAY_REQUEST_SECONDS', .02), \ patch('proxy.cf_h2.REPLAY_CHECK_SECONDS', .005): await channel.send(body, False) await asyncio.wait_for(started.wait(), .5) self.tasks.append(asyncio.create_task(channel._recover())) await asyncio.sleep(.06) self.assertEqual(channel.requests, 1, 'unsent original was duplicated') self.assertTrue(self.requests.empty()) resume.set() original = await asyncio.wait_for(self.requests.get(), .5) probe = await asyncio.wait_for(self.requests.get(), .5) self.assertEqual(original[2], body) self.assertEqual(probe[2], body) self.respond(probe) self.assertEqual(await asyncio.wait_for(channel.receive(), .5), b'r' * 40) self.assertTrue(self.resets.empty()) async def test_body_tail_does_not_wait_for_unrelated_long_polls(self): slow1, _ = await self.post() slow2, _ = await self.post() fast, request = await self.post() self.respond(request, b'a' * 20, end=False, length=40) await asyncio.sleep(.03) self.assertFalse(fast.done()) conn, sid, _ = request conn.h2.send_data(sid, b'b' * 20, end_stream=True) self.flush(conn) self.assertEqual(await asyncio.wait_for(fast, .5), b'a' * 20 + b'b' * 20) self.assertFalse(slow1.done()) self.assertFalse(slow2.done()) async def test_receiving_body_is_marked_before_request_coroutine_resumes(self): channel = _HttpChannel(self.lane, 1, 'early-headers-test') resume = asyncio.Event() send_body = _Connection.send_body async def paused_send(connection, request, stream): await send_body(connection, request, stream) await resume.wait() with patch.object(_Connection, 'send_body', paused_send): await channel.send(b'k' * 40, False) request = await asyncio.wait_for(self.requests.get(), 1) self.respond(request, b'a' * 20, end=False, length=40) async def headers_received(): while channel.receiving_http == 0: await asyncio.sleep(.005) try: await asyncio.wait_for(headers_received(), .5) self.assertEqual(channel.receiving_http, 1) self.assertTrue(channel.queue.empty()) finally: resume.set() conn, sid, _ = request conn.h2.send_data(sid, b'b' * 20, end_stream=True) self.flush(conn) self.assertEqual(await asyncio.wait_for(channel.receive(), 1), b'a' * 20 + b'b' * 20) self.assertEqual(channel.receiving_http, 0) async def test_native_channel_delivers_ready_reply_while_large_body_is_pending(self): class IdentityCipher: def update(self, data): return data class Writer: def __init__(self): self.data = bytearray() self.changed = asyncio.Event() def write(self, data): self.data.extend(data) self.changed.set() async def drain(self): pass reader, writer = asyncio.StreamReader(), Writer() channel = _HttpChannel(self.lane, 1, 'wire-test') ctx = SimpleNamespace(clt_enc=IdentityCipher(), clt_dec=IdentityCipher()) bridge = asyncio.create_task(bridge_h2(reader, writer, channel, ctx, PROTO_TAG_INTERMEDIATE)) self.tasks.append(bridge) reader.feed_data(struct.pack('not found', status=404, end=False) await asyncio.wait_for(bridge, 1) self.assertEqual(writer.data, struct.pack('