From 198381d9cedfe4fadff167d30d2d1b9877330bd4 Mon Sep 17 00:00:00 2001 From: Kangyan-Zhou Date: Wed, 4 Mar 2026 19:27:16 -0800 Subject: [PATCH] Add SSL/TLS support for HTTP and gRPC servers (#18973) Co-authored-by: guys@spotify.com --- python/pyproject.toml | 1 + .../multimodal_gen/runtime/server_args.py | 11 +- python/sglang/srt/entrypoints/grpc_server.py | 126 +++++++++++- python/sglang/srt/entrypoints/http_server.py | 84 +++++++- .../srt/entrypoints/http_server_engine.py | 12 +- python/sglang/srt/entrypoints/ssl_utils.py | 88 +++++++++ python/sglang/srt/server_args.py | 123 +++++++++++- test/registered/core/test_server_args.py | 181 ++++++++++++++++++ .../core/test_ssl_cert_refresher.py | 165 ++++++++++++++++ 9 files changed, 770 insertions(+), 21 deletions(-) create mode 100644 python/sglang/srt/entrypoints/ssl_utils.py create mode 100644 test/registered/core/test_ssl_cert_refresher.py diff --git a/python/pyproject.toml b/python/pyproject.toml index 65a07c88a..e0c6e8863 100755 --- a/python/pyproject.toml +++ b/python/pyproject.toml @@ -73,6 +73,7 @@ dependencies = [ "transformers==4.57.1", "uvicorn", "uvloop", + "watchfiles", "xgrammar==0.1.27", "smg-grpc-proto>=0.4.1", diff --git a/python/sglang/multimodal_gen/runtime/server_args.py b/python/sglang/multimodal_gen/runtime/server_args.py index 85ee65de5..246aa3ec3 100644 --- a/python/sglang/multimodal_gen/runtime/server_args.py +++ b/python/sglang/multimodal_gen/runtime/server_args.py @@ -929,10 +929,15 @@ class ServerArgs: return parser def url(self): - if is_valid_ipv6_address(self.host): - return f"http://[{self.host}]:{self.port}" + host = self.host + if not host or host == "0.0.0.0": + host = "127.0.0.1" + elif host == "::": + host = "::1" + if is_valid_ipv6_address(host): + return f"http://[{host}]:{self.port}" else: - return f"http://{self.host}:{self.port}" + return f"http://{host}:{self.port}" @property def scheduler_endpoint(self): diff --git a/python/sglang/srt/entrypoints/grpc_server.py b/python/sglang/srt/entrypoints/grpc_server.py index 7f6b2b696..b2558841b 100644 --- a/python/sglang/srt/entrypoints/grpc_server.py +++ b/python/sglang/srt/entrypoints/grpc_server.py @@ -1050,10 +1050,132 @@ async def serve_grpc( # Start server listen_addr = f"{server_args.host}:{server_args.port}" - server.add_insecure_port(listen_addr) + if server_args.ssl_certfile and server_args.ssl_keyfile: + if server_args.ssl_keyfile_password: + raise ValueError( + "gRPC mode does not support encrypted SSL key files " + "(--ssl-keyfile-password). Please provide an unencrypted key " + "file when using --grpc-mode." + ) + + def _read_ssl_file(filepath: str, description: str) -> bytes: + try: + with open(filepath, "rb") as f: + return f.read() + except OSError as e: + raise ValueError( + f"Failed to read {description} '{filepath}': {e}" + ) from e + + private_key = _read_ssl_file(server_args.ssl_keyfile, "SSL key file") + certificate_chain = _read_ssl_file( + server_args.ssl_certfile, "SSL certificate file" + ) + root_certificates = None + if server_args.ssl_ca_certs: + root_certificates = _read_ssl_file( + server_args.ssl_ca_certs, "SSL CA certificates file" + ) + + if server_args.enable_ssl_refresh: + # Use dynamic credentials so gRPC re-reads certs on each + # new connection via the fetcher callback. + _cert_mtime = os.path.getmtime(server_args.ssl_certfile) + _key_mtime = os.path.getmtime(server_args.ssl_keyfile) + _ca_mtime = ( + os.path.getmtime(server_args.ssl_ca_certs) + if server_args.ssl_ca_certs + else None + ) + + def _cert_config_fetcher(): + nonlocal _cert_mtime, _key_mtime, _ca_mtime + try: + new_cert_mt = os.path.getmtime(server_args.ssl_certfile) + new_key_mt = os.path.getmtime(server_args.ssl_keyfile) + new_ca_mt = ( + os.path.getmtime(server_args.ssl_ca_certs) + if server_args.ssl_ca_certs + else None + ) + + if ( + new_cert_mt == _cert_mtime + and new_key_mt == _key_mtime + and new_ca_mt == _ca_mtime + ): + return None # No change + + new_key = _read_ssl_file(server_args.ssl_keyfile, "SSL key file") + new_cert = _read_ssl_file( + server_args.ssl_certfile, "SSL certificate file" + ) + new_root = None + if server_args.ssl_ca_certs: + new_root = _read_ssl_file( + server_args.ssl_ca_certs, + "SSL CA certificates file", + ) + + logger.info("gRPC SSL certificate change detected, reloading.") + config = grpc.ssl_server_certificate_configuration( + [(new_key, new_cert)], + root_certificates=new_root, + ) + + # Update mtimes only after successful reload + _cert_mtime = new_cert_mt + _key_mtime = new_key_mt + _ca_mtime = new_ca_mt + + return config + except Exception: + logger.exception( + "Failed to reload gRPC SSL certificates — " + "continuing with previous certificates." + ) + return None + + try: + initial_config = grpc.ssl_server_certificate_configuration( + [(private_key, certificate_chain)], + root_certificates=root_certificates, + ) + credentials = grpc.dynamic_ssl_server_credentials( + initial_config, + _cert_config_fetcher, + ) + except Exception as e: + raise ValueError( + f"Failed to create gRPC dynamic SSL credentials. " + f"Verify that --ssl-keyfile and --ssl-certfile contain " + f"valid, matching PEM data. Underlying error: {e}" + ) from e + logger.info("gRPC SSL certificate auto-refresh enabled.") + else: + try: + credentials = grpc.ssl_server_credentials( + [(private_key, certificate_chain)], # pairs: (key, cert) + root_certificates=root_certificates, + ) + except Exception as e: + raise ValueError( + f"Failed to create gRPC SSL credentials. Verify that " + f"--ssl-keyfile and --ssl-certfile contain valid, matching " + f"PEM data. Underlying error: {e}" + ) from e + bound_port = server.add_secure_port(listen_addr, credentials) + if bound_port == 0: + raise RuntimeError( + f"Failed to bind gRPC TLS server to {listen_addr}. " + f"Check that the port is available and SSL credentials are valid." + ) + logger.info(f"gRPC server (TLS) listening on {listen_addr}") + else: + server.add_insecure_port(listen_addr) + logger.info(f"gRPC server listening on {listen_addr}") await server.start() - logger.info(f"gRPC server listening on {listen_addr}") # Start warmup in a separate thread warmup_thread = threading.Thread( diff --git a/python/sglang/srt/entrypoints/http_server.py b/python/sglang/srt/entrypoints/http_server.py index 200a67ce6..fe0f23a5b 100644 --- a/python/sglang/srt/entrypoints/http_server.py +++ b/python/sglang/srt/entrypoints/http_server.py @@ -1756,12 +1756,16 @@ def _execute_server_warmup(server_args: ServerArgs): if server_args.api_key: headers["Authorization"] = f"Bearer {server_args.api_key}" + ssl_verify = server_args.ssl_verify() + # Wait until the server is launched success = False for _ in range(120): time.sleep(1) try: - res = requests.get(url + "/model_info", timeout=5, headers=headers) + res = requests.get( + url + "/model_info", timeout=5, headers=headers, verify=ssl_verify + ) assert res.status_code == 200, f"{res=}, {res.text=}" success = True break @@ -1850,6 +1854,7 @@ def _execute_server_warmup(server_args: ServerArgs): json=json_data, headers=headers, timeout=warmup_timeout if warmup_timeout > 0 else 600, + verify=ssl_verify, ) assert res.status_code == 200, f"{res.text}" _global_state.tokenizer_manager.server_status = ServerStatus.Up @@ -1878,6 +1883,7 @@ def _execute_server_warmup(server_args: ServerArgs): timeout=( warmup_timeout if warmup_timeout > 0 else 1800 ), # because of deep gemm precache is very long if not precache. + verify=ssl_verify, ) if res.status_code == 200: logger.info( @@ -2057,17 +2063,64 @@ def launch_server( # while model/subprocess initialization is still in progress. reserved_socket.listen(128) + if server_args.ssl_certfile: + logger.info( + f"SSL enabled: certfile={server_args.ssl_certfile}, " + f"keyfile={server_args.ssl_keyfile}" + ) + # Listen for HTTP requests if server_args.tokenizer_worker_num == 1: - # Default case, one tokenizer process - uvicorn.run( - app, - fd=reserved_socket.fileno(), - root_path=server_args.fastapi_root_path, - log_level=server_args.log_level_http or server_args.log_level, - timeout_keep_alive=5, - loop="uvloop", - ) + if server_args.enable_ssl_refresh: + # Use Config/Server API for access to the SSLContext. + config = uvicorn.Config( + app, + fd=reserved_socket.fileno(), + root_path=server_args.fastapi_root_path, + log_level=server_args.log_level_http or server_args.log_level, + timeout_keep_alive=5, + loop="uvloop", + ssl_keyfile=server_args.ssl_keyfile, + ssl_certfile=server_args.ssl_certfile, + ssl_ca_certs=server_args.ssl_ca_certs, + ssl_keyfile_password=server_args.ssl_keyfile_password, + ) + config.load() # Creates the SSLContext + + from sglang.srt.entrypoints.ssl_utils import SSLCertRefresher + + server = uvicorn.Server(config) + + async def _run_with_ssl_refresh(): + refresher = SSLCertRefresher( + config.ssl, + server_args.ssl_keyfile, + server_args.ssl_certfile, + server_args.ssl_ca_certs, + ) + logger.info("SSL certificate auto-refresh enabled.") + try: + await server.serve() + finally: + refresher.stop() + + import asyncio + + asyncio.run(_run_with_ssl_refresh()) + else: + # Default case, one tokenizer process + uvicorn.run( + app, + fd=reserved_socket.fileno(), + root_path=server_args.fastapi_root_path, + log_level=server_args.log_level_http or server_args.log_level, + timeout_keep_alive=5, + loop="uvloop", + ssl_keyfile=server_args.ssl_keyfile, + ssl_certfile=server_args.ssl_certfile, + ssl_ca_certs=server_args.ssl_ca_certs, + ssl_keyfile_password=server_args.ssl_keyfile_password, + ) else: # Multiple tokenizer and http processes from uvicorn.config import LOGGING_CONFIG @@ -2079,6 +2132,13 @@ def launch_server( } monkey_patch_uvicorn_multiprocessing() + if server_args.enable_ssl_refresh: + logger.warning( + "--enable-ssl-refresh is not supported with multiple " + "tokenizer workers (--tokenizer-worker-num > 1). " + "SSL refresh will be disabled." + ) + uvicorn.run( "sglang.srt.entrypoints.http_server:app", fd=reserved_socket.fileno(), @@ -2087,6 +2147,10 @@ def launch_server( timeout_keep_alive=5, loop="uvloop", workers=server_args.tokenizer_worker_num, + ssl_keyfile=server_args.ssl_keyfile, + ssl_certfile=server_args.ssl_certfile, + ssl_ca_certs=server_args.ssl_ca_certs, + ssl_keyfile_password=server_args.ssl_keyfile_password, ) finally: # Close the reserved socket after uvicorn exits or on any error diff --git a/python/sglang/srt/entrypoints/http_server_engine.py b/python/sglang/srt/entrypoints/http_server_engine.py index 5faefa8de..cb6159836 100644 --- a/python/sglang/srt/entrypoints/http_server_engine.py +++ b/python/sglang/srt/entrypoints/http_server_engine.py @@ -20,6 +20,8 @@ def launch_server_process(server_args: ServerArgs) -> multiprocessing.Process: timeout = 300.0 # Increased timeout to 5 minutes for downloading large models start_time = time.perf_counter() + ssl_verify = server_args.ssl_verify() + with requests.Session() as session: while time.perf_counter() - start_time < timeout: try: @@ -27,7 +29,9 @@ def launch_server_process(server_args: ServerArgs) -> multiprocessing.Process: "Content-Type": "application/json; charset=utf-8", "Authorization": f"Bearer {server_args.api_key}", } - response = session.get(f"{base_url}/health_generate", headers=headers) + response = session.get( + f"{base_url}/health_generate", headers=headers, verify=ssl_verify + ) if response.status_code == 200: return p except requests.RequestException: @@ -64,8 +68,10 @@ class HttpServerEngineAdapter(EngineBase): Returns: The JSON response from the server """ - url = f"http://{self.server_args.host}:{self.server_args.port}/{endpoint}" - response = requests.post(url, json=payload or {}) + url = f"{self.server_args.url()}/{endpoint}" + response = requests.post( + url, json=payload or {}, verify=self.server_args.ssl_verify() + ) response.raise_for_status() return response.json() diff --git a/python/sglang/srt/entrypoints/ssl_utils.py b/python/sglang/srt/entrypoints/ssl_utils.py new file mode 100644 index 000000000..87e2b16f2 --- /dev/null +++ b/python/sglang/srt/entrypoints/ssl_utils.py @@ -0,0 +1,88 @@ +"""Utilities for SSL certificate hot-reloading.""" + +import asyncio +import logging +import ssl +from typing import Optional + +from watchfiles import awatch + +logger = logging.getLogger(__name__) + + +class SSLCertRefresher: + """Monitors SSL certificate files and reloads them when changed. + + Uses ``watchfiles.awatch()`` for efficient inotify/kqueue-based + file monitoring. On change the referenced :class:`ssl.SSLContext` + is updated in-place so that new TLS connections automatically pick + up the fresh certificates. + """ + + def __init__( + self, + ssl_context: ssl.SSLContext, + key_path: str, + cert_path: str, + ca_path: Optional[str] = None, + ) -> None: + self._ssl_context = ssl_context + self._key_path = key_path + self._cert_path = cert_path + self._ca_path = ca_path + self._tasks: list[asyncio.Task] = [] + + loop = asyncio.get_running_loop() + self._tasks.append( + loop.create_task(self._watch_cert_key(), name="ssl-cert-key-watcher") + ) + if self._ca_path: + self._tasks.append( + loop.create_task(self._watch_ca(), name="ssl-ca-watcher") + ) + + async def _watch_cert_key(self) -> None: + """Watch cert and key files and reload on change.""" + try: + async for _changes in awatch(self._cert_path, self._key_path): + logger.info( + "SSL cert/key file change detected, reloading: " "cert=%s key=%s", + self._cert_path, + self._key_path, + ) + try: + self._ssl_context.load_cert_chain(self._cert_path, self._key_path) + logger.info("SSL cert/key reloaded successfully.") + except Exception: + logger.exception( + "Failed to reload SSL cert/key — continuing with " + "previous certificates." + ) + except asyncio.CancelledError: + return + + async def _watch_ca(self) -> None: + """Watch CA file and reload on change.""" + assert self._ca_path is not None + try: + async for _changes in awatch(self._ca_path): + logger.info( + "SSL CA file change detected, reloading: ca=%s", + self._ca_path, + ) + try: + self._ssl_context.load_verify_locations(self._ca_path) + logger.info("SSL CA certificates reloaded successfully.") + except Exception: + logger.exception( + "Failed to reload SSL CA certificates — continuing " + "with previous CA bundle." + ) + except asyncio.CancelledError: + return + + def stop(self) -> None: + """Cancel all watching tasks.""" + for task in self._tasks: + task.cancel() + self._tasks.clear() diff --git a/python/sglang/srt/server_args.py b/python/sglang/srt/server_args.py index 9a2208a4c..53d660419 100644 --- a/python/sglang/srt/server_args.py +++ b/python/sglang/srt/server_args.py @@ -309,6 +309,13 @@ class ServerArgs: nccl_port: Optional[int] = None checkpoint_engine_wait_weights_before_ready: bool = False + # SSL/TLS + ssl_keyfile: Optional[str] = None + ssl_certfile: Optional[str] = None + ssl_ca_certs: Optional[str] = None + ssl_keyfile_password: Optional[str] = None + enable_ssl_refresh: bool = False + # Quantization and data type dtype: str = "auto" quantization: Optional[str] = None @@ -718,6 +725,9 @@ class ServerArgs: # Normalize load balancing defaults early (before dummy-model short-circuit). self._handle_load_balance_method() + # Validate SSL arguments early (before dummy-model short-circuit). + self._handle_ssl_validation() + if self.model_path.lower() in ["none", "dummy"]: # Skip for dummy models return @@ -827,6 +837,47 @@ class ServerArgs: ) return + def _handle_ssl_validation(self): + """Ensure SSL arguments are consistent and referenced files exist.""" + if self.ssl_keyfile and not self.ssl_certfile: + raise ValueError( + "--ssl-keyfile requires --ssl-certfile to be specified as well." + ) + if self.ssl_certfile and not self.ssl_keyfile: + raise ValueError( + "--ssl-certfile requires --ssl-keyfile to be specified as well." + ) + if not self.ssl_certfile and not self.ssl_keyfile: + if self.ssl_ca_certs: + raise ValueError( + "--ssl-ca-certs has no effect without --ssl-certfile and --ssl-keyfile." + ) + if self.ssl_keyfile_password: + raise ValueError( + "--ssl-keyfile-password has no effect without --ssl-certfile and --ssl-keyfile." + ) + # Validate files exist early to avoid late failures after model loading. + if self.ssl_keyfile and not os.path.isfile(self.ssl_keyfile): + raise ValueError( + f"SSL key file not found: '{self.ssl_keyfile}'. " + f"Please check the --ssl-keyfile path." + ) + if self.ssl_certfile and not os.path.isfile(self.ssl_certfile): + raise ValueError( + f"SSL certificate file not found: '{self.ssl_certfile}'. " + f"Please check the --ssl-certfile path." + ) + if self.ssl_ca_certs and not os.path.isfile(self.ssl_ca_certs): + raise ValueError( + f"SSL CA certificates file not found: '{self.ssl_ca_certs}'. " + f"Please check the --ssl-ca-certs path." + ) + if self.enable_ssl_refresh and not (self.ssl_certfile and self.ssl_keyfile): + raise ValueError( + "--enable-ssl-refresh requires --ssl-certfile and --ssl-keyfile " + "to be specified." + ) + def _handle_deprecated_args(self): # Handle deprecated tool call parsers deprecated_tool_call_parsers = {"qwen25": "qwen", "glm45": "glm"} @@ -3237,6 +3288,39 @@ class ServerArgs: "before serving inference requests.", ) + # SSL/TLS + parser.add_argument( + "--ssl-keyfile", + type=str, + default=ServerArgs.ssl_keyfile, + help="The file path to the SSL key file.", + ) + parser.add_argument( + "--ssl-certfile", + type=str, + default=ServerArgs.ssl_certfile, + help="The file path to the SSL certificate file.", + ) + parser.add_argument( + "--ssl-ca-certs", + type=str, + default=ServerArgs.ssl_ca_certs, + help="The CA certificates file.", + ) + parser.add_argument( + "--ssl-keyfile-password", + type=str, + default=ServerArgs.ssl_keyfile_password, + help="The password to decrypt the SSL keyfile.", + ) + parser.add_argument( + "--enable-ssl-refresh", + action="store_true", + default=ServerArgs.enable_ssl_refresh, + help="Enable automatic SSL certificate hot-reloading when cert/key " + "files change on disk. Requires --ssl-certfile and --ssl-keyfile.", + ) + # Quantization and data type parser.add_argument( "--dtype", @@ -5337,10 +5421,43 @@ class ServerArgs: return cls(**{attr: getattr(args, attr) for attr in attrs}) def url(self): - if is_valid_ipv6_address(self.host): - return f"http://[{self.host}]:{self.port}" + scheme = "https" if self.ssl_certfile else "http" + # When binding to all interfaces, use loopback for internal requests. + host = self.host + if not host or host == "0.0.0.0": + host = "127.0.0.1" + elif host == "::": + host = "::1" + if is_valid_ipv6_address(host): + return f"{scheme}://[{host}]:{self.port}" else: - return f"http://{self.host}:{self.port}" + return f"{scheme}://{host}:{self.port}" + + def ssl_verify(self): + """Return the value for the requests library's ``verify=`` parameter. + + When SSL is configured: + - If a CA certificate file is provided, return its path so requests + validates the server certificate against that CA. + - Otherwise, return False to disable certificate verification + (suitable for self-signed certificates in development/testing). + A warning is logged once when this happens. + When SSL is not configured, return True to use the system's default + CA bundle. + """ + if self.ssl_ca_certs: + return self.ssl_ca_certs + if self.ssl_certfile: + if not getattr(self, "_ssl_verify_warned", False): + logger.warning( + "SSL is enabled but --ssl-ca-certs was not provided. " + "Certificate verification is DISABLED for internal " + "health checks. For production deployments, provide " + "--ssl-ca-certs or use CA-signed certificates." + ) + self._ssl_verify_warned = True + return False + return True def get_model_config(self): # Lazy init to avoid circular import diff --git a/test/registered/core/test_server_args.py b/test/registered/core/test_server_args.py index d7382d0dd..e50c59a27 100644 --- a/test/registered/core/test_server_args.py +++ b/test/registered/core/test_server_args.py @@ -1,4 +1,5 @@ import json +import tempfile import unittest from unittest.mock import MagicMock, patch @@ -266,5 +267,185 @@ class TestPortArgs(unittest.TestCase): self.assertIn("expected ':' after ']'", str(context.exception)) +class TestSSLArgs(unittest.TestCase): + def test_default_ssl_fields_are_none(self): + server_args = ServerArgs(model_path="dummy") + self.assertIsNone(server_args.ssl_keyfile) + self.assertIsNone(server_args.ssl_certfile) + self.assertIsNone(server_args.ssl_ca_certs) + self.assertIsNone(server_args.ssl_keyfile_password) + + def test_ssl_keyfile_without_certfile_raises(self): + with self.assertRaises(ValueError) as context: + ServerArgs(model_path="dummy", ssl_keyfile="key.pem") + self.assertIn("--ssl-certfile", str(context.exception)) + + def test_ssl_certfile_without_keyfile_raises(self): + with self.assertRaises(ValueError) as context: + ServerArgs(model_path="dummy", ssl_certfile="cert.pem") + self.assertIn("--ssl-keyfile", str(context.exception)) + + @patch("os.path.isfile", return_value=True) + def test_ssl_both_keyfile_and_certfile_accepted(self, _mock_isfile): + server_args = ServerArgs( + model_path="dummy", ssl_keyfile="key.pem", ssl_certfile="cert.pem" + ) + self.assertEqual(server_args.ssl_keyfile, "key.pem") + self.assertEqual(server_args.ssl_certfile, "cert.pem") + + def test_url_returns_http_without_ssl(self): + server_args = ServerArgs(model_path="dummy") + self.assertTrue(server_args.url().startswith("http://")) + + def test_url_rewrites_all_interfaces_to_loopback(self): + server_args = ServerArgs(model_path="dummy", host="0.0.0.0") + self.assertEqual(server_args.url(), "http://127.0.0.1:30000") + + def test_url_rewrites_empty_host_to_loopback(self): + server_args = ServerArgs(model_path="dummy", host="") + self.assertEqual(server_args.url(), "http://127.0.0.1:30000") + + def test_url_rewrites_ipv6_all_interfaces_to_loopback(self): + server_args = ServerArgs(model_path="dummy", host="::") + self.assertEqual(server_args.url(), "http://[::1]:30000") + + @patch("os.path.isfile", return_value=True) + def test_url_returns_https_with_ssl(self, _mock_isfile): + server_args = ServerArgs( + model_path="dummy", ssl_keyfile="key.pem", ssl_certfile="cert.pem" + ) + self.assertTrue(server_args.url().startswith("https://")) + + @patch("os.path.isfile", return_value=True) + def test_ssl_cli_args_parsed(self, _mock_isfile): + server_args = prepare_server_args( + [ + "--model-path", + "dummy", + "--ssl-keyfile", + "key.pem", + "--ssl-certfile", + "cert.pem", + "--ssl-ca-certs", + "ca.pem", + "--ssl-keyfile-password", + "secret", + ] + ) + self.assertEqual(server_args.ssl_keyfile, "key.pem") + self.assertEqual(server_args.ssl_certfile, "cert.pem") + self.assertEqual(server_args.ssl_ca_certs, "ca.pem") + self.assertEqual(server_args.ssl_keyfile_password, "secret") + + def test_ssl_verify_without_ssl(self): + server_args = ServerArgs(model_path="dummy") + self.assertIs(server_args.ssl_verify(), True) + + @patch("os.path.isfile", return_value=True) + def test_ssl_verify_with_ssl_no_ca(self, _mock_isfile): + server_args = ServerArgs( + model_path="dummy", ssl_keyfile="key.pem", ssl_certfile="cert.pem" + ) + self.assertIs(server_args.ssl_verify(), False) + + @patch("os.path.isfile", return_value=True) + def test_ssl_verify_with_ssl_and_ca(self, _mock_isfile): + server_args = ServerArgs( + model_path="dummy", + ssl_keyfile="key.pem", + ssl_certfile="cert.pem", + ssl_ca_certs="ca.pem", + ) + self.assertEqual(server_args.ssl_verify(), "ca.pem") + + def test_ssl_ca_certs_without_certfile_raises(self): + with self.assertRaises(ValueError) as context: + ServerArgs(model_path="dummy", ssl_ca_certs="ca.pem") + self.assertIn("--ssl-ca-certs", str(context.exception)) + + def test_ssl_keyfile_password_without_certfile_raises(self): + with self.assertRaises(ValueError) as context: + ServerArgs(model_path="dummy", ssl_keyfile_password="secret") + self.assertIn("--ssl-keyfile-password", str(context.exception)) + + def test_ssl_keyfile_not_found_raises(self): + with self.assertRaises(ValueError) as context: + ServerArgs( + model_path="dummy", + ssl_keyfile="/nonexistent/key.pem", + ssl_certfile="/nonexistent/cert.pem", + ) + self.assertIn("not found", str(context.exception)) + + def test_ssl_certfile_not_found_raises(self): + with tempfile.NamedTemporaryFile(suffix=".pem") as keyfile: + with self.assertRaises(ValueError) as context: + ServerArgs( + model_path="dummy", + ssl_keyfile=keyfile.name, + ssl_certfile="/nonexistent/cert.pem", + ) + self.assertIn("SSL certificate file not found", str(context.exception)) + + def test_ssl_ca_certs_not_found_raises(self): + with tempfile.NamedTemporaryFile(suffix=".pem") as keyfile: + with tempfile.NamedTemporaryFile(suffix=".pem") as certfile: + with self.assertRaises(ValueError) as context: + ServerArgs( + model_path="dummy", + ssl_keyfile=keyfile.name, + ssl_certfile=certfile.name, + ssl_ca_certs="/nonexistent/ca.pem", + ) + self.assertIn( + "SSL CA certificates file not found", str(context.exception) + ) + + @patch("os.path.isfile", return_value=True) + def test_url_returns_https_with_ssl_and_ipv6(self, _mock_isfile): + server_args = ServerArgs( + model_path="dummy", + host="::1", + ssl_keyfile="key.pem", + ssl_certfile="cert.pem", + ) + self.assertEqual(server_args.url(), "https://[::1]:30000") + + def test_enable_ssl_refresh_default_false(self): + server_args = ServerArgs(model_path="dummy") + self.assertFalse(server_args.enable_ssl_refresh) + + def test_enable_ssl_refresh_without_ssl_raises(self): + with self.assertRaises(ValueError) as context: + ServerArgs(model_path="dummy", enable_ssl_refresh=True) + self.assertIn("--enable-ssl-refresh", str(context.exception)) + self.assertIn("--ssl-certfile", str(context.exception)) + + @patch("os.path.isfile", return_value=True) + def test_enable_ssl_refresh_with_ssl_accepted(self, _mock_isfile): + server_args = ServerArgs( + model_path="dummy", + ssl_keyfile="key.pem", + ssl_certfile="cert.pem", + enable_ssl_refresh=True, + ) + self.assertTrue(server_args.enable_ssl_refresh) + + @patch("os.path.isfile", return_value=True) + def test_enable_ssl_refresh_cli_flag(self, _mock_isfile): + server_args = prepare_server_args( + [ + "--model-path", + "dummy", + "--ssl-keyfile", + "key.pem", + "--ssl-certfile", + "cert.pem", + "--enable-ssl-refresh", + ] + ) + self.assertTrue(server_args.enable_ssl_refresh) + + if __name__ == "__main__": unittest.main() diff --git a/test/registered/core/test_ssl_cert_refresher.py b/test/registered/core/test_ssl_cert_refresher.py new file mode 100644 index 000000000..c34a8a738 --- /dev/null +++ b/test/registered/core/test_ssl_cert_refresher.py @@ -0,0 +1,165 @@ +import asyncio +import os +import tempfile +import unittest +from unittest.mock import MagicMock + +from sglang.srt.entrypoints.ssl_utils import SSLCertRefresher +from sglang.test.ci.ci_register import register_cpu_ci +from sglang.test.test_utils import CustomTestCase + +register_cpu_ci(est_time=9, suite="stage-a-cpu-only") + + +def _make_temp_pem(content: bytes) -> str: + """Create a temporary PEM file and return its path.""" + f = tempfile.NamedTemporaryFile(suffix=".pem", delete=False) + f.write(content) + f.flush() + f.close() + return f.name + + +class TestSSLCertRefresher(CustomTestCase): + """Tests for the SSLCertRefresher class.""" + + def setUp(self): + super().setUp() + self._temp_files: list[str] = [] + + def tearDown(self): + for path in self._temp_files: + try: + os.unlink(path) + except OSError: + pass + super().tearDown() + + def _track(self, path: str) -> str: + """Register a temp file for cleanup.""" + self._temp_files.append(path) + return path + + def _run_async(self, coro): + """Helper to run an async coroutine in tests.""" + loop = asyncio.new_event_loop() + try: + return loop.run_until_complete(coro) + finally: + loop.close() + + def test_reload_cert_key_on_file_change(self): + """SSLCertRefresher calls load_cert_chain when cert/key files change.""" + mock_ctx = MagicMock() + cert_path = self._track(_make_temp_pem(b"CERT_V1")) + key_path = self._track(_make_temp_pem(b"KEY_V1")) + + async def _test(): + refresher = SSLCertRefresher(mock_ctx, key_path, cert_path) + await asyncio.sleep(0.3) + + with open(cert_path, "w") as f: + f.write("CERT_V2") + + await asyncio.sleep(1.5) + refresher.stop() + return mock_ctx + + result_ctx = self._run_async(_test()) + result_ctx.load_cert_chain.assert_called_with(cert_path, key_path) + + def test_reload_ca_on_file_change(self): + """SSLCertRefresher calls load_verify_locations when CA file changes.""" + mock_ctx = MagicMock() + cert_path = self._track(_make_temp_pem(b"CERT")) + key_path = self._track(_make_temp_pem(b"KEY")) + ca_path = self._track(_make_temp_pem(b"CA_V1")) + + async def _test(): + refresher = SSLCertRefresher(mock_ctx, key_path, cert_path, ca_path) + await asyncio.sleep(0.3) + + with open(ca_path, "w") as f: + f.write("CA_V2") + + await asyncio.sleep(1.5) + refresher.stop() + return mock_ctx + + result_ctx = self._run_async(_test()) + result_ctx.load_verify_locations.assert_called_with(ca_path) + + def test_stop_cancels_tasks(self): + """Calling stop() prevents further reloads.""" + mock_ctx = MagicMock() + cert_path = self._track(_make_temp_pem(b"CERT")) + key_path = self._track(_make_temp_pem(b"KEY")) + + async def _test(): + refresher = SSLCertRefresher(mock_ctx, key_path, cert_path) + await asyncio.sleep(0.2) + + refresher.stop() + + with open(cert_path, "w") as f: + f.write("CERT_AFTER_STOP") + + await asyncio.sleep(1.0) + return mock_ctx + + result_ctx = self._run_async(_test()) + result_ctx.load_cert_chain.assert_not_called() + + def test_no_ca_watcher_when_ca_not_provided(self): + """No CA watcher task is created when ca_path is None.""" + mock_ctx = MagicMock() + cert_path = self._track(_make_temp_pem(b"CERT")) + key_path = self._track(_make_temp_pem(b"KEY")) + + async def _test(): + refresher = SSLCertRefresher(mock_ctx, key_path, cert_path) + self.assertEqual(len(refresher._tasks), 1) + refresher.stop() + + self._run_async(_test()) + + def test_ca_watcher_created_when_ca_provided(self): + """A CA watcher task is created when ca_path is provided.""" + mock_ctx = MagicMock() + cert_path = self._track(_make_temp_pem(b"CERT")) + key_path = self._track(_make_temp_pem(b"KEY")) + ca_path = self._track(_make_temp_pem(b"CA")) + + async def _test(): + refresher = SSLCertRefresher(mock_ctx, key_path, cert_path, ca_path) + self.assertEqual(len(refresher._tasks), 2) + refresher.stop() + + self._run_async(_test()) + + def test_reload_error_does_not_crash(self): + """A reload error is logged but doesn't crash the watcher.""" + mock_ctx = MagicMock() + mock_ctx.load_cert_chain.side_effect = Exception("bad cert") + cert_path = self._track(_make_temp_pem(b"CERT")) + key_path = self._track(_make_temp_pem(b"KEY")) + + async def _test(): + refresher = SSLCertRefresher(mock_ctx, key_path, cert_path) + await asyncio.sleep(0.3) + + with open(cert_path, "w") as f: + f.write("BAD_CERT") + + await asyncio.sleep(1.5) + + for task in refresher._tasks: + self.assertFalse(task.done()) + + refresher.stop() + + self._run_async(_test()) + + +if __name__ == "__main__": + unittest.main()