Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
76 changes: 70 additions & 6 deletions httpserver/httpserver.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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).

Expand All @@ -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()
Expand All @@ -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

Expand All @@ -1497,14 +1545,24 @@ 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``),
and registers the path for shutdown cleanup.

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.
Expand Down Expand Up @@ -1537,17 +1595,23 @@ 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
if os.path.exists(path):
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:
Expand Down
Loading
Loading