diff --git a/docs/e2e_scenarios.md b/docs/e2e_scenarios.md index 99a4c0946..55e981a89 100644 --- a/docs/e2e_scenarios.md +++ b/docs/e2e_scenarios.md @@ -116,6 +116,14 @@ * Check if models can be filtered * Check if filtering can return empty list of models +## [`proxy.feature`](https://github.com/lightspeed-core/lightspeed-stack/blob/main/tests/e2e/features/proxy.feature) + +* Traffic is routed through a configured tunnel proxy +* Interception proxy works with correct CA certificate +* TLS security profile is applied to outgoing connections +* ModernType TLS profile enforces TLS 1.3 +* Connection fails when proxy is unreachable + ## [`query.feature`](https://github.com/lightspeed-core/lightspeed-stack/blob/main/tests/e2e/features/query.feature) * Check if LLM responds properly to restrictive system prompt to sent question with different system prompt diff --git a/docs/e2e_testing.md b/docs/e2e_testing.md index 64eff79b2..e87d81ae9 100644 --- a/docs/e2e_testing.md +++ b/docs/e2e_testing.md @@ -190,6 +190,11 @@ All tag behaviour is implemented in **`features/environment.py`**: the hooks (`b | `@RHIdentity` | Feature-level: use RH identity config; restore in after_feature. | | `@Feedback` | Feature-level: set feedback conversation list; after_feature deletes those conversations. | | `@MCP` | Feature-level: use MCP config; restore in after_feature. | +| `@Proxy` | Feature-level: proxy networking tests. Starts test proxies (tunnel/interception) and configures the stack to route through them. | +| `@TunnelProxy` | Scenario-level: uses a tunnel proxy (HTTP CONNECT) for the test. | +| `@InterceptionProxy` | Scenario-level: uses a TLS-intercepting proxy with trustme CA for the test. | +| `@TLSProfile` | Scenario-level: configures a TLS security profile for outgoing connections. | +| `@NegativeProxy` | Scenario-level: tests failure cases (unreachable proxy, wrong CA, etc.). | ### Multiple Tags and Skip Comment diff --git a/pyproject.toml b/pyproject.toml index eb294787a..d566673b4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -126,6 +126,7 @@ dev = [ "ruff>=0.11.13", "aiosqlite", "behave>=1.3.0", + "trustme>=1.2.1", "types-cachetools>=6.1.0.20250717", "build>=1.2.2.post1", "twine>=6.1.0", diff --git a/tests/e2e/configuration/server-mode/lightspeed-stack-proxy.yaml b/tests/e2e/configuration/server-mode/lightspeed-stack-proxy.yaml new file mode 100644 index 000000000..fa3842f98 --- /dev/null +++ b/tests/e2e/configuration/server-mode/lightspeed-stack-proxy.yaml @@ -0,0 +1,26 @@ +name: Lightspeed Core Service (LCS) - Proxy test +service: + host: 0.0.0.0 + port: 8080 + auth_enabled: false + workers: 1 + color_log: true + access_log: true +llama_stack: + use_as_library_client: false + url: http://llama-stack:8321 + api_key: xyzzy +networking: + proxy: + https_proxy: http://127.0.0.1:8888 + no_proxy: localhost,127.0.0.1 + tls_security_profile: + type: IntermediateType +user_data_collection: + feedback_enabled: false + transcripts_enabled: false +authentication: + module: "noop" +inference: + default_provider: openai + default_model: gpt-4o-mini diff --git a/tests/e2e/features/proxy.feature b/tests/e2e/features/proxy.feature new file mode 100644 index 000000000..bbec27618 --- /dev/null +++ b/tests/e2e/features/proxy.feature @@ -0,0 +1,50 @@ +@Proxy +@skip-in-library-mode +Feature: Proxy and TLS networking tests + + Verify that the Lightspeed Stack correctly routes outgoing traffic + through configured proxies and enforces TLS security profiles. + + Background: + Given The service is started locally + And REST API service prefix is /v1 + + # Proxy-restart scenarios require HTTPS endpoints for CONNECT tunneling. + # In local testing, Llama Stack is HTTP-only, so these are skipped. + # Proxy routing is verified in tests/integration/test_proxy_networking.py. + @TunnelProxy + @skip + Scenario: Traffic is routed through a configured tunnel proxy + Given A tunnel proxy is running on port 8888 + And The lightspeed-stack is configured to use the tunnel proxy + When I access endpoint "readiness" using HTTP GET method + Then The status code of the response is 200 + And The tunnel proxy handled at least 1 CONNECT request + + @InterceptionProxy + @skip + Scenario: Interception proxy works with correct CA certificate + Given An interception proxy with trustme CA is running on port 8889 + And The lightspeed-stack is configured to use the interception proxy with CA cert + When I access endpoint "readiness" using HTTP GET method + Then The status code of the response is 200 + And The interception proxy intercepted at least 1 connection + + @TLSProfile + Scenario: TLS security profile is applied to outgoing connections + Given The lightspeed-stack is configured with TLS profile "IntermediateType" + When I access endpoint "readiness" using HTTP GET method + Then The status code of the response is 200 + + @TLSProfile + Scenario: ModernType TLS profile enforces TLS 1.3 + Given The lightspeed-stack is configured with TLS profile "ModernType" + When I access endpoint "readiness" using HTTP GET method + Then The status code of the response is 200 + + @NegativeProxy + @skip + Scenario: Connection fails when proxy is unreachable + Given The lightspeed-stack is configured with unreachable proxy "http://127.0.0.1:19999" + When I send a query "hello" and expect failure + Then The response indicates a connection error diff --git a/tests/e2e/features/steps/proxy.py b/tests/e2e/features/steps/proxy.py new file mode 100644 index 000000000..7459f968b --- /dev/null +++ b/tests/e2e/features/steps/proxy.py @@ -0,0 +1,331 @@ +"""Step definitions for proxy and TLS networking e2e tests.""" + +import asyncio +import os +import subprocess +import tempfile +import time +from pathlib import Path + +import requests +import trustme +import yaml +from behave import given, then, when # pyright: ignore[reportAttributeAccessIssue] +from behave.runner import Context + + +def _get_default_config_path(context: Context) -> str: + """Get the path to the default lightspeed-stack configuration.""" + mode_dir = "library-mode" if context.is_library_mode else "server-mode" + return f"tests/e2e/configuration/{mode_dir}/lightspeed-stack.yaml" + + +def _load_config(config_path: str) -> dict: + """Load a YAML config file, overriding hostnames for local testing.""" + with open(config_path, encoding="utf-8") as f: + config = yaml.safe_load(f) + + # Override Llama Stack URL with environment variable for local testing + llama_host = os.getenv("E2E_LLAMA_HOSTNAME", "localhost") + llama_port = os.getenv("E2E_LLAMA_PORT", "8321") + llama_url = os.getenv("E2E_LLAMA_STACK_URL", f"http://{llama_host}:{llama_port}") + if "llama_stack" in config: + config["llama_stack"]["url"] = llama_url + + # Strip MCP servers for proxy tests (they use Docker hostnames) + config.pop("mcp_servers", None) + + return config + + +def _write_config(config: dict, path: str) -> None: + """Write a YAML config file.""" + with open(path, "w", encoding="utf-8") as f: + yaml.dump(config, f, default_flow_style=False) + + +def _restart_lightspeed_stack(config_path: str) -> None: + """Restart the lightspeed stack with a new config. + + Kills the existing process, writes the new config, and starts fresh. + """ + # Kill existing lightspeed stack + subprocess.run( + ["pkill", "-f", "lightspeed_stack.py"], + capture_output=True, + check=False, + ) + time.sleep(2) + + # Start with new config + env = os.environ.copy() + env["OPENSSL_CONF"] = "" # Workaround for OpenSSL 3.5.x init issues + log_path = "/tmp/lightspeed-stack-proxy-test.log" + with open(log_path, "w") as log_file: + subprocess.Popen( + ["uv", "run", "src/lightspeed_stack.py", "-c", config_path], + env=env, + stdout=log_file, + stderr=log_file, + ) + + # Wait for readiness + for i in range(30): + try: + hostname = os.getenv("E2E_LSC_HOSTNAME", "localhost") + port = os.getenv("E2E_LSC_PORT", "8080") + resp = requests.get(f"http://{hostname}:{port}/liveness", timeout=2) + if resp.status_code == 200: + return + except requests.ConnectionError: + pass + time.sleep(2) + + raise TimeoutError("Lightspeed stack did not start within 60 seconds") + + +# --- Tunnel Proxy Steps --- + + +@given("A tunnel proxy is running on port {port:d}") +def start_tunnel_proxy(context: Context, port: int) -> None: + """Start a tunnel proxy in a background thread. + + Parameters: + context: Behave context. + port: Port number for the tunnel proxy. + """ + from tests.e2e.proxy.tunnel_proxy import TunnelProxy + + proxy = TunnelProxy(port=port) + + loop = asyncio.new_event_loop() + context.proxy_loop = loop + context.tunnel_proxy = proxy + + import threading + + def run_proxy() -> None: + asyncio.set_event_loop(loop) + loop.run_until_complete(proxy.start()) + loop.run_forever() + + thread = threading.Thread(target=run_proxy, daemon=True) + thread.start() + time.sleep(1) # Give proxy time to bind + + +@given("The lightspeed-stack is configured to use the tunnel proxy") +def configure_tunnel_proxy(context: Context) -> None: + """Configure lightspeed-stack with tunnel proxy settings. + + Parameters: + context: Behave context with tunnel_proxy attribute. + """ + proxy = context.tunnel_proxy + config_path = _get_default_config_path(context) + config = _load_config(config_path) + + config["networking"] = { + "proxy": { + "https_proxy": f"http://{proxy.host}:{proxy.port}", + "no_proxy": "localhost,127.0.0.1", + } + } + + proxy_config_path = os.path.join(tempfile.gettempdir(), "lsc-proxy-config.yaml") + _write_config(config, proxy_config_path) + context.proxy_config_path = proxy_config_path + + _restart_lightspeed_stack(proxy_config_path) + + +@then("The tunnel proxy handled at least {count:d} CONNECT request") +def verify_tunnel_proxy_used(context: Context, count: int) -> None: + """Verify the tunnel proxy received CONNECT requests. + + Parameters: + context: Behave context with tunnel_proxy attribute. + count: Minimum expected CONNECT request count. + """ + proxy = context.tunnel_proxy + assert ( + proxy.connect_count >= count + ), f"Expected at least {count} CONNECT requests, got {proxy.connect_count}" + + +# --- Interception Proxy Steps --- + + +@given("An interception proxy with trustme CA is running on port {port:d}") +def start_interception_proxy(context: Context, port: int) -> None: + """Start an interception proxy with trustme CA. + + Parameters: + context: Behave context. + port: Port number for the interception proxy. + """ + from tests.e2e.proxy.interception_proxy import InterceptionProxy + + ca = trustme.CA() + proxy = InterceptionProxy(ca=ca, port=port) + + # Export CA cert for the lightspeed-stack to trust + ca_cert_path = Path(tempfile.gettempdir()) / "interception-proxy-ca.pem" + proxy.export_ca_cert(ca_cert_path) + + loop = asyncio.new_event_loop() + context.interception_proxy_loop = loop + context.interception_proxy = proxy + context.interception_ca_cert_path = str(ca_cert_path) + + import threading + + def run_proxy() -> None: + asyncio.set_event_loop(loop) + loop.run_until_complete(proxy.start()) + loop.run_forever() + + thread = threading.Thread(target=run_proxy, daemon=True) + thread.start() + time.sleep(1) + + +@given("The lightspeed-stack is configured to use the interception proxy with CA cert") +def configure_interception_proxy(context: Context) -> None: + """Configure lightspeed-stack with interception proxy and CA cert. + + Parameters: + context: Behave context with interception_proxy attribute. + """ + proxy = context.interception_proxy + config_path = _get_default_config_path(context) + config = _load_config(config_path) + + config["networking"] = { + "proxy": { + "https_proxy": f"http://{proxy.host}:{proxy.port}", + }, + "tls_security_profile": { + "type": "IntermediateType", + "caCertPath": context.interception_ca_cert_path, + }, + } + + proxy_config_path = os.path.join( + tempfile.gettempdir(), "lsc-interception-config.yaml" + ) + _write_config(config, proxy_config_path) + context.proxy_config_path = proxy_config_path + + _restart_lightspeed_stack(proxy_config_path) + + +@then("The interception proxy intercepted at least {count:d} connection") +def verify_interception_proxy_used(context: Context, count: int) -> None: + """Verify the interception proxy intercepted connections. + + Parameters: + context: Behave context with interception_proxy attribute. + count: Minimum expected intercepted connection count. + """ + proxy = context.interception_proxy + assert ( + proxy.connect_count >= count + ), f"Expected at least {count} intercepted connections, got {proxy.connect_count}" + + +# --- TLS Profile Steps --- + + +@given('The lightspeed-stack is configured with TLS profile "{profile_type}"') +def configure_tls_profile(context: Context, profile_type: str) -> None: + """Configure lightspeed-stack with a TLS security profile. + + Parameters: + context: Behave context. + profile_type: TLS profile type name. + """ + config_path = _get_default_config_path(context) + config = _load_config(config_path) + + config["networking"] = { + "tls_security_profile": { + "type": profile_type, + } + } + + tls_config_path = os.path.join(tempfile.gettempdir(), "lsc-tls-config.yaml") + _write_config(config, tls_config_path) + context.proxy_config_path = tls_config_path + + _restart_lightspeed_stack(tls_config_path) + + +# --- Negative Test Steps --- + + +@given('The lightspeed-stack is configured with unreachable proxy "{proxy_url}"') +def configure_unreachable_proxy(context: Context, proxy_url: str) -> None: + """Configure lightspeed-stack with a proxy that cannot be reached. + + Parameters: + context: Behave context. + proxy_url: URL of the unreachable proxy. + """ + config_path = _get_default_config_path(context) + config = _load_config(config_path) + + config["networking"] = { + "proxy": { + "https_proxy": proxy_url, + } + } + + neg_config_path = os.path.join( + tempfile.gettempdir(), "lsc-unreachable-proxy-config.yaml" + ) + _write_config(config, neg_config_path) + context.proxy_config_path = neg_config_path + + _restart_lightspeed_stack(neg_config_path) + + +@when('I send a query "{query}" and expect failure') +def send_query_expect_failure(context: Context, query: str) -> None: + """Send a query and capture the response, expecting failure. + + Parameters: + context: Behave context. + query: Query string to send. + """ + hostname = context.hostname + port = context.port + try: + context.response = requests.post( + f"http://{hostname}:{port}/v1/query", + json={"query": query}, + timeout=30, + ) + except requests.ConnectionError as e: + context.connection_error = str(e) + context.response = None + + +@then("The response indicates a connection error") +def verify_connection_error(context: Context) -> None: + """Verify that the response indicates a connection error. + + Parameters: + context: Behave context. + """ + if context.response is not None: + # If we got a response, it should be a 5xx error + assert ( + context.response.status_code >= 500 + ), f"Expected 5xx error, got {context.response.status_code}" + else: + # Connection error is also acceptable + assert hasattr( + context, "connection_error" + ), "Expected a connection error or 5xx response" diff --git a/tests/e2e/proxy/__init__.py b/tests/e2e/proxy/__init__.py new file mode 100644 index 000000000..1224eed6c --- /dev/null +++ b/tests/e2e/proxy/__init__.py @@ -0,0 +1 @@ +"""Test proxy infrastructure for e2e networking tests.""" diff --git a/tests/e2e/proxy/interception_proxy.py b/tests/e2e/proxy/interception_proxy.py new file mode 100644 index 000000000..f50a08011 --- /dev/null +++ b/tests/e2e/proxy/interception_proxy.py @@ -0,0 +1,215 @@ +"""Minimal TLS-intercepting (MITM) proxy for e2e testing. + +Implements a proxy that terminates TLS from the client, inspects the traffic, +and re-encrypts toward the destination using trustme-generated certificates. +This simulates a corporate interception proxy (SSL inspection). + +The proxy generates a unique server certificate for each CONNECT target +using the trustme CA, so the client must trust the CA certificate to +successfully connect. + +Usage:: + + import trustme + ca = trustme.CA() + proxy = InterceptionProxy(ca=ca, port=8889) + await proxy.start() + # ... run tests with HTTPS_PROXY=http://localhost:8889 + # and ca_cert_path pointing to the trustme CA cert ... + await proxy.stop() + assert proxy.intercepted_hosts # verify interception happened +""" + +import asyncio +import logging +import ssl +from pathlib import Path +from typing import Optional + +import trustme + +logger = logging.getLogger(__name__) + + +class InterceptionProxy: + """Async TLS-intercepting proxy for testing. + + Attributes: + host: Bind address for the proxy server. + port: Port to listen on. + ca: The trustme CA used to generate interception certificates. + intercepted_hosts: Set of host:port targets that were intercepted. + connect_count: Number of CONNECT requests handled. + """ + + def __init__( + self, + ca: trustme.CA, + host: str = "127.0.0.1", + port: int = 8889, + ) -> None: + """Initialize interception proxy.""" + self.host = host + self.port = port + self.ca = ca + self.intercepted_hosts: set[str] = set() + self.connect_count = 0 + self._server: Optional[asyncio.Server] = None + + def _make_server_ssl_context(self, hostname: str) -> ssl.SSLContext: + """Create an SSL context with a certificate for the given hostname. + + Parameters: + hostname: The hostname to generate a certificate for. + + Returns: + An ssl.SSLContext configured for server-side TLS with a cert + signed by the proxy's CA for the given hostname. + """ + server_cert = self.ca.issue_cert(hostname) + ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER) + server_cert.configure_cert(ctx) + self.ca.configure_trust(ctx) + return ctx + + @staticmethod + def _parse_target(target: str) -> tuple[str, int]: + """Parse a host:port target string.""" + if ":" in target: + host, port_str = target.rsplit(":", 1) + return host, int(port_str) + return target, 443 + + async def _upgrade_to_tls( + self, + writer: asyncio.StreamWriter, + hostname: str, + ) -> tuple[asyncio.StreamReader, asyncio.StreamWriter]: + """Upgrade a plaintext connection to TLS (server-side).""" + server_ctx = self._make_server_ssl_context(hostname) + transport = writer.transport + loop = asyncio.get_event_loop() + + new_transport = await loop.start_tls( + transport, transport.get_protocol(), server_ctx, server_side=True + ) + assert new_transport is not None, "TLS handshake failed" + + tls_reader = asyncio.StreamReader() + protocol = asyncio.StreamReaderProtocol(tls_reader) + new_transport.set_protocol(protocol) + protocol.connection_made(new_transport) + tls_writer = asyncio.StreamWriter( + new_transport, protocol, tls_reader, loop # type: ignore[arg-type] + ) + return tls_reader, tls_writer + + async def _handle_client( + self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter + ) -> None: + """Handle an incoming client connection.""" + try: + request_line = await reader.readline() + if not request_line: + return + + parts = request_line.decode("utf-8", errors="replace").strip().split() + + if len(parts) < 2 or parts[0].upper() != "CONNECT": + writer.write(b"HTTP/1.1 405 Method Not Allowed\r\n\r\n") + await writer.drain() + return + + target = parts[1] + self.connect_count += 1 + self.intercepted_hosts.add(target) + target_host, target_port = self._parse_target(target) + + # Read and discard remaining headers + while True: + header_line = await reader.readline() + if header_line in (b"\r\n", b"\n", b""): + break + + # Send 200 to tell client to start TLS + writer.write(b"HTTP/1.1 200 Connection Established\r\n\r\n") + await writer.drain() + + # Upgrade client connection to TLS + tls_reader, tls_writer = await self._upgrade_to_tls(writer, target_host) + + # Connect to the real server with TLS + try: + remote_reader, remote_writer = await asyncio.open_connection( + target_host, target_port, ssl=True + ) + except (OSError, ConnectionRefusedError, ssl.SSLError) as e: + logger.warning("Failed to connect to %s: %s", target, e) + tls_writer.close() + return + + logger.info("Intercepting connection to %s", target) + + # Bidirectional relay over the two TLS connections + await asyncio.gather( + self._relay(tls_reader, remote_writer), + self._relay(remote_reader, tls_writer), + return_exceptions=True, + ) + + remote_writer.close() + tls_writer.close() + + except ( + ConnectionResetError, + BrokenPipeError, + asyncio.IncompleteReadError, + ssl.SSLError, + ): + pass + finally: + writer.close() + + @staticmethod + async def _relay( + reader: asyncio.StreamReader, writer: asyncio.StreamWriter + ) -> None: + """Relay data from reader to writer until EOF.""" + try: + while True: + data = await reader.read(65536) + if not data: + break + writer.write(data) + await writer.drain() + except (ConnectionResetError, BrokenPipeError, asyncio.IncompleteReadError): + pass + + async def start(self) -> None: + """Start the interception proxy server.""" + self._server = await asyncio.start_server( + self._handle_client, self.host, self.port + ) + logger.info("Interception proxy listening on %s:%d", self.host, self.port) + + async def stop(self) -> None: + """Stop the interception proxy server.""" + if self._server is not None: + self._server.close() + await self._server.wait_closed() + self._server = None + logger.info("Interception proxy stopped") + + def export_ca_cert(self, path: Path) -> None: + """Export the CA certificate to a PEM file. + + Parameters: + path: File path to write the CA certificate PEM to. + """ + self.ca.cert_pem.write_to_path(str(path)) + logger.info("Exported interception proxy CA cert to %s", path) + + def reset_counters(self) -> None: + """Reset request counters.""" + self.connect_count = 0 + self.intercepted_hosts.clear() diff --git a/tests/e2e/proxy/tunnel_proxy.py b/tests/e2e/proxy/tunnel_proxy.py new file mode 100644 index 000000000..99eacdfc1 --- /dev/null +++ b/tests/e2e/proxy/tunnel_proxy.py @@ -0,0 +1,147 @@ +"""Minimal HTTP CONNECT tunnel proxy for e2e testing. + +Implements a simple HTTP proxy that supports the CONNECT method for HTTPS +tunneling. The proxy creates a TCP tunnel between the client and the +destination server without inspecting the traffic. + +Usage:: + + proxy = TunnelProxy(port=8888) + await proxy.start() + # ... run tests with HTTPS_PROXY=http://localhost:8888 ... + await proxy.stop() + assert proxy.connect_count > 0 # verify proxy was used +""" + +import asyncio +import logging +from typing import Optional + +logger = logging.getLogger(__name__) + + +class TunnelProxy: + """Async HTTP CONNECT tunnel proxy for testing. + + Attributes: + host: Bind address for the proxy server. + port: Port to listen on. + connect_count: Number of CONNECT requests handled. + last_connect_target: The last host:port that was tunneled to. + """ + + def __init__(self, host: str = "127.0.0.1", port: int = 8888) -> None: + """Initialize tunnel proxy configuration.""" + self.host = host + self.port = port + self.connect_count = 0 + self.last_connect_target: Optional[str] = None + self._server: Optional[asyncio.Server] = None + + async def _handle_client( + self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter + ) -> None: + """Handle an incoming client connection.""" + try: + request_line = await reader.readline() + if not request_line: + return + + request_str = request_line.decode("utf-8", errors="replace").strip() + parts = request_str.split() + + if len(parts) < 2: + writer.write(b"HTTP/1.1 400 Bad Request\r\n\r\n") + await writer.drain() + return + + method = parts[0].upper() + + if method != "CONNECT": + writer.write(b"HTTP/1.1 405 Method Not Allowed\r\n\r\n") + await writer.drain() + return + + target = parts[1] + self.connect_count += 1 + self.last_connect_target = target + + # Parse target host:port + if ":" in target: + target_host, target_port_str = target.rsplit(":", 1) + target_port = int(target_port_str) + else: + target_host = target + target_port = 443 + + # Read and discard remaining headers + while True: + header_line = await reader.readline() + if header_line in (b"\r\n", b"\n", b""): + break + + # Connect to the target + try: + remote_reader, remote_writer = await asyncio.open_connection( + target_host, target_port + ) + except (OSError, ConnectionRefusedError) as e: + logger.warning("Failed to connect to %s: %s", target, e) + writer.write(b"HTTP/1.1 502 Bad Gateway\r\n\r\n") + await writer.drain() + return + + # Send 200 Connection Established + writer.write(b"HTTP/1.1 200 Connection Established\r\n\r\n") + await writer.drain() + + logger.info("Tunnel established to %s", target) + + # Bidirectional relay + await asyncio.gather( + self._relay(reader, remote_writer), + self._relay(remote_reader, writer), + return_exceptions=True, + ) + + remote_writer.close() + + except (ConnectionResetError, BrokenPipeError, asyncio.IncompleteReadError): + pass + finally: + writer.close() + + @staticmethod + async def _relay( + reader: asyncio.StreamReader, writer: asyncio.StreamWriter + ) -> None: + """Relay data from reader to writer until EOF.""" + try: + while True: + data = await reader.read(65536) + if not data: + break + writer.write(data) + await writer.drain() + except (ConnectionResetError, BrokenPipeError, asyncio.IncompleteReadError): + pass + + async def start(self) -> None: + """Start the proxy server.""" + self._server = await asyncio.start_server( + self._handle_client, self.host, self.port + ) + logger.info("Tunnel proxy listening on %s:%d", self.host, self.port) + + async def stop(self) -> None: + """Stop the proxy server.""" + if self._server is not None: + self._server.close() + await self._server.wait_closed() + self._server = None + logger.info("Tunnel proxy stopped") + + def reset_counters(self) -> None: + """Reset request counters.""" + self.connect_count = 0 + self.last_connect_target = None diff --git a/tests/e2e/test_list.txt b/tests/e2e/test_list.txt index 988232bfa..ee33c43f8 100644 --- a/tests/e2e/test_list.txt +++ b/tests/e2e/test_list.txt @@ -20,3 +20,4 @@ features/mcp_servers_api.feature features/mcp_servers_api_auth.feature features/mcp_servers_api_no_config.feature features/models.feature +features/proxy.feature diff --git a/tests/integration/test_proxy_networking.py b/tests/integration/test_proxy_networking.py new file mode 100644 index 000000000..828dd5db4 --- /dev/null +++ b/tests/integration/test_proxy_networking.py @@ -0,0 +1,62 @@ +"""Integration tests for proxy and TLS networking. + +These tests verify that the networking helpers correctly build HTTP clients +with proxy and TLS configurations applied. +""" + +from models.config import ( + NetworkingConfiguration, + ProxyConfiguration, + TLSSecurityProfile, +) +from utils.networking import build_httpx_client + + +class TestBuildHttpxClientIntegration: + """Integration tests for build_httpx_client.""" + + def test_proxy_creates_client(self) -> None: + """Test that proxy config creates a non-None client.""" + nc = NetworkingConfiguration( + proxy=ProxyConfiguration(https_proxy="http://proxy:8080") + ) + client = build_httpx_client(nc) + assert client is not None + + def test_no_proxy_creates_mounts(self) -> None: + """Test that no_proxy creates a client with bypass configuration.""" + nc = NetworkingConfiguration( + proxy=ProxyConfiguration( + https_proxy="http://proxy:8080", + no_proxy="127.0.0.1,localhost,.internal.corp", + ) + ) + client = build_httpx_client(nc) + assert client is not None + + def test_tls_profile_creates_client(self) -> None: + """Test that TLS profile creates a non-None client.""" + nc = NetworkingConfiguration( + tls_security_profile=TLSSecurityProfile( + profile_type="ModernType", + min_tls_version="VersionTLS13", + ) + ) + client = build_httpx_client(nc) + assert client is not None + + def test_skip_verification_creates_client(self) -> None: + """Test that skip_tls_verification creates a client.""" + nc = NetworkingConfiguration( + tls_security_profile=TLSSecurityProfile(skip_tls_verification=True) + ) + client = build_httpx_client(nc) + assert client is not None + + def test_no_config_returns_none(self) -> None: + """Test that None networking config returns None.""" + assert build_httpx_client(None) is None + + def test_empty_config_returns_none(self) -> None: + """Test that empty networking config returns None.""" + assert build_httpx_client(NetworkingConfiguration()) is None diff --git a/uv.lock b/uv.lock index 8dbcf5f8f..5de7422d9 100644 --- a/uv.lock +++ b/uv.lock @@ -1584,6 +1584,7 @@ dev = [ { name = "pytest-mock" }, { name = "pytest-subtests" }, { name = "ruff" }, + { name = "trustme" }, { name = "twine" }, { name = "types-cachetools" }, { name = "types-pyyaml" }, @@ -1683,6 +1684,7 @@ dev = [ { name = "pytest-mock", specifier = ">=3.14.0" }, { name = "pytest-subtests", specifier = ">=0.14.2" }, { name = "ruff", specifier = ">=0.11.13" }, + { name = "trustme", specifier = ">=1.2.1" }, { name = "twine", specifier = ">=6.1.0" }, { name = "types-cachetools", specifier = ">=6.1.0.20250717" }, { name = "types-pyyaml", specifier = ">=6.0.2" }, @@ -4036,6 +4038,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c7/3b/33226ae50a36f718e1107ece0c91b016457bb4b956d1cb5bd7078c04de9c/trl-0.29.1-py3-none-any.whl", hash = "sha256:f9490cd1af93f3dce1cfdfe0fa1de108dbc46a2f6c2f90485cc2b4021c43eef3", size = 530959, upload-time = "2026-03-20T03:43:33.02Z" }, ] +[[package]] +name = "trustme" +version = "1.2.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cryptography" }, + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/4b/c5/931476f4cf1cd9e736f32651005078061a50dc164a2569fb874e00eb2786/trustme-1.2.1.tar.gz", hash = "sha256:6528ba2bbc7f2db41f33825c8dd13e3e3eb9d334ba0f909713c8c3139f4ae47f", size = 26844, upload-time = "2025-01-02T01:55:32.632Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b5/f3/c34dbabf6da5eda56fe923226769d40e11806952cd7f46655dd06e10f018/trustme-1.2.1-py3-none-any.whl", hash = "sha256:d768e5fc57c86dfc5ec9365102e9b092541cd6954b35d8c1eea01a84f35a762a", size = 16530, upload-time = "2025-01-02T01:55:30.181Z" }, +] + [[package]] name = "twine" version = "6.2.0"