From 54ac36a6c5175e23426e661b3572c44bad837db3 Mon Sep 17 00:00:00 2001 From: Badri Natarajan Date: Mon, 22 Jun 2026 01:04:06 -0700 Subject: [PATCH] [Frontend] Add TLS support with certificate/key files - Implement TLS listener configuration - Add unit tests for TLS settings - Support client certificate authentication modes Co-authored-by: GitHub Copilot Co-authored-by: Claude Signed-off-by: Badri Natarajan --- rust/Cargo.lock | 113 +++++++++- rust/Cargo.toml | 3 + rust/src/cmd/src/cli.rs | 110 ++++++++- rust/src/cmd/src/cli/tests.rs | 259 ++++++++++++++++++++++ rust/src/cmd/src/cli/unsupported.rs | 21 -- rust/src/server/Cargo.toml | 3 + rust/src/server/src/config.rs | 23 ++ rust/src/server/src/lib.rs | 22 +- rust/src/server/src/listener.rs | 332 ++++++++++++++++++++++++++-- vllm/entrypoints/openai/cli_args.py | 22 +- 10 files changed, 849 insertions(+), 59 deletions(-) diff --git a/rust/Cargo.lock b/rust/Cargo.lock index 7639b9cc13a9..782469329d2e 100644 --- a/rust/Cargo.lock +++ b/rust/Cargo.lock @@ -365,6 +365,28 @@ dependencies = [ "arrayvec", ] +[[package]] +name = "aws-lc-rs" +version = "1.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ec2f1fc3ec205783a5da9a7e6c1509cc69dedf09a1949e412c1e18469326d00" +dependencies = [ + "aws-lc-sys", + "zeroize", +] + +[[package]] +name = "aws-lc-sys" +version = "0.41.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a2f9779ce85b93ab6170dd940ad0169b5766ff848247aff13bb788b832fe3f4" +dependencies = [ + "cc", + "cmake", + "dunce", + "fs_extra", +] + [[package]] name = "axum" version = "0.8.8" @@ -707,6 +729,15 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3a822ea5bc7590f9d40f1ba12c0dc3c2760f3482c6984db1573ad11031420831" +[[package]] +name = "cmake" +version = "0.1.58" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678" +dependencies = [ + "cc", +] + [[package]] name = "color_quant" version = "1.1.0" @@ -1163,6 +1194,12 @@ version = "1.0.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4c3cf4824e2d5f025c7b531afcb2325364084a16806f6d47fbc1f5fbd9960590" +[[package]] +name = "dunce" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" + [[package]] name = "dyn-clone" version = "1.0.20" @@ -1492,6 +1529,12 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "fs_extra" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" + [[package]] name = "fslock" version = "0.2.1" @@ -1890,11 +1933,11 @@ dependencies = [ "http", "hyper", "hyper-util", - "rustls", + "rustls 0.23.37", "rustls-native-certs", "rustls-pki-types", "tokio", - "tokio-rustls", + "tokio-rustls 0.26.4", "tower-service", "webpki-roots 1.0.6", ] @@ -3485,7 +3528,7 @@ dependencies = [ "quinn-proto", "quinn-udp", "rustc-hash 2.1.1", - "rustls", + "rustls 0.23.37", "socket2", "thiserror 2.0.18", "tokio", @@ -3505,7 +3548,7 @@ dependencies = [ "rand 0.9.2", "ring", "rustc-hash 2.1.1", - "rustls", + "rustls 0.23.37", "rustls-pki-types", "slab", "thiserror 2.0.18", @@ -3801,7 +3844,7 @@ dependencies = [ "percent-encoding", "pin-project-lite", "quinn", - "rustls", + "rustls 0.23.37", "rustls-native-certs", "rustls-pki-types", "serde", @@ -3810,7 +3853,7 @@ dependencies = [ "sync_wrapper", "tokio", "tokio-native-tls", - "tokio-rustls", + "tokio-rustls 0.26.4", "tokio-util", "tower", "tower-http", @@ -3953,17 +3996,32 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "rustls" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf4ef73721ac7bcd79b2b315da7779d8fc09718c6b3d2d1b2d94850eb8c18432" +dependencies = [ + "log", + "ring", + "rustls-pki-types", + "rustls-webpki 0.102.8", + "subtle", + "zeroize", +] + [[package]] name = "rustls" version = "0.23.37" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "758025cb5fccfd3bc2fd74708fd4682be41d99e5dff73c377c0646c6012c73a4" dependencies = [ + "aws-lc-rs", "log", "once_cell", "ring", "rustls-pki-types", - "rustls-webpki", + "rustls-webpki 0.103.9", "subtle", "zeroize", ] @@ -3980,6 +4038,15 @@ dependencies = [ "security-framework", ] +[[package]] +name = "rustls-pemfile" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dce314e5fee3f39953d46bb63bb8a46d40c2f8fb7cc5a3b6cab2bde9721d6e50" +dependencies = [ + "rustls-pki-types", +] + [[package]] name = "rustls-pki-types" version = "1.14.0" @@ -3990,12 +4057,24 @@ dependencies = [ "zeroize", ] +[[package]] +name = "rustls-webpki" +version = "0.102.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64ca1bc8749bd4cf37b5ce386cc146580777b4e8572c7b97baf22c83f444bee9" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + [[package]] name = "rustls-webpki" version = "0.103.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d7df23109aa6c1567d1c575b9952556388da57401e4ace1d15f79eedad0d8f53" dependencies = [ + "aws-lc-rs", "ring", "rustls-pki-types", "untrusted", @@ -4977,13 +5056,24 @@ dependencies = [ "tokio", ] +[[package]] +name = "tokio-rustls" +version = "0.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "775e0c0f0adb3a2f22a00c4745d728b479985fc15ee7ca6a2608388c5569860f" +dependencies = [ + "rustls 0.22.4", + "rustls-pki-types", + "tokio", +] + [[package]] name = "tokio-rustls" version = "0.26.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" dependencies = [ - "rustls", + "rustls 0.23.37", "tokio", ] @@ -5453,7 +5543,7 @@ dependencies = [ "flate2", "log", "once_cell", - "rustls", + "rustls 0.23.37", "rustls-pki-types", "serde", "serde_json", @@ -5475,7 +5565,7 @@ dependencies = [ "log", "native-tls", "percent-encoding", - "rustls", + "rustls 0.23.37", "rustls-pki-types", "serde", "serde_json", @@ -5809,6 +5899,8 @@ dependencies = [ "prost-types", "rmp-serde", "rmpv", + "rustls 0.23.37", + "rustls-pemfile", "serde", "serde_json", "serde_with", @@ -5816,6 +5908,7 @@ dependencies = [ "socket2", "thiserror-ext", "tokio", + "tokio-rustls 0.25.0", "tokio-stream", "tokio-util", "tonic", diff --git a/rust/Cargo.toml b/rust/Cargo.toml index 9ca38d0ae790..c0a8c6691069 100644 --- a/rust/Cargo.toml +++ b/rust/Cargo.toml @@ -63,6 +63,8 @@ prost-types = "0.14.3" rand = "0.9.2" reasoning-parser = "1.2.2" reqwest = { version = "0.12.8", default-features = false, features = ["rustls-tls"] } +rustls = "0.23" +rustls-pemfile = "2.2" riptoken = { version = "0.3.0", default-features = false } rmp-serde = "1.3.1" rmpv = { version = "1.3.1", features = ["with-serde"] } @@ -92,6 +94,7 @@ tokio = { version = "1.47.1", features = [ "sync", "time", ] } +tokio-rustls = "0.25" tokio-stream = "0.1" tokio-util = { version = "0.7.18", features = ["rt"] } tonic = "0.14.5" diff --git a/rust/src/cmd/src/cli.rs b/rust/src/cmd/src/cli.rs index 312d9365a0f2..a83c24e20881 100644 --- a/rust/src/cmd/src/cli.rs +++ b/rust/src/cmd/src/cli.rs @@ -195,6 +195,61 @@ pub struct SharedRuntimeArgs { #[serde(default)] pub served_model_name: Vec, + /// HTTP bind host for the OpenAI-compatible server (passed to frontend in bootstrap mode). + #[arg(long, default_value = "127.0.0.1")] + #[serde(default = "default_frontend_host", alias = "host")] + pub frontend_host: String, + + /// HTTP bind port for the OpenAI-compatible server (passed to frontend in bootstrap mode). + #[arg(long, default_value_t = 8000)] + #[serde(default = "default_frontend_port", alias = "port")] + pub frontend_port: u16, + + /// Path to TLS certificate file (PEM format). If set, enables TLS on the HTTP server. + #[arg(long)] + #[serde(default, alias = "ssl_certfile")] + pub ssl_cert_file: Option, + + /// Path to TLS private key file (PEM format). Required if ssl_cert_file is set. + #[arg(long)] + #[serde(default, alias = "ssl_keyfile")] + pub ssl_key_file: Option, + + /// Path to CA certificates file (PEM format) for client certificate verification. + #[arg(long)] + #[serde(default, alias = "ssl_ca_certs")] + pub ssl_ca_certs: Option, + + /// Client certificate requirement level: 0=CERT_NONE (no client auth), 2=CERT_REQUIRED (mandatory). + /// + /// Note: CERT_OPTIONAL (value 1) is NOT SUPPORTED due to rustls v0.23 limitations. + /// If ssl_cert_reqs=1 is specified, the server will log a warning and fall back to CERT_NONE. + /// + /// - 0 (CERT_NONE): Client certificates are not required. Server does not request or verify client certs. + /// - 2 (CERT_REQUIRED): Client certificates are mandatory. Clients must provide valid certificates + /// signed by the CA specified in ssl_ca_certs. Connections without valid certs will be rejected. + /// - 1 (CERT_OPTIONAL): NOT SUPPORTED - will fallback to CERT_NONE with warning. + /// Rustls v0.23 has no TLS-level support for optional client authentication. + #[arg(long, default_value = "0")] + #[serde(default)] + pub ssl_cert_reqs: u32, + + /// SSL cipher suites (colon-separated). Rustls only supports modern secure cipher suites + /// and does not allow weak or legacy ciphers (by design). + /// + /// IMPORTANT: This parameter is ACCEPTED FOR VALIDATION AND LOGGING ONLY. + /// It does NOT restrict which ciphers the server actually accepts - rustls always uses its + /// hardcoded secure defaults and provides no API to configure or restrict cipher suites. + /// The server will always offer all rustls default ciphers regardless of this setting. + /// + /// This parameter exists purely for CLI compatibility with Python/Uvicorn. + /// Supported formats: + /// - OpenSSL names: 'ECDHE-RSA-AES256-GCM-SHA384:ECDHE-RSA-AES128-GCM-SHA256' + /// - Rustls internal names: 'TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384' + #[arg(long)] + #[serde(default, alias = "ssl_ciphers")] + pub ssl_ciphers: Option, + /// Unsupported Python vLLM frontend arguments recognized but not yet /// implemented in Rust. #[educe(Debug(ignore))] @@ -218,7 +273,8 @@ impl SharedRuntimeArgs { /// Build the OpenAI-server config for the Python-bootstrap worker contract. /// /// The resulting config binds the Python-supplied transport addresses and - /// inherits an already open HTTP listener from the supervisor process. + /// inherits an already open HTTP listener from the supervisor process, + /// unless TLS is enabled in which case it binds its own socket. fn into_bootstrapped_config( self, listen_fd: i32, @@ -230,6 +286,22 @@ impl SharedRuntimeArgs { let ready_timeout = self.ready_timeout(); let shutdown_timeout = self.shutdown_timeout(); + // Determine listener mode: use TLS if cert/key are provided, otherwise use inherited fd + let listener_mode = match (&self.ssl_cert_file, &self.ssl_key_file) { + (Some(cert), Some(key)) => HttpListenerMode::BindTcpTls { + host: self.frontend_host.clone(), + port: self.frontend_port, + cert_path: cert.clone(), + key_path: key.clone(), + ca_certs_path: self.ssl_ca_certs.clone(), + ssl_cert_reqs: self.ssl_cert_reqs, + ssl_ciphers: self.ssl_ciphers.clone(), + }, + (Some(_), None) => panic!("ssl_cert_file specified but ssl_key_file is missing"), + (None, Some(_)) => panic!("ssl_key_file specified but ssl_cert_file is missing"), + (None, None) => HttpListenerMode::InheritedFd { fd: listen_fd }, + }; + Config { transport_mode: TransportMode::Bootstrapped { input_address, @@ -243,7 +315,7 @@ impl SharedRuntimeArgs { }, model: self.model, served_model_name: self.served_model_name, - listener_mode: HttpListenerMode::InheritedFd { fd: listen_fd }, + listener_mode, tool_call_parser: self.tool_call_parser, reasoning_parser: self.reasoning_parser, renderer: self.renderer, @@ -306,6 +378,14 @@ fn default_engine_ready_timeout_secs() -> u64 { 600 } +fn default_frontend_host() -> String { + "127.0.0.1".to_string() +} + +fn default_frontend_port() -> u16 { + 8000 +} + fn parse_json(value: &str) -> Result { serde_json::from_str(value).map_err(|e| format!("invalid JSON object: {}", e.as_report())) } @@ -385,7 +465,7 @@ pub struct ServeArgs { #[arg(long, hide = true, env = "VLLM_RS_DEBUG_CLI")] pub debug_cli: bool, - /// Shared frontend arguments. + /// Shared frontend arguments (includes TLS arguments: ssl_cert_file, ssl_key_file, ssl_ca_certs, ssl_cert_reqs, ssl_ciphers). #[command(flatten)] pub runtime: SharedRuntimeArgs, @@ -403,10 +483,26 @@ impl ServeArgs { self.managed_engine.frontend_local_only().then(frontend_ipc_addresses).unzip(); let listener_mode = match &self.uds { Some(path) => HttpListenerMode::BindUnix { path: path.clone() }, - None => HttpListenerMode::BindTcp { - host: self.host.clone(), - port: self.port, - }, + None => { + // Check if TLS is requested + match (&self.runtime.ssl_cert_file, &self.runtime.ssl_key_file) { + (Some(cert), Some(key)) => HttpListenerMode::BindTcpTls { + host: self.host.clone(), + port: self.port, + cert_path: cert.clone(), + key_path: key.clone(), + ca_certs_path: self.runtime.ssl_ca_certs.clone(), + ssl_cert_reqs: self.runtime.ssl_cert_reqs, + ssl_ciphers: self.runtime.ssl_ciphers.clone(), + }, + (Some(_), None) => panic!("ssl_cert_file specified but ssl_key_file is missing"), + (None, Some(_)) => panic!("ssl_key_file specified but ssl_cert_file is missing"), + (None, None) => HttpListenerMode::BindTcp { + host: self.host.clone(), + port: self.port, + }, + } + } }; self.runtime.clone().into_managed_config( diff --git a/rust/src/cmd/src/cli/tests.rs b/rust/src/cmd/src/cli/tests.rs index c56db5433553..05e750faece1 100644 --- a/rust/src/cmd/src/cli/tests.rs +++ b/rust/src/cmd/src/cli/tests.rs @@ -47,6 +47,13 @@ fn serve_args_forward_python_flags_with_separator() { enable_request_id_headers: false, disable_log_stats: false, served_model_name: [], + frontend_host: "127.0.0.1", + frontend_port: 8000, + ssl_cert_file: None, + ssl_key_file: None, + ssl_ca_certs: None, + ssl_cert_reqs: 0, + ssl_ciphers: None, }, managed_engine: ManagedEngineArgs { python: "../vllm/.venv/bin/python", @@ -275,6 +282,13 @@ fn frontend_args_accept_json() { enable_request_id_headers: false, disable_log_stats: false, served_model_name: [], + frontend_host: "127.0.0.1", + frontend_port: 8000, + ssl_cert_file: None, + ssl_key_file: None, + ssl_ca_certs: None, + ssl_cert_reqs: 0, + ssl_ciphers: None, }, }, ), @@ -676,6 +690,13 @@ fn serve_args_accept_handshake_aliases() { enable_request_id_headers: false, disable_log_stats: false, served_model_name: [], + frontend_host: "127.0.0.1", + frontend_port: 8000, + ssl_cert_file: None, + ssl_key_file: None, + ssl_ca_certs: None, + ssl_cert_reqs: 0, + ssl_ciphers: None, }, managed_engine: ManagedEngineArgs { python: "python3", @@ -967,3 +988,241 @@ fn serve_frontend_config_uses_unix_listener_when_uds_is_present() { } ); } + +// TLS/HTTPS support tests +#[test] +fn serve_args_accept_tls_arguments_with_cert_and_key() { + let cli = Cli::try_parse_from([ + "vllm-rs", + "serve", + "Qwen/Qwen3-0.6B", + "--ssl-cert-file", + "/tmp/server-cert.pem", + "--ssl-key-file", + "/tmp/server-key.pem", + ]) + .unwrap(); + + let Command::Serve(args) = cli.command else { + panic!("expected serve args"); + }; + assert_eq!(args.runtime.ssl_cert_file, Some("/tmp/server-cert.pem".to_string())); + assert_eq!(args.runtime.ssl_key_file, Some("/tmp/server-key.pem".to_string())); + assert_eq!(args.runtime.ssl_cert_reqs, 0); // Default: CERT_NONE + assert_eq!(args.runtime.ssl_ciphers, None); + assert_eq!(args.runtime.ssl_ca_certs, None); +} + +#[test] +fn serve_args_accept_tls_with_client_auth_required() { + let cli = Cli::try_parse_from([ + "vllm-rs", + "serve", + "Qwen/Qwen3-0.6B", + "--ssl-cert-file", + "/tmp/server-cert.pem", + "--ssl-key-file", + "/tmp/server-key.pem", + "--ssl-ca-certs", + "/tmp/ca-cert.pem", + "--ssl-cert-reqs", + "2", + ]) + .unwrap(); + + let Command::Serve(args) = cli.command else { + panic!("expected serve args"); + }; + assert_eq!(args.runtime.ssl_cert_file, Some("/tmp/server-cert.pem".to_string())); + assert_eq!(args.runtime.ssl_key_file, Some("/tmp/server-key.pem".to_string())); + assert_eq!(args.runtime.ssl_ca_certs, Some("/tmp/ca-cert.pem".to_string())); + assert_eq!(args.runtime.ssl_cert_reqs, 2); // CERT_REQUIRED +} + +#[test] +fn serve_args_accept_tls_with_client_auth_optional_mode() { + let cli = Cli::try_parse_from([ + "vllm-rs", + "serve", + "Qwen/Qwen3-0.6B", + "--ssl-cert-file", + "/tmp/server-cert.pem", + "--ssl-key-file", + "/tmp/server-key.pem", + "--ssl-cert-reqs", + "1", + ]) + .unwrap(); + + let Command::Serve(args) = cli.command else { + panic!("expected serve args"); + }; + assert_eq!(args.runtime.ssl_cert_reqs, 1); // CERT_OPTIONAL (maps to CERT_NONE in rustls) +} + +#[test] +fn serve_args_accept_tls_with_ciphers() { + let cli = Cli::try_parse_from([ + "vllm-rs", + "serve", + "Qwen/Qwen3-0.6B", + "--ssl-cert-file", + "/tmp/server-cert.pem", + "--ssl-key-file", + "/tmp/server-key.pem", + "--ssl-ciphers", + "ECDHE-RSA-AES256-GCM-SHA384:ECDHE-RSA-AES128-GCM-SHA256", + ]) + .unwrap(); + + let Command::Serve(args) = cli.command else { + panic!("expected serve args"); + }; + assert_eq!( + args.runtime.ssl_ciphers, + Some("ECDHE-RSA-AES256-GCM-SHA384:ECDHE-RSA-AES128-GCM-SHA256".to_string()) + ); +} + +#[test] +fn serve_frontend_config_creates_tls_listener_mode() { + let cli = Cli::try_parse_from([ + "vllm-rs", + "serve", + "Qwen/Qwen3-0.6B", + "--ssl-cert-file", + "/tmp/server-cert.pem", + "--ssl-key-file", + "/tmp/server-key.pem", + ]) + .unwrap(); + + let Command::Serve(args) = cli.command else { + panic!("expected serve args"); + }; + let config = args.to_frontend_config("tcp://127.0.0.1:29550".to_string()); + + assert_eq!( + config.listener_mode, + HttpListenerMode::BindTcpTls { + host: "127.0.0.1".to_string(), + port: 8000, + cert_path: "/tmp/server-cert.pem".to_string(), + key_path: "/tmp/server-key.pem".to_string(), + ca_certs_path: None, + ssl_cert_reqs: 0, + ssl_ciphers: None, + } + ); +} + +#[test] +fn serve_frontend_config_creates_tls_listener_with_client_auth() { + let cli = Cli::try_parse_from([ + "vllm-rs", + "serve", + "Qwen/Qwen3-0.6B", + "--ssl-cert-file", + "/tmp/server-cert.pem", + "--ssl-key-file", + "/tmp/server-key.pem", + "--ssl-ca-certs", + "/tmp/ca-cert.pem", + "--ssl-cert-reqs", + "2", + "--ssl-ciphers", + "ECDHE-RSA-AES256-GCM-SHA384", + ]) + .unwrap(); + + let Command::Serve(args) = cli.command else { + panic!("expected serve args"); + }; + let config = args.to_frontend_config("tcp://127.0.0.1:29550".to_string()); + + assert_eq!( + config.listener_mode, + HttpListenerMode::BindTcpTls { + host: "127.0.0.1".to_string(), + port: 8000, + cert_path: "/tmp/server-cert.pem".to_string(), + key_path: "/tmp/server-key.pem".to_string(), + ca_certs_path: Some("/tmp/ca-cert.pem".to_string()), + ssl_cert_reqs: 2, + ssl_ciphers: Some("ECDHE-RSA-AES256-GCM-SHA384".to_string()), + } + ); +} + +#[test] +fn serve_frontend_config_no_tls_without_cert() { + let cli = Cli::try_parse_from([ + "vllm-rs", + "serve", + "Qwen/Qwen3-0.6B", + ]) + .unwrap(); + + let Command::Serve(args) = cli.command else { + panic!("expected serve args"); + }; + let config = args.to_frontend_config("tcp://127.0.0.1:29550".to_string()); + + assert_eq!( + config.listener_mode, + HttpListenerMode::BindTcp { + host: "127.0.0.1".to_string(), + port: 8000, + } + ); +} + +#[test] +fn frontend_args_json_accepts_tls_fields() { + let cli = Cli::try_parse_from([ + "vllm-rs", + "frontend", + "--listen-fd", + "3", + "--input-address", + "ipc:///tmp/input.sock", + "--output-address", + "ipc:///tmp/output.sock", + "--args-json", + r#"{"model_tag":"Qwen/Qwen3-0.6B","ssl_cert_file":"/tmp/server-cert.pem","ssl_key_file":"/tmp/server-key.pem","ssl_cert_reqs":0}"#, + ]) + .unwrap(); + + let Command::Frontend(args) = cli.command else { + panic!("expected frontend args"); + }; + assert_eq!(args.runtime.ssl_cert_file, Some("/tmp/server-cert.pem".to_string())); + assert_eq!(args.runtime.ssl_key_file, Some("/tmp/server-key.pem".to_string())); + assert_eq!(args.runtime.ssl_cert_reqs, 0); +} + +#[test] +fn frontend_args_json_accepts_tls_with_ca_certs() { + let cli = Cli::try_parse_from([ + "vllm-rs", + "frontend", + "--listen-fd", + "3", + "--input-address", + "ipc:///tmp/input.sock", + "--output-address", + "ipc:///tmp/output.sock", + "--args-json", + r#"{"model_tag":"Qwen/Qwen3-0.6B","ssl_cert_file":"/tmp/server-cert.pem","ssl_key_file":"/tmp/server-key.pem","ssl_ca_certs":"/tmp/ca-cert.pem","ssl_cert_reqs":2,"ssl_ciphers":"ECDHE-RSA-AES256-GCM-SHA384"}"#, + ]) + .unwrap(); + + let Command::Frontend(args) = cli.command else { + panic!("expected frontend args"); + }; + assert_eq!(args.runtime.ssl_cert_file, Some("/tmp/server-cert.pem".to_string())); + assert_eq!(args.runtime.ssl_key_file, Some("/tmp/server-key.pem".to_string())); + assert_eq!(args.runtime.ssl_ca_certs, Some("/tmp/ca-cert.pem".to_string())); + assert_eq!(args.runtime.ssl_cert_reqs, 2); + assert_eq!(args.runtime.ssl_ciphers, Some("ECDHE-RSA-AES256-GCM-SHA384".to_string())); +} diff --git a/rust/src/cmd/src/cli/unsupported.rs b/rust/src/cmd/src/cli/unsupported.rs index 8bd972ae17a3..41c17af36502 100644 --- a/rust/src/cmd/src/cli/unsupported.rs +++ b/rust/src/cmd/src/cli/unsupported.rs @@ -569,18 +569,6 @@ pub struct ServerUnsupportedArgs { #[arg(long)] pub api_key: Option, - /// The file path to the SSL key file. - #[arg(long)] - pub ssl_keyfile: Option, - - /// The file path to the SSL cert file. - #[arg(long)] - pub ssl_certfile: Option, - - /// The CA certificates file. - #[arg(long)] - pub ssl_ca_certs: Option, - /// Refresh SSL Context when SSL certificate files change #[arg( long, @@ -590,15 +578,6 @@ pub struct ServerUnsupportedArgs { )] pub enable_ssl_refresh: Option, - /// Whether client certificate is required (see stdlib ssl module's). - #[arg(long)] - pub ssl_cert_reqs: Option, - - /// SSL cipher suites for HTTPS (TLS 1.2 and below only). - /// Example: 'ECDHE-RSA-AES256-GCM-SHA384:ECDHE-RSA-CHACHA20-POLY1305' - #[arg(long)] - pub ssl_ciphers: Option, - /// FastAPI root_path when app is behind a path based routing proxy. #[arg(long)] pub root_path: Option, diff --git a/rust/src/server/Cargo.toml b/rust/src/server/Cargo.toml index 6030f972a9f2..bfd555dd75d0 100644 --- a/rust/src/server/Cargo.toml +++ b/rust/src/server/Cargo.toml @@ -22,8 +22,11 @@ serde_with.workspace = true socket2.workspace = true thiserror-ext.workspace = true tokio.workspace = true +tokio-rustls.workspace = true tokio-stream.workspace = true tokio-util.workspace = true +rustls.workspace = true +rustls-pemfile.workspace = true tonic.workspace = true tonic-prost.workspace = true tower-http.workspace = true diff --git a/rust/src/server/src/config.rs b/rust/src/server/src/config.rs index 3018f57377ab..eda59f812043 100644 --- a/rust/src/server/src/config.rs +++ b/rust/src/server/src/config.rs @@ -12,6 +12,29 @@ use vllm_engine_core_client::{CoordinatorMode as EngineCoreCoordinatorMode, Tran pub enum HttpListenerMode { /// Bind a fresh TCP listener on the given host/port. BindTcp { host: String, port: u16 }, + /// Bind a fresh TCP listener with TLS on the given host/port. + /// Requires paths to certificate and private key PEM files. + /// + /// ssl_cert_reqs: + /// 0=CERT_NONE - No client authentication required + /// 2=CERT_REQUIRED - Client certificates mandatory and verified + /// 1=CERT_OPTIONAL - NOT SUPPORTED (rustls v0.23 limitation); falls back to CERT_NONE + /// + /// Note: Rustls v0.23 does not support optional client certificate verification + /// at the TLS layer. If ssl_cert_reqs=1 is specified, the server will log a warning + /// and fall back to CERT_NONE (client certificates not required). + /// + /// ssl_ciphers: VALIDATION AND LOGGING ONLY. Rustls uses hardcoded secure defaults and + /// provides no API to restrict ciphers. Specified ciphers are validated/logged but NOT enforced. + BindTcpTls { + host: String, + port: u16, + cert_path: String, + key_path: String, + ca_certs_path: Option, + ssl_cert_reqs: u32, + ssl_ciphers: Option, + }, /// Bind a fresh Unix domain listener on the given filesystem path. BindUnix { path: String }, /// Adopt an already-open listening socket inherited from a supervisor diff --git a/rust/src/server/src/lib.rs b/rust/src/server/src/lib.rs index 52ac76332921..7408a385a510 100644 --- a/rust/src/server/src/lib.rs +++ b/rust/src/server/src/lib.rs @@ -19,7 +19,6 @@ pub use config::{Config, CoordinatorMode, HttpListenerMode}; use tokio::net::TcpListener; use tokio::time::{Instant, sleep_until}; use tokio_stream::wrappers::TcpListenerStream; -use tokio_util::either::Either; use tokio_util::sync::CancellationToken; use tonic::transport::Server as TonicServer; use tracing::{info, trace, warn}; @@ -140,6 +139,7 @@ where let grpc_setup = if let Some(grpc_port) = config.grpc_port { let grpc_host = match &config.listener_mode { HttpListenerMode::BindTcp { host, .. } => host.as_str(), + HttpListenerMode::BindTcpTls { host, .. } => host.as_str(), HttpListenerMode::BindUnix { .. } | HttpListenerMode::InheritedFd { .. } => "0.0.0.0", }; let grpc_listener = TcpListener::bind((grpc_host, grpc_port)) @@ -152,16 +152,26 @@ where } else { None }; - + info!(%bind_address, %model, "starting OpenAI server"); // Set TCP_NODELAY on accepted connections to reduce latency. // By `tap_io` we will do this on every accepted connection. let listener = listener.tap_io(|io| { - if let Either::Left(tcp_stream) = io - && let Err(err) = tcp_stream.set_nodelay(true) - { - trace!(error = %err, "failed to enable TCP_NODELAY on accepted HTTP connection"); + match io { + listener::ListenerIo::Tcp(tcp_stream) => { + if let Err(err) = tcp_stream.set_nodelay(true) { + trace!(error = %err, "failed to enable TCP_NODELAY on accepted HTTP connection"); + } + } + listener::ListenerIo::TcpTls(tls_stream) => { + if let Err(err) = tls_stream.get_ref().0.set_nodelay(true) { + trace!(error = %err, "failed to enable TCP_NODELAY on TLS connection"); + } + } + listener::ListenerIo::Unix(_) => { + // Unix sockets don't support TCP_NODELAY + } } }); diff --git a/rust/src/server/src/listener.rs b/rust/src/server/src/listener.rs index b7b715b0ebd7..89ffd38d84a2 100644 --- a/rust/src/server/src/listener.rs +++ b/rust/src/server/src/listener.rs @@ -4,45 +4,276 @@ //! the rest of the server can bind or inherit one socket and pass it to //! `axum::serve(...)` through a single type. +use std::fmt; use std::io::Result; use std::net::TcpListener as StdTcpListener; use std::os::fd::{FromRawFd, IntoRawFd, OwnedFd}; use std::os::unix::net::UnixListener as StdUnixListener; +use std::sync::Arc; use socket2::Socket; use tokio::net::{TcpListener, TcpStream, UnixListener, UnixStream}; +use tokio_rustls::TlsAcceptor; use tokio_util::either::Either; +use tracing::{info, warn}; use crate::HttpListenerMode; +/// TLS-wrapped TCP stream for use with tokio-rustls +pub type TlsStream = tokio_rustls::server::TlsStream; + /// Runtime listener type used by the OpenAI-compatible HTTP server, which is -/// either a TCP listener or a Unix-domain listener. -#[derive(Debug)] +/// either a TCP listener, a TLS-enabled TCP listener, or a Unix-domain listener. pub enum Listener { Tcp(TcpListener), + TcpTls(TcpListener, Arc), Unix(UnixListener), } +impl fmt::Debug for Listener { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Tcp(listener) => f.debug_tuple("Tcp").field(listener).finish(), + Self::TcpTls(listener, _) => f.debug_tuple("TcpTls").field(listener).field(&"").finish(), + Self::Unix(listener) => f.debug_tuple("Unix").field(listener).finish(), + } + } +} + impl Listener { /// Bind or adopt the listener described by the frontend configuration. /// /// For inherited sockets, the concrete listener kind is detected from the /// socket family of the supplied file descriptor. pub async fn bind(mode: &HttpListenerMode) -> Result { + match mode { HttpListenerMode::BindTcp { host, port } => { Ok(Self::Tcp(TcpListener::bind((host.as_str(), *port)).await?)) } + HttpListenerMode::BindTcpTls { + host, + port, + cert_path, + key_path, + ca_certs_path, + ssl_cert_reqs, + ssl_ciphers, + } => { + let tcp_listener = TcpListener::bind((host.as_str(), *port)).await?; + let tls_acceptor = Self::load_tls_acceptor(cert_path, key_path, ca_certs_path.as_deref(), *ssl_cert_reqs, ssl_ciphers.as_deref())?; + info!("TLS Listener bound to {}:{}", host, port); + Ok(Self::TcpTls(tcp_listener, Arc::new(tls_acceptor))) + } HttpListenerMode::BindUnix { path } => Ok(Self::Unix(UnixListener::bind(path)?)), HttpListenerMode::InheritedFd { fd } => Self::from_inherited_fd(*fd), } } + /// Validate and log SSL cipher suites for CLI compatibility. + /// + /// IMPORTANT: This is VALIDATION AND LOGGING ONLY. Rustls uses hardcoded secure-by-default + /// cipher suites and provides no API to restrict or configure which ciphers are used. + /// Any ciphers specified in ssl_ciphers are validated and logged, but NOT enforced. + /// The server will always use all rustls default ciphers regardless of this parameter. + /// + /// This function exists for CLI compatibility with Python/Uvicorn, which do support + /// cipher configuration. Unsupported/weak ciphers are logged as warnings but do not + /// cause errors - the server continues to use rustls secure defaults. + fn validate_ssl_ciphers(ciphers_str: &str) -> Result<()> { + // Rustls (v0.23 with aws-lc-rs) supports only these modern secure cipher suites: + // These are the actual internal names rustls uses. + let supported_ciphers = [ + // TLS 1.3 cipher suites (rustls internal names) + "TLS_AES_256_GCM_SHA384", + "TLS_CHACHA20_POLY1305_SHA256", + "TLS_AES_128_GCM_SHA256", + // TLS 1.3 cipher suites (OpenSSL-compatible aliases) + "TLS13-AES-256-GCM-SHA384", + "TLS13-CHACHA20-POLY1305-SHA256", + "TLS13-AES-128-GCM-SHA256", + // TLS 1.2 ECDHE cipher suites (ECDSA) - rustls internal names + "TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384", + "TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256", + "TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305_SHA256", + // TLS 1.2 ECDHE cipher suites (ECDSA) - OpenSSL-compatible aliases + "ECDHE-ECDSA-AES256-GCM-SHA384", + "ECDHE-ECDSA-AES128-GCM-SHA256", + "ECDHE-ECDSA-CHACHA20-POLY1305", + // TLS 1.2 ECDHE cipher suites (RSA) - rustls internal names + "TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384", + "TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256", + "TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305_SHA256", + // TLS 1.2 ECDHE cipher suites (RSA) - OpenSSL-compatible aliases + "ECDHE-RSA-AES256-GCM-SHA384", + "ECDHE-RSA-AES128-GCM-SHA256", + "ECDHE-RSA-CHACHA20-POLY1305", + ]; + + let mut unsupported = Vec::new(); + let mut supported = Vec::new(); + + for cipher in ciphers_str.split(':').filter(|c| !c.is_empty()) { + if supported_ciphers.contains(&cipher) { + supported.push(cipher); + } else { + unsupported.push(cipher); + } + } + + if !unsupported.is_empty() { + warn!( + "ssl_ciphers contains {} cipher suite(s) that are not supported or recognized by rustls and will be ignored: {}", + unsupported.len(), + unsupported.join(", ") + ); + } + + if !supported.is_empty() { + info!( + "ssl_ciphers contains {} supported cipher suite(s): {}", + supported.len(), + supported.join(", ") + ); + } + + // Always inform user that rustls uses secure defaults and doesn't allow weak ciphers + info!( + "Rustls uses hardcoded secure-by-default cipher suites and does not allow configuration \ + of weak or legacy ciphers. The ssl_ciphers parameter is accepted for CLI compatibility \ + with Python/Uvicorn but is not enforced." + ); + + Ok(()) + } + + /// Load TLS configuration from certificate and key files. + /// Optionally loads CA certificates for client authentication. + /// ssl_cert_reqs: 0=CERT_NONE (no client auth), 1=CERT_OPTIONAL (optional client auth), 2=CERT_REQUIRED (mandatory client auth) + /// ssl_ciphers: VALIDATION AND LOGGING ONLY. Rustls uses hardcoded secure defaults and provides no API + /// to restrict or configure cipher suites. Any specified ciphers are validated and logged but NOT enforced. + /// The server will always use all rustls default ciphers regardless of this parameter. + fn load_tls_acceptor(cert_path: &str, key_path: &str, ca_certs_path: Option<&str>, ssl_cert_reqs: u32, ssl_ciphers: Option<&str>) -> Result { + use rustls_pemfile::{certs, private_key}; + use std::fs; + use std::io::BufReader; + + // Validate and log ssl_ciphers parameter + if let Some(ciphers) = ssl_ciphers { + Self::validate_ssl_ciphers(ciphers)?; + } + + // Load certificate chain + let cert_file = fs::File::open(cert_path) + .map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, format!("Failed to open cert file {}: {}", cert_path, e)))?; + let mut reader = BufReader::new(cert_file); + let cert_chain: Vec<_> = certs(&mut reader) + .collect::, _>>() + .map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, format!("Failed to parse certificates from {}: {}", cert_path, e)))?; + + if cert_chain.is_empty() { + return Err(std::io::Error::new( + std::io::ErrorKind::Other, + format!("No certificates found in {}", cert_path), + )); + } + info!("Loaded {} certificate(s) from {}", cert_chain.len(), cert_path); + + // Load private key (supports PKCS8, RSA, EC, and Ed25519 formats) + // Note: rustls_pemfile's private_key function automatically detects the key type and supports multiple formats, so we don't need to manually try different parsers. + // Also the uvicorn TLS implementation supports DSA and DH keys but rustls does not, so we don't attempt to parse those formats and will error if they're used. + let key_file = fs::File::open(key_path) + .map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, format!("Failed to open key file {}: {}", key_path, e)))?; + let mut reader = BufReader::new(key_file); + let private_key = private_key(&mut reader) + .map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, format!("Failed to parse private key from {}: {}", key_path, e)))? + .ok_or_else(|| std::io::Error::new( + std::io::ErrorKind::Other, + format!("No private key found in {}. Supported formats: PKCS8, RSA, EC, Ed25519", key_path), + ))?; + + info!("Loaded private key from {}", key_path); + + // Create TLS server config with client certificate verification + // ssl_cert_reqs: 0=CERT_NONE, 2=CERT_REQUIRED + // Note: CERT_OPTIONAL (1) is not supported by rustls v0.23 and will be treated as CERT_NONE + let server_config = match ssl_cert_reqs { + 0 => { + // CERT_NONE: No client certificate verification + info!("Client certificates not required (CERT_NONE)"); + tokio_rustls::rustls::ServerConfig::builder() + .with_no_client_auth() + .with_single_cert(cert_chain, private_key) + .map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, format!("Invalid TLS config: {}", e)))? + } + 1 => { + // CERT_OPTIONAL: Not supported in rustls v0.23 - fallback to CERT_NONE + warn!("ssl_cert_reqs=1 (CERT_OPTIONAL) is not supported. Falling back to CERT_NONE (client certificates not required)."); + tokio_rustls::rustls::ServerConfig::builder() + .with_no_client_auth() + .with_single_cert(cert_chain, private_key) + .map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, format!("Invalid TLS config: {}", e)))? + } + 2 => { + // CERT_REQUIRED: Client certificates are required + if let Some(ca_path) = ca_certs_path { + info!("Client certificates required (CERT_REQUIRED)"); + // Load CA certificates for required client authentication + let ca_file = std::fs::File::open(ca_path) + .map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, format!("Failed to open CA certs file {}: {}", ca_path, e)))?; + let mut ca_reader = std::io::BufReader::new(ca_file); + let ca_certs: Vec<_> = certs(&mut ca_reader) + .collect::, _>>() + .map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, format!("Failed to parse CA certificates from {}: {}", ca_path, e)))?; + + if ca_certs.is_empty() { + return Err(std::io::Error::new( + std::io::ErrorKind::Other, + format!("No CA certificates found in {}", ca_path), + )); + } + info!("Loaded {} CA certificate(s) from {}", ca_certs.len(), ca_path); + + // Build root cert store + let mut root_store = tokio_rustls::rustls::RootCertStore::empty(); + for cert in ca_certs { + root_store.add(cert) + .map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, format!("Invalid CA certificate: {}", e)))?; + } + + // Use builder API to create required client verifier + let client_verifier = tokio_rustls::rustls::server::WebPkiClientVerifier::builder(std::sync::Arc::new(root_store)) + .build() + .map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, format!("Failed to build client verifier: {}", e)))?; + + tokio_rustls::rustls::ServerConfig::builder() + .with_client_cert_verifier(client_verifier) + .with_single_cert(cert_chain, private_key) + .map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, format!("Invalid TLS config with required client auth: {}", e)))? + } else { + return Err(std::io::Error::new( + std::io::ErrorKind::Other, + "CERT_REQUIRED specified but no CA certificates provided".to_string(), + )); + } + } + _ => { + return Err(std::io::Error::new( + std::io::ErrorKind::Other, + format!("Invalid ssl_cert_reqs value: {}. Supported values: 0 (CERT_NONE), 2 (CERT_REQUIRED). Note: 1 (CERT_OPTIONAL) is not supported and will fallback to CERT_NONE.", ssl_cert_reqs), + )); + } + }; + + Ok(TlsAcceptor::from(Arc::new(server_config))) + } + /// Return a log-friendly local address string for either TCP or Unix /// sockets. pub fn local_addr(&self) -> Result { match self { Self::Tcp(listener) => Ok(listener.local_addr()?.to_string()), + Self::TcpTls(listener, _) => Ok(listener.local_addr()?.to_string()), Self::Unix(listener) => Ok(match listener.local_addr()?.as_pathname() { Some(path) => format!("unix:{}", path.display()), None => "unix:".to_string(), @@ -72,27 +303,102 @@ impl Listener { } } +/// IO stream type that can be either plain TCP, TLS-wrapped TCP, or Unix domain. +pub enum ListenerIo { + Tcp(TcpStream), + TcpTls(TlsStream), + Unix(UnixStream), +} + +impl tokio::io::AsyncRead for ListenerIo { + fn poll_read( + mut self: std::pin::Pin<&mut Self>, + cx: &mut std::task::Context<'_>, + buf: &mut tokio::io::ReadBuf<'_>, + ) -> std::task::Poll> { + match &mut *self { + ListenerIo::Tcp(stream) => std::pin::Pin::new(stream).poll_read(cx, buf), + ListenerIo::TcpTls(stream) => std::pin::Pin::new(stream).poll_read(cx, buf), + ListenerIo::Unix(stream) => std::pin::Pin::new(stream).poll_read(cx, buf), + } + } +} + +impl tokio::io::AsyncWrite for ListenerIo { + fn poll_write( + mut self: std::pin::Pin<&mut Self>, + cx: &mut std::task::Context<'_>, + buf: &[u8], + ) -> std::task::Poll> { + match &mut *self { + ListenerIo::Tcp(stream) => std::pin::Pin::new(stream).poll_write(cx, buf), + ListenerIo::TcpTls(stream) => std::pin::Pin::new(stream).poll_write(cx, buf), + ListenerIo::Unix(stream) => std::pin::Pin::new(stream).poll_write(cx, buf), + } + } + + fn poll_flush( + mut self: std::pin::Pin<&mut Self>, + cx: &mut std::task::Context<'_>, + ) -> std::task::Poll> { + match &mut *self { + ListenerIo::Tcp(stream) => std::pin::Pin::new(stream).poll_flush(cx), + ListenerIo::TcpTls(stream) => std::pin::Pin::new(stream).poll_flush(cx), + ListenerIo::Unix(stream) => std::pin::Pin::new(stream).poll_flush(cx), + } + } + + fn poll_shutdown( + mut self: std::pin::Pin<&mut Self>, + cx: &mut std::task::Context<'_>, + ) -> std::task::Poll> { + match &mut *self { + ListenerIo::Tcp(stream) => std::pin::Pin::new(stream).poll_shutdown(cx), + ListenerIo::TcpTls(stream) => std::pin::Pin::new(stream).poll_shutdown(cx), + ListenerIo::Unix(stream) => std::pin::Pin::new(stream).poll_shutdown(cx), + } + } +} + /// Allow the unified listener to plug directly into `axum::serve(...)`. impl axum::serve::Listener for Listener { type Addr = Either; - type Io = Either; + type Io = ListenerIo; - async fn accept(&mut self) -> (Self::Io, Self::Addr) { - match self { - Self::Tcp(listener) => { - let (io, addr) = listener.accept().await; - (Either::Left(io), Either::Left(addr)) - } - Self::Unix(listener) => { - let (io, addr) = listener.accept().await; - (Either::Right(io), Either::Right(addr)) + #[allow(refining_impl_trait)] + fn accept(&mut self) -> std::pin::Pin + Send + '_>> { + Box::pin(async { + match self { + Self::Tcp(listener) => { + let (io, addr) = listener.accept().await; + (ListenerIo::Tcp(io), Either::Left(addr)) + } + Self::TcpTls(listener, tls_acceptor) => { + let (io, addr) = listener.accept().await; + // Perform TLS handshake + match tls_acceptor.accept(io).await { + Ok(tls_io) => (ListenerIo::TcpTls(tls_io), Either::Left(addr)), + Err(e) => { + // Log the TLS error for debugging + warn!("TLS handshake failed from {}: {}", addr, e); + // On TLS error, recursively accept the next connection instead + // This prevents the server from crashing on TLS errors + self.accept().await + } + } + } + Self::Unix(listener) => { + let (io, addr) = listener.accept().await; + (ListenerIo::Unix(io), Either::Right(addr)) + } } - } + }) } fn local_addr(&self) -> Result { match self { Self::Tcp(listener) => listener.local_addr().map(Either::Left), + Self::TcpTls(listener, _) => listener.local_addr().map(Either::Left), Self::Unix(listener) => listener.local_addr().map(Either::Right), } } diff --git a/vllm/entrypoints/openai/cli_args.py b/vllm/entrypoints/openai/cli_args.py index 1533895edcdd..36a47951b1db 100644 --- a/vllm/entrypoints/openai/cli_args.py +++ b/vllm/entrypoints/openai/cli_args.py @@ -276,8 +276,26 @@ class FrontendArgs(BaseFrontendArgs): ssl_cert_reqs: int = int(ssl.CERT_NONE) """Whether client certificate is required (see stdlib ssl module's).""" ssl_ciphers: str | None = None - """SSL cipher suites for HTTPS (TLS 1.2 and below only). - Example: 'ECDHE-RSA-AES256-GCM-SHA384:ECDHE-RSA-CHACHA20-POLY1305'""" + """SSL cipher suites (colon-separated). For Rust frontend: rustls only + supports modern secure cipher suites (TLS 1.2+) and does not allow weak + or legacy ciphers. + + IMPORTANT: This parameter is ACCEPTED FOR VALIDATION AND LOGGING ONLY. + It does NOT restrict which ciphers the server accepts - rustls always + uses its hardcoded secure defaults and provides no API to configure or + restrict cipher suites. + + This argument exists purely for CLI compatibility with Python/Uvicorn. + Invalid ciphers are logged as warnings but do not cause errors. The + server will always offer all rustls default ciphers regardless of this + setting. + + Valid formats: OpenSSL names (e.g. + 'ECDHE-RSA-AES256-GCM-SHA384:ECDHE-RSA-AES128-GCM-SHA256') or internal + rustls names (e.g. 'TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384'). + Supported: ECDHE-RSA/ECDSA with AES-128/256-GCM, CHACHA20-POLY1305 + (TLS 1.2); AES-128/256-GCM-SHA256/384, CHACHA20-POLY1305-SHA256 + (TLS 1.3).""" root_path: str | None = None """FastAPI root_path when app is behind a path based routing proxy.""" middleware: list[str] = field(default_factory=lambda: [])