[Rust Frontend] Add support for TLS in render server (#54999)

Co-authored-by: Bugen Zhao <i@bugenzhao.com> Co-authored-by: Richard <noreply@moonshot.cn> Signed-off-by: Wen Zhou <wenzhou@redhat.com> Signed-off-by: Bugen Zhao <i@bugenzhao.com>

Wen Zhou committed Sep 3, 2026 at 23:56 UTC c7e6e36fa93a5b8cb95b74fa96e4abdf2f0be51d
5 files changed +215 -64
rust/README.md
+21
@@ -90,6 +90,27 @@ It exposes `/v1/chat/completions/render` and `/v1/completions/render`. Only
90 tokenizer and model configuration files are loaded; model weights, PyTorch,
91 and vLLM kernels are not required.
92
93 +To serve HTTPS, pass `--ssl-certfile`. If the certificate and private key are
94 +in separate files, pass `--ssl-keyfile` for the key; otherwise the key is read from
95 +the certificate file. For mTLS, pass `--ssl-ca-certs` with a CA
96 +bundle and set `--ssl-cert-reqs` to `1` (optional) or `2` (required) to
97 +verify client certificates. Use `--ssl-ciphers` to override the default
98 +OpenSSL cipher list.
99 +
100 +```bash
101 +# Combined PEM (cert + key in one file):
102 +vllm-rs \
103 + render Qwen/Qwen3-32B \
104 + --host 127.0.0.1 --max-model-len 32768 \
105 + --ssl-certfile /path/to/combined.pem
106 +
107 +# Separate cert and key files:
108 +vllm-rs \
109 + render Qwen/Qwen3-32B \
110 + --host 127.0.0.1 --max-model-len 32768 \
111 + --ssl-certfile /path/to/cert.pem --ssl-keyfile /path/to/key.pem
112 +```
113 +
114 The render endpoints return the public token-in `GenerateRequest` consumed by
115 the Rust `/inference/v1/generate` endpoint. A chat render response, or one item
116 from a completion render response, can be submitted to that endpoint without
rust/src/cmd/src/cli.rs
+15 -47
@@ -7,6 +7,7 @@
7 //! - Engine args: <https://github.com/vllm-project/vllm/blob/bc2c0c86efb28e77677a3cfb8687e976914a313a/vllm/engine/arg_utils.py#L657-L1311>
8 //! - Environment variables: <https://github.com/vllm-project/vllm/blob/bc2c0c86efb28e77677a3cfb8687e976914a313a/vllm/envs.py#L472>
9
10 +mod ssl;
11 mod unsupported;
12
13 use std::collections::HashMap;
@@ -30,9 +31,10 @@ use vllm_managed_engine::cli::{ManagedEngineArgs, repartition_managed_engine_arg
31 use vllm_server::{
32 ApiServerOptions, ChatTemplateContentFormatOption, Config, CoordinatorMode, CorsConfig,
33 DEFAULT_KEEP_ALIVE_TIMEOUT, HttpListenerMode, LoraModulePath, ParserSelection, RenderConfig,
33 - RendererSelection, TlsConfig,
34 + RendererSelection,
35 };
36
37 +use crate::cli::ssl::SslArgs;
38 use crate::cli::unsupported::UnsupportedArgs;
39
40 /// Top-level parser for the `vllm-rs` binary.
@@ -143,10 +145,15 @@ pub struct RenderArgs {
145 /// Maximum accepted logprobs count; -1 disables the cap.
146 #[arg(long, value_parser = clap::value_parser!(i32).range(-1..), allow_negative_numbers = true)]
147 max_logprobs: Option<i32>,
148 + /// TLS options for HTTPS/mTLS.
149 + #[command(flatten)]
150 + ssl: SslArgs,
151 }
152
153 impl RenderArgs {
154 pub(super) fn into_config(self) -> RenderConfig {
155 + let tls = self.ssl.tls_config();
156 +
157 RenderConfig {
158 model: self.model,
159 served_model_name: self.served_model_name,
@@ -160,6 +167,7 @@ impl RenderArgs {
167 chat_template_content_format: self.chat_template_content_format,
168 max_model_len: self.max_model_len,
169 max_logprobs: self.max_logprobs,
170 + tls,
171 }
172 }
173 }
@@ -360,33 +368,10 @@ pub struct SharedRuntimeArgs {
368 #[serde(default)]
369 pub allow_credentials: bool,
370
363 - /// The file path to the SSL key file. When omitted, the key is read from
364 - /// `--ssl-certfile` (combined PEM).
365 - #[arg(long)]
366 - #[serde(default)]
367 - pub ssl_keyfile: Option<String>,
368 -
369 - /// The file path to the SSL cert file. Enables TLS when set.
370 - #[arg(long)]
371 - #[serde(default)]
372 - pub ssl_certfile: Option<String>,
373 -
374 - /// The CA certificates file used to verify client certificates (mTLS).
375 - #[arg(long)]
376 - #[serde(default)]
377 - pub ssl_ca_certs: Option<String>,
378 -
379 - /// Whether a client certificate is required: 0 = none, 1 = optional,
380 - /// 2 = required (mirrors Python's `ssl.CERT_*`).
381 - #[arg(long, default_value_t = 0, value_parser = clap::value_parser!(i32).range(0..=2))]
382 - #[serde(default)]
383 - pub ssl_cert_reqs: i32,
384 -
385 - /// OpenSSL cipher string for HTTPS (TLS 1.2 and below).
386 - /// When unset, the linked OpenSSL's default suites are used.
387 - #[arg(long)]
388 - #[serde(default)]
389 - pub ssl_ciphers: Option<String>,
371 + /// TLS options for HTTPS/mTLS.
372 + #[command(flatten)]
373 + #[serde(default, flatten)]
374 + pub ssl: SslArgs,
375
376 /// Profiler configuration forwarded by the Python supervisor.
377 ///
@@ -478,7 +463,7 @@ impl SharedRuntimeArgs {
463 let keep_alive_timeout = self.keep_alive_timeout();
464 let api_server_options = self.api_server_options();
465 let cors = self.cors_config();
481 - let tls = self.tls_config();
466 + let tls = self.ssl.tls_config();
467 let profiler = self.profiler();
468
469 Config {
@@ -536,7 +521,7 @@ impl SharedRuntimeArgs {
521 let keep_alive_timeout = self.keep_alive_timeout();
522 let api_server_options = self.api_server_options();
523 let cors = self.cors_config();
539 - let tls = self.tls_config();
524 + let tls = self.ssl.tls_config();
525 let profiler = self.profiler();
526
527 Config {
@@ -591,23 +576,6 @@ impl SharedRuntimeArgs {
576 allow_credentials: self.allow_credentials,
577 }
578 }
594 -
595 - /// Build the TLS config: `Some` when any `ssl_*` argument is set, else
596 - /// `None` (plaintext). The combination is validated in [`Config::validate`].
597 - fn tls_config(&self) -> Option<TlsConfig> {
598 - let tls_requested = self.ssl_certfile.is_some()
599 - || self.ssl_keyfile.is_some()
600 - || self.ssl_ca_certs.is_some()
601 - || self.ssl_cert_reqs != 0
602 - || self.ssl_ciphers.is_some();
603 - tls_requested.then(|| TlsConfig {
604 - cert_file: self.ssl_certfile.clone(),
605 - key_file: self.ssl_keyfile.clone(),
606 - ca_certs: self.ssl_ca_certs.clone(),
607 - cert_reqs: self.ssl_cert_reqs,
608 - ciphers: self.ssl_ciphers.clone(),
609 - })
610 - }
579 }
580
581 fn default_engine_ready_timeout_secs() -> u64 {
rust/src/cmd/src/cli/ssl.rs new
+61
@@ -0,0 +1,61 @@
1 +// SPDX-License-Identifier: Apache-2.0
2 +// SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 +
4 +//! TLS CLI options mirroring Python's uvicorn `ssl_*` arguments, shared by the
5 +//! OpenAI frontend and the render server.
6 +
7 +use clap::Args;
8 +use serde::Deserialize;
9 +use vllm_server::TlsConfig;
10 +
11 +/// TLS options mirroring Python's uvicorn `ssl_*` arguments, shared by the
12 +/// OpenAI frontend and the render server.
13 +#[derive(Clone, Debug, Default, Args, PartialEq, Eq, Deserialize)]
14 +pub struct SslArgs {
15 + /// The file path to the SSL key file. When omitted, the key is read from
16 + /// `--ssl-certfile` (combined PEM).
17 + #[arg(long)]
18 + #[serde(default)]
19 + pub ssl_keyfile: Option<String>,
20 +
21 + /// The file path to the SSL cert file. Enables TLS when set.
22 + #[arg(long)]
23 + #[serde(default)]
24 + pub ssl_certfile: Option<String>,
25 +
26 + /// The CA certificates file used to verify client certificates (mTLS).
27 + #[arg(long)]
28 + #[serde(default)]
29 + pub ssl_ca_certs: Option<String>,
30 +
31 + /// Whether a client certificate is required: 0 = none, 1 = optional,
32 + /// 2 = required (mirrors Python's `ssl.CERT_*`).
33 + #[arg(long, default_value_t = 0, value_parser = clap::value_parser!(i32).range(0..=2))]
34 + #[serde(default)]
35 + pub ssl_cert_reqs: i32,
36 +
37 + /// OpenSSL cipher string for HTTPS (TLS 1.2 and below).
38 + /// When unset, the linked OpenSSL's default suites are used.
39 + #[arg(long)]
40 + #[serde(default)]
41 + pub ssl_ciphers: Option<String>,
42 +}
43 +
44 +impl SslArgs {
45 + /// Build the TLS config: `Some` when any `ssl_*` argument is set, else
46 + /// `None` (plaintext). The combination is validated in [`TlsConfig::validate`].
47 + pub fn tls_config(&self) -> Option<TlsConfig> {
48 + let tls_requested = self.ssl_certfile.is_some()
49 + || self.ssl_keyfile.is_some()
50 + || self.ssl_ca_certs.is_some()
51 + || self.ssl_cert_reqs != 0
52 + || self.ssl_ciphers.is_some();
53 + tls_requested.then(|| TlsConfig {
54 + cert_file: self.ssl_certfile.clone(),
55 + key_file: self.ssl_keyfile.clone(),
56 + ca_certs: self.ssl_ca_certs.clone(),
57 + cert_reqs: self.ssl_cert_reqs,
58 + ciphers: self.ssl_ciphers.clone(),
59 + })
60 + }
61 +}
rust/src/cmd/src/cli/tests.rs
+92 -16
@@ -31,7 +31,7 @@ fn bench_serve_args_parse_without_managed_engine_repartition() {
31 }
32
33 #[test]
34 -fn render_args_build_supported_config() {
34 +fn render_args_build_config_without_tls() {
35 let cli = Cli::try_parse_from([
36 "vllm-rs",
37 "render",
@@ -63,6 +63,76 @@ fn render_args_build_supported_config() {
63 assert_eq!(config.reasoning_parser, ParserSelection::Auto);
64 assert_eq!(config.renderer, RendererSelection::DeepSeekV32);
65 assert_eq!(config.max_logprobs, Some(-1));
66 + assert!(config.tls.is_none());
67 +}
68 +
69 +#[test]
70 +fn render_args_build_config_with_tls() {
71 + let cli = Cli::try_parse_from([
72 + "vllm-rs",
73 + "render",
74 + "Qwen/Qwen2.5-0.5B-Instruct",
75 + "--max-model-len",
76 + "32768",
77 + "--ssl-certfile",
78 + "/tmp/cert.pem",
79 + "--ssl-keyfile",
80 + "/tmp/key.pem",
81 + ])
82 + .unwrap();
83 +
84 + let Command::Render(args) = cli.command else {
85 + panic!("expected render args");
86 + };
87 + let config = args.into_config();
88 +
89 + let tls = config.tls.expect("TLS should be enabled");
90 + assert_eq!(tls.cert_file.as_deref(), Some("/tmp/cert.pem"));
91 + assert_eq!(tls.key_file.as_deref(), Some("/tmp/key.pem"));
92 +}
93 +
94 +#[test]
95 +fn render_args_reject_tls_without_certificate() {
96 + let cli = Cli::try_parse_from([
97 + "vllm-rs",
98 + "render",
99 + "Qwen/Qwen2.5-0.5B-Instruct",
100 + "--max-model-len",
101 + "32768",
102 + "--ssl-keyfile",
103 + "/tmp/key.pem",
104 + ])
105 + .unwrap();
106 +
107 + let Command::Render(args) = cli.command else {
108 + panic!("expected render args");
109 + };
110 + let error = args.into_config().validate().unwrap_err();
111 +
112 + assert!(error.to_string().contains("--ssl-certfile is required"));
113 +}
114 +
115 +#[test]
116 +fn render_args_reject_client_cert_verification_without_ca() {
117 + let cli = Cli::try_parse_from([
118 + "vllm-rs",
119 + "render",
120 + "Qwen/Qwen2.5-0.5B-Instruct",
121 + "--max-model-len",
122 + "32768",
123 + "--ssl-certfile",
124 + "/tmp/cert.pem",
125 + "--ssl-cert-reqs",
126 + "2",
127 + ])
128 + .unwrap();
129 +
130 + let Command::Render(args) = cli.command else {
131 + panic!("expected render args");
132 + };
133 + let error = args.into_config().validate().unwrap_err();
134 +
135 + assert!(error.to_string().contains("--ssl-ca-certs is required"));
136 }
137
138 #[test]
@@ -127,11 +197,13 @@ fn serve_args_forward_python_flags_with_separator() {
197 ],
198 ),
199 allow_credentials: false,
130 - ssl_keyfile: None,
131 - ssl_certfile: None,
132 - ssl_ca_certs: None,
133 - ssl_cert_reqs: 0,
134 - ssl_ciphers: None,
200 + ssl: SslArgs {
201 + ssl_keyfile: None,
202 + ssl_certfile: None,
203 + ssl_ca_certs: None,
204 + ssl_cert_reqs: 0,
205 + ssl_ciphers: None,
206 + },
207 profiler_config: None,
208 },
209 managed_engine: ManagedEngineArgs {
@@ -944,11 +1016,13 @@ fn frontend_args_accept_json() {
1016 ],
1017 ),
1018 allow_credentials: false,
947 - ssl_keyfile: None,
948 - ssl_certfile: None,
949 - ssl_ca_certs: None,
950 - ssl_cert_reqs: 0,
951 - ssl_ciphers: None,
1019 + ssl: SslArgs {
1020 + ssl_keyfile: None,
1021 + ssl_certfile: None,
1022 + ssl_ca_certs: None,
1023 + ssl_cert_reqs: 0,
1024 + ssl_ciphers: None,
1025 + },
1026 profiler_config: None,
1027 },
1028 },
@@ -1539,11 +1613,13 @@ fn serve_args_accept_handshake_aliases() {
1613 ],
1614 ),
1615 allow_credentials: false,
1542 - ssl_keyfile: None,
1543 - ssl_certfile: None,
1544 - ssl_ca_certs: None,
1545 - ssl_cert_reqs: 0,
1546 - ssl_ciphers: None,
1616 + ssl: SslArgs {
1617 + ssl_keyfile: None,
1618 + ssl_certfile: None,
1619 + ssl_ca_certs: None,
1620 + ssl_cert_reqs: 0,
1621 + ssl_ciphers: None,
1622 + },
1623 profiler_config: None,
1624 },
1625 managed_engine: ManagedEngineArgs {
rust/src/server/src/render.rs
+26 -1
@@ -14,7 +14,11 @@ use vllm_chat::{
14 };
15 use vllm_text::TextRequestProcessor;
16
17 -use crate::{HttpListenerMode, listener::Listener};
17 +use crate::{
18 + HttpListenerMode, TlsConfig,
19 + listener::{Listener, MaybeTlsListener},
20 + tls,
21 +};
22
23 /// Configuration for the engine-free text rendering server.
24 #[derive(Debug)]
@@ -31,6 +35,7 @@ pub struct RenderConfig {
35 pub chat_template_content_format: ChatTemplateContentFormatOption,
36 pub max_model_len: u32,
37 pub max_logprobs: Option<i32>,
38 + pub tls: Option<TlsConfig>,
39 }
40
41 impl RenderConfig {
@@ -41,6 +46,9 @@ impl RenderConfig {
46 if self.max_logprobs.is_some_and(|value| value < -1) {
47 bail!("max_logprobs must be non-negative or -1");
48 }
49 + if let Some(tls) = &self.tls {
50 + tls.validate()?;
51 + }
52 Ok(())
53 }
54 }
@@ -87,6 +95,12 @@ async fn build_state(config: &RenderConfig) -> Result<Arc<RenderState>> {
95 /// to an inference engine.
96 pub async fn serve_render(config: RenderConfig, shutdown: CancellationToken) -> Result<()> {
97 config.validate().context("invalid render server configuration")?;
98 + let tls_config = config
99 + .tls
100 + .as_ref()
101 + .map(tls::build_server_config)
102 + .transpose()
103 + .context("invalid TLS configuration")?;
104 let state = tokio::select! {
105 result = build_state(&config) => result?,
106 _ = shutdown.cancelled() => return Ok(()),
@@ -99,8 +113,18 @@ pub async fn serve_render(config: RenderConfig, shutdown: CancellationToken) ->
113 .await
114 .with_context(|| format!("failed to bind {}:{}", config.host, config.port))?;
115 let address = listener.local_addr_display()?;
116 + let scheme = if tls_config.is_some() {
117 + "https"
118 + } else {
119 + "http"
120 + };
121 + let listener = match tls_config {
122 + Some(context) => MaybeTlsListener::tls(listener, context),
123 + None => MaybeTlsListener::plain(listener),
124 + };
125 info!(
126 %address,
127 + scheme,
128 model = %config.model,
129 "starting engine-free Rust render server"
130 );
@@ -132,6 +156,7 @@ mod tests {
156 chat_template_content_format: ChatTemplateContentFormatOption::Auto,
157 max_model_len: 128,
158 max_logprobs: None,
159 + tls: None,
160 },
161 shutdown,
162 )