[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
)