diff --git a/httpserver/httpserver.py b/httpserver/httpserver.py index f44814a..ae18f5b 100644 --- a/httpserver/httpserver.py +++ b/httpserver/httpserver.py @@ -45,6 +45,7 @@ def echo(request): import os import re import signal +import ssl import sys from collections.abc import AsyncIterator, Callable from email.utils import formatdate @@ -1439,6 +1440,10 @@ def run( port: int = DEFAULT_PORT, *, socket: str | None = None, + ssl_context: ssl.SSLContext | None = None, + backlog: int | None = None, + reuse_address: bool | None = None, + reuse_port: bool | None = None, ) -> None: """Start the server (blocking). @@ -1450,13 +1455,44 @@ def run( on a Unix socket instead of TCP. The socket file permissions are restricted to owner-only (``0o600``) after creation. Only available on Unix-like systems. + ssl_context: An :class:`ssl.SSLContext` for TLS termination. + When provided, the server accepts HTTPS connections. + The caller is responsible for configuring the context + (loading certs, setting verify mode, etc.). + backlog: Maximum number of queued connections passed to + :func:`asyncio.start_server`. ``None`` leaves the OS + default (typically 128). + reuse_address: Sets ``SO_REUSEADDR``. ``None`` lets asyncio + decide (``True`` on non-Windows). + reuse_port: Sets ``SO_REUSEPORT`` for multi-process load + balancing. ``None`` lets asyncio decide (``False``). """ try: - asyncio.run(self._serve(host, port, socket=socket)) + asyncio.run( + self._serve( + host, + port, + socket=socket, + ssl_context=ssl_context, + backlog=backlog, + reuse_address=reuse_address, + reuse_port=reuse_port, + ) + ) except KeyboardInterrupt: pass - async def _serve(self, host: str, port: int, *, socket: str | None = None) -> None: + async def _serve( + self, + host: str, + port: int, + *, + socket: str | None = None, + ssl_context: ssl.SSLContext | None = None, + backlog: int | None = None, + reuse_address: bool | None = None, + reuse_port: bool | None = None, + ) -> None: """Internal async server loop.""" self._shutdown_event = asyncio.Event() self._loop = asyncio.get_running_loop() @@ -1467,19 +1503,31 @@ async def _serve(self, host: str, port: int, *, socket: str | None = None) -> No await self._run_startup_hooks() if socket: - server = await self._start_unix_socket(socket) + server = await self._start_unix_socket( + socket, ssl_context=ssl_context, backlog=backlog + ) else: + kwargs: dict[str, Any] = {} + if backlog is not None: + kwargs["backlog"] = backlog + if reuse_address is not None: + kwargs["reuse_address"] = reuse_address + if reuse_port is not None: + kwargs["reuse_port"] = reuse_port server = await asyncio.start_server( self._handle_connection, host, port, + ssl=ssl_context, + **kwargs, ) addrs = ( server.sockets[0].getsockname() if server.sockets else (host, port) ) self.host = addrs[0] self.port = addrs[1] - logger.info("Serving on %s:%d", self.host, self.port) + scheme = "https" if ssl_context else "http" + logger.info("Serving on %s://%s:%d", scheme, self.host, self.port) self._server = server @@ -1497,7 +1545,13 @@ async def _serve(self, host: str, port: int, *, socket: str | None = None) -> No # Run shutdown hooks after server stops accepting connections await self._run_shutdown_hooks() - async def _start_unix_socket(self, socket_path: str) -> asyncio.Server: + async def _start_unix_socket( + self, + socket_path: str, + *, + ssl_context: ssl.SSLContext | None = None, + backlog: int | None = None, + ) -> asyncio.Server: """Start listening on a Unix domain socket. Handles stale socket cleanup, permission hardening (``0o600``), @@ -1505,6 +1559,10 @@ async def _start_unix_socket(self, socket_path: str) -> asyncio.Server: Args: socket_path: Path for the Unix domain socket file. + ssl_context: Optional :class:`ssl.SSLContext` for TLS over + the Unix socket. + backlog: Maximum number of queued connections. ``None`` + leaves the OS default. Returns: The ``asyncio.Server`` instance. @@ -1537,9 +1595,14 @@ async def _start_unix_socket(self, socket_path: str) -> asyncio.Server: logger.error("Socket parent directory does not exist: %s", parent) sys.exit(1) + kwargs: dict[str, Any] = {} + if backlog is not None: + kwargs["backlog"] = backlog server = await asyncio.start_unix_server( self._handle_connection, path=path, + ssl=ssl_context, + **kwargs, ) # Restrict permissions to owner-only @@ -1547,7 +1610,8 @@ async def _start_unix_socket(self, socket_path: str) -> asyncio.Server: os.chmod(path, 0o600) self._socket_path = path - logger.info("Serving on unix:%s (mode 0600)", path) + scheme = "https+unix" if ssl_context else "unix" + logger.info("Serving on %s:%s (mode 0600)", scheme, path) return server def _cleanup_socket(self) -> None: diff --git a/httpserver/test_httpserver_tls.py b/httpserver/test_httpserver_tls.py new file mode 100644 index 0000000..281d26f --- /dev/null +++ b/httpserver/test_httpserver_tls.py @@ -0,0 +1,323 @@ +"""Tests for TLS support in httpserver.""" + +from __future__ import annotations + +import asyncio +import os +import shutil +import socket as socket_mod +import ssl +import subprocess +import sys + +import pytest + +from httpserver import App, JSONResponse + +pytestmark = pytest.mark.skipif( + shutil.which("openssl") is None, reason="openssl CLI not found" +) + + +async def _wait_ready( + app: App, *, socket_path: str | None = None, timeout: float = 5 +) -> None: + """Poll until the server is ready to accept connections.""" + loop = asyncio.get_event_loop() + deadline = loop.time() + timeout + while loop.time() < deadline: + if socket_path is not None: + if os.path.exists(socket_path): + return + elif app.port is not None: + return + await asyncio.sleep(0.02) + raise TimeoutError("Server did not become ready") + + +def _generate_self_signed_cert(cert_path: str, key_path: str) -> None: + """Generate a self-signed certificate using openssl CLI.""" + subprocess.run( + [ + "openssl", + "req", + "-x509", + "-newkey", + "rsa:2048", + "-keyout", + key_path, + "-out", + cert_path, + "-days", + "1", + "-nodes", + "-subj", + "/CN=localhost", + "-addext", + "subjectAltName=DNS:localhost,IP:127.0.0.1", + ], + check=True, + capture_output=True, + ) + + +@pytest.fixture(scope="module") +def tls_certs(tmp_path_factory): + """Generate a temporary self-signed cert/key pair.""" + d = tmp_path_factory.mktemp("tls") + cert_path = str(d / "cert.pem") + key_path = str(d / "key.pem") + _generate_self_signed_cert(cert_path, key_path) + return cert_path, key_path + + +@pytest.fixture(scope="module") +def server_ssl_context(tls_certs): + """Create a server-side SSLContext.""" + cert_path, key_path = tls_certs + ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER) + ctx.load_cert_chain(cert_path, key_path) + return ctx + + +@pytest.fixture(scope="module") +def client_ssl_context(tls_certs): + """Create a client-side SSLContext that trusts the self-signed cert.""" + cert_path, _ = tls_certs + ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT) + ctx.load_verify_locations(cert_path) + return ctx + + +@pytest.fixture +def app(): + """Create a simple test app.""" + app = App() + + @app.route("/health") + async def health(request): + return JSONResponse({"status": "ok"}) + + @app.route("/echo", methods=["POST"]) + async def echo(request): + return JSONResponse(request.json()) + + return app + + +class TestTLSServe: + """Test TLS server lifecycle and connections.""" + + @pytest.mark.asyncio + async def test_tls_accepts_https_connection( + self, app, server_ssl_context, client_ssl_context + ): + """Server with ssl_context accepts TLS connections.""" + serve_task = asyncio.create_task( + app._serve("127.0.0.1", 0, ssl_context=server_ssl_context) + ) + await _wait_ready(app) + try: + reader, writer = await asyncio.open_connection( + "127.0.0.1", app.port, ssl=client_ssl_context + ) + writer.write(b"GET /health HTTP/1.1\r\nHost: localhost\r\n\r\n") + await writer.drain() + + response = await asyncio.wait_for(reader.read(4096), timeout=5) + assert b"200 OK" in response + assert b'"status": "ok"' in response + + writer.close() + await writer.wait_closed() + finally: + app.shutdown() + await serve_task + + @pytest.mark.asyncio + async def test_tls_rejects_plaintext(self, app, server_ssl_context): + """TLS server rejects plain HTTP connections.""" + serve_task = asyncio.create_task( + app._serve("127.0.0.1", 0, ssl_context=server_ssl_context) + ) + await _wait_ready(app) + try: + reader, writer = await asyncio.open_connection("127.0.0.1", app.port) + writer.write(b"GET /health HTTP/1.1\r\nHost: localhost\r\n\r\n") + await writer.drain() + + response = await asyncio.wait_for(reader.read(4096), timeout=2) + assert response == b"" or b"200 OK" not in response + + writer.close() + await writer.wait_closed() + finally: + app.shutdown() + await serve_task + + @pytest.mark.asyncio + async def test_tls_post_request(self, app, server_ssl_context, client_ssl_context): + """POST with JSON body works over TLS.""" + serve_task = asyncio.create_task( + app._serve("127.0.0.1", 0, ssl_context=server_ssl_context) + ) + await _wait_ready(app) + try: + reader, writer = await asyncio.open_connection( + "127.0.0.1", app.port, ssl=client_ssl_context + ) + body = b'{"msg":"hello"}' + request = ( + b"POST /echo HTTP/1.1\r\n" + b"Host: localhost\r\n" + b"Content-Type: application/json\r\n" + b"Content-Length: " + str(len(body)).encode() + b"\r\n" + b"\r\n" + body + ) + writer.write(request) + await writer.drain() + + response = await asyncio.wait_for(reader.read(4096), timeout=5) + assert b"200 OK" in response + assert b'"msg": "hello"' in response + + writer.close() + await writer.wait_closed() + finally: + app.shutdown() + await serve_task + + @pytest.mark.asyncio + async def test_no_ssl_context_serves_plaintext(self, app): + """Without ssl_context, server accepts plain HTTP (default behavior).""" + serve_task = asyncio.create_task(app._serve("127.0.0.1", 0)) + await _wait_ready(app) + try: + reader, writer = await asyncio.open_connection("127.0.0.1", app.port) + writer.write(b"GET /health HTTP/1.1\r\nHost: localhost\r\n\r\n") + await writer.drain() + + response = await asyncio.wait_for(reader.read(4096), timeout=5) + assert b"200 OK" in response + + writer.close() + await writer.wait_closed() + finally: + app.shutdown() + await serve_task + + +class TestSocketTuning: + """Test backlog, reuse_address, reuse_port parameters.""" + + @pytest.mark.asyncio + async def test_custom_backlog(self, app): + """Server starts with custom backlog value.""" + serve_task = asyncio.create_task(app._serve("127.0.0.1", 0, backlog=2048)) + await _wait_ready(app) + try: + reader, writer = await asyncio.open_connection("127.0.0.1", app.port) + writer.write(b"GET /health HTTP/1.1\r\nHost: localhost\r\n\r\n") + await writer.drain() + + response = await asyncio.wait_for(reader.read(4096), timeout=5) + assert b"200 OK" in response + + writer.close() + await writer.wait_closed() + finally: + app.shutdown() + await serve_task + + @pytest.mark.asyncio + async def test_reuse_address(self, app): + """Server starts with reuse_address=True.""" + serve_task = asyncio.create_task(app._serve("127.0.0.1", 0, reuse_address=True)) + await _wait_ready(app) + try: + reader, writer = await asyncio.open_connection("127.0.0.1", app.port) + writer.write(b"GET /health HTTP/1.1\r\nHost: localhost\r\n\r\n") + await writer.drain() + + response = await asyncio.wait_for(reader.read(4096), timeout=5) + assert b"200 OK" in response + + writer.close() + await writer.wait_closed() + finally: + app.shutdown() + await serve_task + + @pytest.mark.asyncio + async def test_combined_tls_and_tuning( + self, app, server_ssl_context, client_ssl_context + ): + """TLS and socket tuning parameters work together.""" + serve_task = asyncio.create_task( + app._serve( + "127.0.0.1", + 0, + ssl_context=server_ssl_context, + backlog=512, + reuse_address=True, + ) + ) + await _wait_ready(app) + try: + reader, writer = await asyncio.open_connection( + "127.0.0.1", app.port, ssl=client_ssl_context + ) + writer.write(b"GET /health HTTP/1.1\r\nHost: localhost\r\n\r\n") + await writer.drain() + + response = await asyncio.wait_for(reader.read(4096), timeout=5) + assert b"200 OK" in response + + writer.close() + await writer.wait_closed() + finally: + app.shutdown() + await serve_task + + +@pytest.fixture +def socket_path(tmp_path): + """Return a path for a Unix socket in a temp directory.""" + return str(tmp_path / "tls_test.sock") + + +@pytest.mark.skipif( + sys.platform == "win32", reason="Unix sockets not available on Windows" +) +class TestTLSUnixSocket: + """Test TLS over Unix domain sockets.""" + + @pytest.mark.asyncio + async def test_tls_over_unix_socket( + self, app, socket_path, server_ssl_context, client_ssl_context + ): + """TLS works over Unix domain sockets.""" + serve_task = asyncio.create_task( + app._serve("", 0, socket=socket_path, ssl_context=server_ssl_context) + ) + await _wait_ready(app, socket_path=socket_path) + try: + raw_sock = socket_mod.socket(socket_mod.AF_UNIX, socket_mod.SOCK_STREAM) + raw_sock.connect(socket_path) + raw_sock.setblocking(False) + + reader, writer = await asyncio.open_connection( + sock=raw_sock, ssl=client_ssl_context, server_hostname="localhost" + ) + writer.write(b"GET /health HTTP/1.1\r\nHost: localhost\r\n\r\n") + await writer.drain() + + response = await asyncio.wait_for(reader.read(4096), timeout=5) + assert b"200 OK" in response + assert b'"status": "ok"' in response + + writer.close() + await writer.wait_closed() + finally: + app.shutdown() + await serve_task diff --git a/manifest.json b/manifest.json index 3848d13..4f76842 100644 --- a/manifest.json +++ b/manifest.json @@ -1,6 +1,6 @@ { "version": "1", - "generated": "2026-09-15T01:26:49.606515+00:00", + "generated": "2026-09-15T05:11:46.731585+00:00", "modules": { "a2a": { "description": "A2A (Agent-to-Agent Protocol) - Zero-dependency Python implementation", @@ -179,8 +179,8 @@ "deps": [], "tier": "subsystem", "category": "network", - "last_updated": "2026-09-14T17:25:37-05:00", - "content_hash": "4f82dddf858711a6ac0019104a24ef3f73e98997072fc945ecd03ad72312749d" + "last_updated": "2026-09-14T19:04:23-05:00", + "content_hash": "4983837f8402eba1a9f7f5f5a48c7ee4eac27f893a35119c888a3d78f8c69465" }, "jsonrpc": { "description": "JSON-RPC 2.0 -- Zero-dependency Python implementation", @@ -287,8 +287,8 @@ "deps": [], "tier": "medium", "category": "devtools", - "last_updated": "2026-09-14T18:40:27-05:00", - "content_hash": "3c9a99462020857e4adc5ace31813336f5537a450f50bf295ddc5258da92cc6b" + "last_updated": "2026-09-14T20:53:33-05:00", + "content_hash": "b9bd9b27691eae5384bc5deceb1b3d008bf9f33b964bb0d28f208a8b5bdcf01a" }, "prompt": { "description": "Zero-dependency interactive CLI prompts (confirm, select, text)", diff --git a/pyproject.toml b/pyproject.toml index e29d9e2..7d088b2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -201,6 +201,7 @@ max-complexity = 20 "diff/diff.py" = ["E501"] "vcs/vcs.py" = ["E501"] "httpserver/test_httpserver_unix_socket.py" = ["ASYNC240"] +"httpserver/test_httpserver_tls.py" = ["ASYNC240"] "xml/xml.py" = ["E501"] "a2a/a2a.py" = ["E501"] "acp/acp.py" = ["E501"]