[Rust Frontend] Add static HTTPS and mTLS support for HTTP and gRPC (#45890)

Co-authored-by: Bugen Zhao <i@bugenzhao.com> Co-authored-by: mergify[bot] <37929162+mergify[bot]@users.noreply.github.com> Signed-off-by: Tahsin Tunan <tahsintunan@gmail.com> Signed-off-by: Bugen Zhao <i@bugenzhao.com>

Tahsin Tunan committed Jun 30, 2026 at 07:45 UTC b8cb75b1497a9bdae39964b3beee34eaaf5a678f
16 files changed +1942 -125
rust/Cargo.lock
+69 -20
@@ -272,6 +272,18 @@ version = "1.1.2"
272 source = "registry+https://github.com/rust-lang/crates.io-index"
273 checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0"
274
275 +[[package]]
276 +name = "auto_enums"
277 +version = "0.8.9"
278 +source = "registry+https://github.com/rust-lang/crates.io-index"
279 +checksum = "2e4487600931c9a89f8db7ffbdf3fbdd45bb7bd85e26861f659a463cd0dff966"
280 +dependencies = [
281 + "derive_utils",
282 + "proc-macro2",
283 + "quote",
284 + "syn 2.0.117",
285 +]
286 +
287 [[package]]
288 name = "auto_impl"
289 version = "1.3.0"
@@ -938,6 +950,17 @@ dependencies = [
950 "unicode-xid",
951 ]
952
953 +[[package]]
954 +name = "derive_utils"
955 +version = "0.15.1"
956 +source = "registry+https://github.com/rust-lang/crates.io-index"
957 +checksum = "362f47930db19fe7735f527e6595e4900316b893ebf6d48ad3d31be928d57dd6"
958 +dependencies = [
959 + "proc-macro2",
960 + "quote",
961 + "syn 2.0.117",
962 +]
963 +
964 [[package]]
965 name = "digest"
966 version = "0.10.7"
@@ -1478,9 +1501,9 @@ dependencies = [
1501
1502 [[package]]
1503 name = "h2"
1481 -version = "0.4.13"
1504 +version = "0.4.15"
1505 source = "registry+https://github.com/rust-lang/crates.io-index"
1483 -checksum = "2f44da3a8150a6703ed5d34e164b875fd14c2cdab9af1252a9a1020bde2bdc54"
1506 +checksum = "6cb093c84e8bd9b188d4c4a8cb6579fc016968d14c99882163cd3ff402a4f155"
1507 dependencies = [
1508 "atomic-waker",
1509 "bytes",
@@ -1638,9 +1661,9 @@ checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9"
1661
1662 [[package]]
1663 name = "hyper"
1641 -version = "1.8.1"
1664 +version = "1.10.1"
1665 source = "registry+https://github.com/rust-lang/crates.io-index"
1643 -checksum = "2ab2d4f250c3d7b1c9fcdff1cece94ea4e2dfbec68614f7b87cb205f24ca9d11"
1666 +checksum = "55281c53a1894c864990125767da440a4e630446785086f52523b20033b74498"
1667 dependencies = [
1668 "atomic-waker",
1669 "bytes",
@@ -1653,7 +1676,6 @@ dependencies = [
1676 "httpdate",
1677 "itoa",
1678 "pin-project-lite",
1656 - "pin-utils",
1679 "smallvec",
1680 "tokio",
1681 "want",
@@ -2569,15 +2591,14 @@ dependencies = [
2591
2592 [[package]]
2593 name = "openssl"
2572 -version = "0.10.76"
2594 +version = "0.10.81"
2595 source = "registry+https://github.com/rust-lang/crates.io-index"
2574 -checksum = "951c002c75e16ea2c65b8c7e4d3d51d5530d8dfa7d060b4776828c88cfb18ecf"
2596 +checksum = "77823a27f0babb03091cb9ed9ef80af3b39dbc82f97e8fa530374b7dafd87a45"
2597 dependencies = [
2598 "bitflags",
2599 "cfg-if",
2600 "foreign-types",
2601 "libc",
2580 - "once_cell",
2602 "openssl-macros",
2603 "openssl-sys",
2604 ]
@@ -2610,9 +2631,9 @@ dependencies = [
2631
2632 [[package]]
2633 name = "openssl-sys"
2613 -version = "0.9.112"
2634 +version = "0.9.117"
2635 source = "registry+https://github.com/rust-lang/crates.io-index"
2615 -checksum = "57d55af3b3e226502be1526dfdba67ab0e9c96fc293004e79576b2b9edb0dbdb"
2636 +checksum = "b47e7e6bb2c38cd930d25a23b40fa52e068c10e85f3e03a7f5ba5aaca5713695"
2637 dependencies = [
2638 "cc",
2639 "libc",
@@ -2783,12 +2804,6 @@ version = "0.2.17"
2804 source = "registry+https://github.com/rust-lang/crates.io-index"
2805 checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
2806
2786 -[[package]]
2787 -name = "pin-utils"
2788 -version = "0.1.0"
2789 -source = "registry+https://github.com/rust-lang/crates.io-index"
2790 -checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184"
2791 -
2807 [[package]]
2808 name = "pkg-config"
2809 version = "0.3.32"
@@ -2988,7 +3003,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
3003 checksum = "343d3bd7056eda839b03204e68deff7d1b13aba7af2b2fd16890697274262ee7"
3004 dependencies = [
3005 "heck",
2991 - "itertools 0.10.5",
3006 + "itertools 0.14.0",
3007 "log",
3008 "multimap",
3009 "petgraph",
@@ -3009,7 +3024,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
3024 checksum = "27c6023962132f4b30eb4c172c91ce92d933da334c59c23cddee82358ddafb0b"
3025 dependencies = [
3026 "anyhow",
3012 - "itertools 0.10.5",
3027 + "itertools 0.14.0",
3028 "proc-macro2",
3029 "quote",
3030 "syn 2.0.117",
@@ -3503,9 +3518,9 @@ dependencies = [
3518
3519 [[package]]
3520 name = "rustls-pki-types"
3506 -version = "1.14.0"
3521 +version = "1.14.1"
3522 source = "registry+https://github.com/rust-lang/crates.io-index"
3508 -checksum = "be040f8b0a225e40375822a563fa9524378b9d63112f53e19ffff34df5d33fdd"
3523 +checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9"
3524 dependencies = [
3525 "zeroize",
3526 ]
@@ -4385,6 +4400,22 @@ dependencies = [
4400 "serde_json",
4401 ]
4402
4403 +[[package]]
4404 +name = "tls-listener"
4405 +version = "0.11.2"
4406 +source = "registry+https://github.com/rust-lang/crates.io-index"
4407 +checksum = "1461056cc1ef47003f7ee16e4cef3741068d4c7f6b627bfce49b7c00c120a530"
4408 +dependencies = [
4409 + "axum",
4410 + "futures-util",
4411 + "openssl",
4412 + "pin-project-lite",
4413 + "thiserror 2.0.18",
4414 + "tokio",
4415 + "tokio-openssl",
4416 + "tracing",
4417 +]
4418 +
4419 [[package]]
4420 name = "tokenizers"
4421 version = "0.22.2"
@@ -4457,6 +4488,17 @@ dependencies = [
4488 "tokio",
4489 ]
4490
4491 +[[package]]
4492 +name = "tokio-openssl"
4493 +version = "0.6.5"
4494 +source = "registry+https://github.com/rust-lang/crates.io-index"
4495 +checksum = "59df6849caa43bb7567f9a36f863c447d95a11d5903c9cc334ba32576a27eadd"
4496 +dependencies = [
4497 + "openssl",
4498 + "openssl-sys",
4499 + "tokio",
4500 +]
4501 +
4502 [[package]]
4503 name = "tokio-rustls"
4504 version = "0.26.4"
@@ -5220,6 +5262,7 @@ dependencies = [
5262 "anyhow",
5263 "async-openai",
5264 "asynk-strim-attr",
5265 + "auto_enums",
5266 "axum",
5267 "bytes",
5268 "clap",
@@ -5227,10 +5270,13 @@ dependencies = [
5270 "expect-test",
5271 "futures",
5272 "http-body",
5273 + "hyper",
5274 + "hyper-util",
5275 "indexmap 2.13.0",
5276 "itertools 0.14.0",
5277 "libc",
5278 "llm-multimodal",
5279 + "openssl",
5280 "prost",
5281 "prost-types",
5282 "rmp-serde",
@@ -5242,8 +5288,11 @@ dependencies = [
5288 "sha2",
5289 "socket2",
5290 "subtle",
5291 + "tempfile",
5292 "thiserror-ext",
5293 + "tls-listener",
5294 "tokio",
5295 + "tokio-openssl",
5296 "tokio-stream",
5297 "tokio-util",
5298 "tonic",
rust/Cargo.toml
+10
@@ -26,6 +26,7 @@ arc-swap = "1.9.0"
26 async-openai = { version = "0.33.1", default-features = false, features = ["native-tls"] }
27 async-trait = "0.1.89"
28 asynk-strim-attr = "0.1.0"
29 +auto_enums = { version = "0.8.9", features = ["tokio1"] }
30 axum = "0.8.8"
31 base64 = "0.22.1"
32 bytemuck = { version = "1.25.0", features = ["extern_crate_alloc"] }
@@ -43,6 +44,12 @@ half = { version = "2.7.1", features = ["bytemuck"] }
44 hex = "0.4.3"
45 hf-hub = { version = "0.5.0", default-features = false, features = ["tokio"] }
46 http-body = "1.0.1"
47 +hyper = { version = "1.10.1", features = ["http1", "server"] }
48 +hyper-util = { version = "0.1.20", features = [
49 + "server-graceful",
50 + "service",
51 + "tokio",
52 +] }
53 indexmap = "2.13.0"
54 itertools = "0.14.0"
55 libc = "0.2.177"
@@ -54,6 +61,7 @@ native-tls-vendored = { package = "native-tls", version = "0.2.18", features = [
61 ndarray = { version = "0.16.1", features = ["serde"] }
62 openai-harmony = { package = "oss-harmony", git = "https://github.com/oss-harmony/harmony", tag = "v0.0.11", default-features = false }
63 openai-protocol = "1.6.0"
64 +openssl = "0.10"
65 parking_lot = "0.12.5"
66 paste = "1.0.15"
67 prometheus-client = "0.24.0"
@@ -89,6 +97,7 @@ thiserror = "2.0.16"
97 thiserror-ext = "0.3.0"
98 tiktoken-rs = "0.9.1"
99 time = { version = "0.3.47", features = ["formatting", "local-offset", "macros"] }
100 +tls-listener = { version = "0.11.2", default-features = false, features = ["openssl", "tokio-net", "axum"] }
101 tokenizers = "0.22.0"
102 tokio = { version = "1.47.1", features = [
103 "macros",
@@ -97,6 +106,7 @@ tokio = { version = "1.47.1", features = [
106 "sync",
107 "time",
108 ] }
109 +tokio-openssl = "0.6"
110 tokio-stream = "0.1"
111 tokio-util = { version = "0.7.18", features = ["rt"] }
112 tonic = "0.14.5"
rust/src/cmd/src/cli.rs
+66 -1
@@ -25,7 +25,7 @@ use vllm_managed_engine::ManagedEngineConfig;
25 use vllm_managed_engine::cli::{ManagedEngineArgs, repartition_managed_engine_args};
26 use vllm_server::{
27 ApiServerOptions, ChatTemplateContentFormatOption, Config, CoordinatorMode, CorsConfig,
28 - HttpListenerMode, ParserSelection, RendererSelection,
28 + DEFAULT_KEEP_ALIVE_TIMEOUT, HttpListenerMode, ParserSelection, RendererSelection, TlsConfig,
29 };
30
31 use crate::cli::unsupported::UnsupportedArgs;
@@ -154,6 +154,11 @@ pub struct SharedRuntimeArgs {
154 #[arg(long, default_value_t = 0)]
155 #[serde(default)]
156 pub shutdown_timeout: u64,
157 + /// Maximum idle time (seconds) on a keep-alive HTTP connection before the
158 + /// server closes it (default 5).
159 + #[arg(long = "http-timeout-keep-alive", env = "VLLM_HTTP_TIMEOUT_KEEP_ALIVE")]
160 + #[serde(default)]
161 + pub http_timeout_keep_alive: Option<u64>,
162
163 /// The file path to the chat template, or the template in single-line form
164 /// for the specified model.
@@ -257,6 +262,34 @@ pub struct SharedRuntimeArgs {
262 #[serde(default)]
263 pub allow_credentials: bool,
264
265 + /// The file path to the SSL key file. When omitted, the key is read from
266 + /// `--ssl-certfile` (combined PEM).
267 + #[arg(long)]
268 + #[serde(default)]
269 + pub ssl_keyfile: Option<String>,
270 +
271 + /// The file path to the SSL cert file. Enables TLS when set.
272 + #[arg(long)]
273 + #[serde(default)]
274 + pub ssl_certfile: Option<String>,
275 +
276 + /// The CA certificates file used to verify client certificates (mTLS).
277 + #[arg(long)]
278 + #[serde(default)]
279 + pub ssl_ca_certs: Option<String>,
280 +
281 + /// Whether a client certificate is required: 0 = none, 1 = optional,
282 + /// 2 = required (mirrors Python's `ssl.CERT_*`).
283 + #[arg(long, default_value_t = 0, value_parser = clap::value_parser!(i32).range(0..=2))]
284 + #[serde(default)]
285 + pub ssl_cert_reqs: i32,
286 +
287 + /// OpenSSL cipher string for HTTPS (TLS 1.2 and below).
288 + /// When unset, the linked OpenSSL's default suites are used.
289 + #[arg(long)]
290 + #[serde(default)]
291 + pub ssl_ciphers: Option<String>,
292 +
293 /// Unsupported Python vLLM frontend arguments recognized but not yet
294 /// implemented in Rust.
295 #[educe(Debug(ignore))]
@@ -277,6 +310,13 @@ impl SharedRuntimeArgs {
310 Duration::from_secs(self.shutdown_timeout)
311 }
312
313 + /// Maximum idle time on a keep-alive HTTP connection before the server
314 + /// closes it.
315 + pub fn keep_alive_timeout(&self) -> Duration {
316 + self.http_timeout_keep_alive
317 + .map_or(DEFAULT_KEEP_ALIVE_TIMEOUT, Duration::from_secs)
318 + }
319 +
320 /// Apply fallback logic for API key configuration from env variables.
321 fn apply_env_api_key_fallback(&mut self) {
322 if self.api_key.is_empty()
@@ -301,8 +341,10 @@ impl SharedRuntimeArgs {
341 ) -> Config {
342 let ready_timeout = self.ready_timeout();
343 let shutdown_timeout = self.shutdown_timeout();
344 + let keep_alive_timeout = self.keep_alive_timeout();
345 let api_server_options = self.api_server_options();
346 let cors = self.cors_config();
347 + let tls = self.tls_config();
348
349 Config {
350 transport_mode: TransportMode::Bootstrapped {
@@ -329,10 +371,12 @@ impl SharedRuntimeArgs {
371 max_logprobs: self.max_logprobs,
372 api_server_options,
373 cors,
374 + tls,
375 api_keys: self.api_key,
376 disable_log_stats: self.disable_log_stats,
377 grpc_port: self.grpc_port,
378 shutdown_timeout,
379 + keep_alive_timeout,
380 }
381 }
382
@@ -349,8 +393,10 @@ impl SharedRuntimeArgs {
393 ) -> Config {
394 let ready_timeout = self.ready_timeout();
395 let shutdown_timeout = self.shutdown_timeout();
396 + let keep_alive_timeout = self.keep_alive_timeout();
397 let api_server_options = self.api_server_options();
398 let cors = self.cors_config();
399 + let tls = self.tls_config();
400
401 Config {
402 transport_mode: TransportMode::HandshakeOwner {
@@ -375,10 +421,12 @@ impl SharedRuntimeArgs {
421 max_logprobs: self.max_logprobs,
422 api_server_options,
423 cors,
424 + tls,
425 api_keys: self.api_key,
426 disable_log_stats: self.disable_log_stats,
427 grpc_port: self.grpc_port,
428 shutdown_timeout,
429 + keep_alive_timeout,
430 }
431 }
432
@@ -398,6 +446,23 @@ impl SharedRuntimeArgs {
446 allow_credentials: self.allow_credentials,
447 }
448 }
449 +
450 + /// Build the TLS config: `Some` when any `ssl_*` argument is set, else
451 + /// `None` (plaintext). The combination is validated in [`Config::validate`].
452 + fn tls_config(&self) -> Option<TlsConfig> {
453 + let tls_requested = self.ssl_certfile.is_some()
454 + || self.ssl_keyfile.is_some()
455 + || self.ssl_ca_certs.is_some()
456 + || self.ssl_cert_reqs != 0
457 + || self.ssl_ciphers.is_some();
458 + tls_requested.then(|| TlsConfig {
459 + cert_file: self.ssl_certfile.clone(),
460 + key_file: self.ssl_keyfile.clone(),
461 + ca_certs: self.ssl_ca_certs.clone(),
462 + cert_reqs: self.ssl_cert_reqs,
463 + ciphers: self.ssl_ciphers.clone(),
464 + })
465 + }
466 }
467
468 fn default_engine_ready_timeout_secs() -> u64 {
rust/src/cmd/src/cli/tests.rs
+167 -9
@@ -41,6 +41,7 @@ fn serve_args_forward_python_flags_with_separator() {
41 max_logprobs: None,
42 grpc_port: None,
43 shutdown_timeout: 0,
44 + http_timeout_keep_alive: None,
45 chat_template: None,
46 default_chat_template_kwargs: None,
47 chat_template_content_format: Auto,
@@ -65,6 +66,11 @@ fn serve_args_forward_python_flags_with_separator() {
66 ],
67 ),
68 allow_credentials: false,
69 + ssl_keyfile: None,
70 + ssl_certfile: None,
71 + ssl_ca_certs: None,
72 + ssl_cert_reqs: 0,
73 + ssl_ciphers: None,
74 },
75 managed_engine: ManagedEngineArgs {
76 python: "../vllm/.venv/bin/python",
@@ -363,6 +369,140 @@ fn serve_passes_enable_prompt_tokens_details_into_config() {
369 assert!(config.api_server_options.enable_prompt_tokens_details);
370 }
371
372 +#[test]
373 +fn serve_passes_tls_into_config() {
374 + let cli = Cli::try_parse_from([
375 + "vllm-rs",
376 + "serve",
377 + "Qwen/Qwen3-0.6B",
378 + "--ssl-certfile",
379 + "/tmp/cert.pem",
380 + "--ssl-keyfile",
381 + "/tmp/key.pem",
382 + "--ssl-ca-certs",
383 + "/tmp/ca.pem",
384 + "--ssl-cert-reqs",
385 + "2",
386 + ])
387 + .unwrap();
388 +
389 + let Command::Serve(args) = cli.command else {
390 + panic!("expected serve args");
391 + };
392 + let config = args.to_frontend_config("tcp://127.0.0.1:62100".to_string());
393 + let tls = config.tls.expect("tls configured");
394 + assert_eq!(tls.cert_file.as_deref(), Some("/tmp/cert.pem"));
395 + assert_eq!(tls.key_file.as_deref(), Some("/tmp/key.pem"));
396 + assert_eq!(tls.ca_certs.as_deref(), Some("/tmp/ca.pem"));
397 + assert_eq!(tls.cert_reqs, 2);
398 +}
399 +
400 +#[test]
401 +fn serve_without_ssl_flags_has_no_tls() {
402 + let cli = Cli::try_parse_from(["vllm-rs", "serve", "Qwen/Qwen3-0.6B"]).unwrap();
403 +
404 + let Command::Serve(args) = cli.command else {
405 + panic!("expected serve args");
406 + };
407 + let config = args.to_frontend_config("tcp://127.0.0.1:62100".to_string());
408 + assert!(config.tls.is_none());
409 +}
410 +
411 +#[test]
412 +fn serve_ssl_keyfile_without_certfile_fails_validation() {
413 + let cli = Cli::try_parse_from([
414 + "vllm-rs",
415 + "serve",
416 + "Qwen/Qwen3-0.6B",
417 + "--ssl-keyfile",
418 + "/tmp/key.pem",
419 + ])
420 + .unwrap();
421 +
422 + let Command::Serve(args) = cli.command else {
423 + panic!("expected serve args");
424 + };
425 + let config = args.to_frontend_config("tcp://127.0.0.1:62100".to_string());
426 + // TLS is requested (a key was given) but there is no certificate, so
427 + // validation fails loud rather than silently serving plaintext.
428 + assert_eq!(config.tls.as_ref().expect("tls requested").cert_file, None);
429 + let err = config.validate().unwrap_err().to_string();
430 + assert!(err.contains("--ssl-certfile is required"), "{err}");
431 +}
432 +
433 +#[test]
434 +fn serve_mtls_without_ca_certs_fails_validation() {
435 + let cli = Cli::try_parse_from([
436 + "vllm-rs",
437 + "serve",
438 + "Qwen/Qwen3-0.6B",
439 + "--ssl-certfile",
440 + "/tmp/cert.pem",
441 + "--ssl-cert-reqs",
442 + "2",
443 + ])
444 + .unwrap();
445 +
446 + let Command::Serve(args) = cli.command else {
447 + panic!("expected serve args");
448 + };
449 + let config = args.to_frontend_config("tcp://127.0.0.1:62100".to_string());
450 + // Client-cert verification without a CA bundle has nothing to verify
451 + // against, so it fails loud at startup.
452 + let err = config.validate().unwrap_err().to_string();
453 + assert!(err.contains("--ssl-ca-certs is required"), "{err}");
454 +}
455 +
456 +#[test]
457 +fn frontend_args_json_passes_tls_into_config() {
458 + let cli = Cli::try_parse_from([
459 + "vllm-rs",
460 + "frontend",
461 + "--listen-fd",
462 + "3",
463 + "--input-address",
464 + "ipc:///tmp/input.sock",
465 + "--output-address",
466 + "ipc:///tmp/output.sock",
467 + "--args-json",
468 + r#"{"model_tag":"Qwen/Qwen3-0.6B","ssl_certfile":"/tmp/cert.pem","ssl_keyfile":"/tmp/key.pem"}"#,
469 + ])
470 + .unwrap();
471 +
472 + let Command::Frontend(args) = cli.command else {
473 + panic!("expected frontend args");
474 + };
475 + let config = args.into_config();
476 + let tls = config.tls.expect("tls configured");
477 + assert_eq!(tls.cert_file.as_deref(), Some("/tmp/cert.pem"));
478 + assert_eq!(tls.key_file.as_deref(), Some("/tmp/key.pem"));
479 +}
480 +
481 +#[test]
482 +fn frontend_args_json_rejects_out_of_range_cert_reqs() {
483 + let cli = Cli::try_parse_from([
484 + "vllm-rs",
485 + "frontend",
486 + "--listen-fd",
487 + "3",
488 + "--input-address",
489 + "ipc:///tmp/input.sock",
490 + "--output-address",
491 + "ipc:///tmp/output.sock",
492 + "--args-json",
493 + r#"{"model_tag":"Qwen/Qwen3-0.6B","ssl_certfile":"/tmp/cert.pem","ssl_cert_reqs":5}"#,
494 + ])
495 + .unwrap();
496 +
497 + let Command::Frontend(args) = cli.command else {
498 + panic!("expected frontend args");
499 + };
500 + // The JSON path bypasses clap's range check, so validate() is the only guard.
501 + let config = args.into_config();
502 + let err = config.validate().unwrap_err().to_string();
503 + assert!(err.contains("--ssl-cert-reqs"), "{err}");
504 +}
505 +
506 #[test]
507 fn frontend_args_json_passes_enable_request_id_headers_into_config() {
508 let cli = Cli::try_parse_from([
@@ -481,13 +621,13 @@ fn serve_args_reject_unsupported_flag_arg() {
621 "vllm-rs",
622 "serve",
623 "Qwen/Qwen3-0.6B",
484 - "--ssl-keyfile",
485 - "/tmp/key.pem",
624 + "--root-path",
625 + "/prefix",
626 ])
627 .unwrap_err();
628
629 expect![[r#"
490 - error: invalid value '/tmp/key.pem' for '--ssl-keyfile <SSL_KEYFILE>': argument is not implemented in Rust frontend yet
630 + error: invalid value '/prefix' for '--root-path <ROOT_PATH>': argument is not implemented in Rust frontend yet
631
632 Remove this unsupported argument to continue.
633
@@ -562,6 +702,7 @@ fn frontend_args_accept_json() {
702 max_logprobs: None,
703 grpc_port: None,
704 shutdown_timeout: 0,
705 + http_timeout_keep_alive: None,
706 chat_template: None,
707 default_chat_template_kwargs: None,
708 chat_template_content_format: Auto,
@@ -586,6 +727,11 @@ fn frontend_args_accept_json() {
727 ],
728 ),
729 allow_credentials: false,
730 + ssl_keyfile: None,
731 + ssl_certfile: None,
732 + ssl_ca_certs: None,
733 + ssl_cert_reqs: 0,
734 + ssl_ciphers: None,
735 },
736 },
737 ),
@@ -798,14 +944,14 @@ fn frontend_args_json_rejects_unsupported_fields() {
944 "--output-address",
945 "ipc:///tmp/output.sock",
946 "--args-json",
801 - r#"{"model_tag":"Qwen/Qwen3-0.6B","ssl_keyfile":"/tmp/key.pem"}"#,
947 + r#"{"model_tag":"Qwen/Qwen3-0.6B","root_path":"/prefix"}"#,
948 ])
949 .unwrap_err();
950
951 expect![[r#"
806 - error: invalid value '{"model_tag":"Qwen/Qwen3-0.6B","ssl_keyfile":"/tmp/key.pem"}' for '--args-json <JSON>':
952 + error: invalid value '{"model_tag":"Qwen/Qwen3-0.6B","root_path":"/prefix"}' for '--args-json <JSON>':
953 The following arguments are not implemented in Rust frontend yet:
808 - - ssl_keyfile
954 + - root_path
955
956 Remove these arguments to continue.
957
@@ -825,16 +971,16 @@ fn frontend_args_json_aggregates_multiple_unsupported_fields() {
971 "--output-address",
972 "ipc:///tmp/output.sock",
973 "--args-json",
828 - r#"{"model_tag":"Qwen/Qwen3-0.6B","response_role":"assistant","ssl_keyfile":"/tmp/key.pem"}"#,
974 + r#"{"model_tag":"Qwen/Qwen3-0.6B","response_role":"assistant","root_path":"/prefix"}"#,
975 ])
976 .unwrap_err();
977
978 let actual = error.to_string().replace(": \n", ":\n");
979 expect![[r#"
834 - error: invalid value '{"model_tag":"Qwen/Qwen3-0.6B","response_role":"assistant","ssl_keyfile":"/tmp/key.pem"}' for '--args-json <JSON>':
980 + error: invalid value '{"model_tag":"Qwen/Qwen3-0.6B","response_role":"assistant","root_path":"/prefix"}' for '--args-json <JSON>':
981 The following arguments are not implemented in Rust frontend yet:
982 - response_role
837 - - ssl_keyfile
983 + - root_path
984
985 Remove these arguments to continue.
986
@@ -1077,6 +1223,7 @@ fn serve_args_accept_handshake_aliases() {
1223 max_logprobs: None,
1224 grpc_port: None,
1225 shutdown_timeout: 0,
1226 + http_timeout_keep_alive: None,
1227 chat_template: None,
1228 default_chat_template_kwargs: None,
1229 chat_template_content_format: Auto,
@@ -1101,6 +1248,11 @@ fn serve_args_accept_handshake_aliases() {
1248 ],
1249 ),
1250 allow_credentials: false,
1251 + ssl_keyfile: None,
1252 + ssl_certfile: None,
1253 + ssl_ca_certs: None,
1254 + ssl_cert_reqs: 0,
1255 + ssl_ciphers: None,
1256 },
1257 managed_engine: ManagedEngineArgs {
1258 python: "python3",
@@ -1234,10 +1386,12 @@ fn serve_frontend_config_uses_dp_address_as_advertised_host() {
1386 ],
1387 allow_credentials: false,
1388 },
1389 + tls: None,
1390 api_keys: [],
1391 disable_log_stats: false,
1392 grpc_port: None,
1393 shutdown_timeout: 0ns,
1394 + keep_alive_timeout: 5s,
1395 }
1396 "#]]
1397 .assert_debug_eq(&Config {
@@ -1315,10 +1469,12 @@ fn serve_frontend_config_keeps_tcp_transport_for_non_local_only_topology() {
1469 ],
1470 allow_credentials: false,
1471 },
1472 + tls: None,
1473 api_keys: [],
1474 disable_log_stats: false,
1475 grpc_port: None,
1476 shutdown_timeout: 0ns,
1477 + keep_alive_timeout: 5s,
1478 }
1479 "#]]
1480 .assert_debug_eq(&config);
@@ -1414,10 +1570,12 @@ fn frontend_config_uses_external_coordinator_when_coordinator_address_is_present
1570 ],
1571 allow_credentials: false,
1572 },
1573 + tls: None,
1574 api_keys: [],
1575 disable_log_stats: false,
1576 grpc_port: None,
1577 shutdown_timeout: 0ns,
1578 + keep_alive_timeout: 5s,
1579 }
1580 "#]]
1581 .assert_debug_eq(&config);
rust/src/cmd/src/cli/unsupported.rs
-21
@@ -526,18 +526,6 @@ pub struct ServerUnsupportedArgs {
526 #[arg(long)]
527 pub disable_access_log_for_endpoints: Option<Noop>,
528
529 - /// The file path to the SSL key file.
530 - #[arg(long)]
531 - pub ssl_keyfile: Option<Unsupported>,
532 -
533 - /// The file path to the SSL cert file.
534 - #[arg(long)]
535 - pub ssl_certfile: Option<Unsupported>,
536 -
537 - /// The CA certificates file.
538 - #[arg(long)]
539 - pub ssl_ca_certs: Option<Unsupported>,
540 -
529 /// Refresh SSL Context when SSL certificate files change
530 #[arg(
531 long,
@@ -547,15 +535,6 @@ pub struct ServerUnsupportedArgs {
535 )]
536 pub enable_ssl_refresh: Option<Unsupported>,
537
550 - /// Whether client certificate is required (see stdlib ssl module's).
551 - #[arg(long)]
552 - pub ssl_cert_reqs: Option<Unsupported>,
553 -
554 - /// SSL cipher suites for HTTPS (TLS 1.2 and below only).
555 - /// Example: 'ECDHE-RSA-AES256-GCM-SHA384:ECDHE-RSA-CHACHA20-POLY1305'
556 - #[arg(long)]
557 - pub ssl_ciphers: Option<Unsupported>,
558 -
538 /// FastAPI root_path when app is behind a path based routing proxy.
539 #[arg(long)]
540 pub root_path: Option<Unsupported>,
rust/src/engine-core-client/src/tests/client.rs
+13 -7
@@ -1285,18 +1285,24 @@ async fn dropping_multiple_live_streams_aborts_all_in_a_burst() {
1285 )
1286 .await;
1287
1288 - let abort =
1289 - timeout(Duration::from_secs(1), recv_engine_message(dealer)).await.unwrap();
1290 - assert_eq!(abort[0].as_ref(), &[0x01]);
1291 - let ids: Vec<String> = rmp_serde::from_slice(&abort[1]).unwrap();
1288 + // Aborts may coalesce into one burst or split across several.
1289 + let mut aborted = BTreeSet::new();
1290 + while aborted.len() < 3 {
1291 + let abort =
1292 + timeout(Duration::from_secs(1), recv_engine_message(dealer)).await.unwrap();
1293 + assert_eq!(abort[0].as_ref(), &[0x01]);
1294 + let ids: Vec<String> = rmp_serde::from_slice(&abort[1]).unwrap();
1295 + aborted.extend(ids);
1296 + }
1297 assert_eq!(
1293 - ids,
1294 - vec![
1298 + aborted,
1299 + BTreeSet::from([
1300 "req-1".to_string(),
1301 "req-2".to_string(),
1302 "req-3".to_string()
1298 - ]
1303 + ])
1304 );
1305 + // No spurious extra aborts.
1306 assert!(
1307 timeout(Duration::from_millis(100), recv_engine_message(dealer)).await.is_err()
1308 );
rust/src/server/Cargo.toml
+7
@@ -7,14 +7,18 @@ license.workspace = true
7 [dependencies]
8 anyhow.workspace = true
9 asynk-strim-attr.workspace = true
10 +auto_enums.workspace = true
11 axum.workspace = true
12 educe.workspace = true
13 futures.workspace = true
14 http-body.workspace = true
15 +hyper.workspace = true
16 +hyper-util.workspace = true
17 indexmap.workspace = true
18 itertools.workspace = true
19 libc.workspace = true
20 llm-multimodal.workspace = true
21 +openssl.workspace = true
22 prost.workspace = true
23 prost-types.workspace = true
24 rmpv.workspace = true
@@ -25,7 +29,9 @@ sha2.workspace = true
29 socket2.workspace = true
30 subtle.workspace = true
31 thiserror-ext.workspace = true
32 +tls-listener.workspace = true
33 tokio.workspace = true
34 +tokio-openssl.workspace = true
35 tokio-stream.workspace = true
36 tokio-util.workspace = true
37 tonic.workspace = true
@@ -54,6 +60,7 @@ clap.workspace = true
60 expect-test.workspace = true
61 rmp-serde.workspace = true
62 serial_test.workspace = true
63 +tempfile.workspace = true
64 tower.workspace = true
65 vllm-engine-core-client = { workspace = true, features = ["test-util"] }
66 zeromq.workspace = true
rust/src/server/examples/external_engine_openai_qwen.rs
+2
@@ -71,10 +71,12 @@ async fn main() -> Result<()> {
71 max_logprobs: None,
72 api_server_options: ApiServerOptions::default(),
73 cors: CorsConfig::default(),
74 + tls: None,
75 api_keys: Vec::new(),
76 disable_log_stats: false,
77 grpc_port: None,
78 shutdown_timeout: Duration::ZERO,
79 + keep_alive_timeout: Duration::from_secs(5),
80 };
81
82 let bind_address = format!("127.0.0.1:{port}");
rust/src/server/src/config.rs
+61
@@ -10,6 +10,10 @@ use serde_json::Value;
10 use vllm_chat::{ChatTemplateContentFormatOption, ParserSelection, RendererSelection};
11 use vllm_engine_core_client::{CoordinatorMode as EngineCoreCoordinatorMode, TransportMode};
12
13 +/// Default keep-alive idle timeout (seconds); also the head-read bound
14 +/// when keep-alive is disabled (`0`).
15 +pub const DEFAULT_KEEP_ALIVE_TIMEOUT: Duration = Duration::from_secs(5);
16 +
17 /// How the HTTP server obtains its listening socket.
18 #[derive(Debug, Clone, PartialEq, Eq, Serialize)]
19 pub enum HttpListenerMode {
@@ -99,6 +103,54 @@ impl CorsConfig {
103 }
104 }
105
106 +/// TLS settings mirroring Python's uvicorn `ssl_*` arguments.
107 +#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
108 +pub struct TlsConfig {
109 + /// PEM certificate chain file. Required when TLS is configured; may also
110 + /// hold the private key (combined PEM) when `key_file` is unset.
111 + pub cert_file: Option<String>,
112 + /// PEM private key file. When `None`, the key is read from `cert_file`
113 + /// (combined PEM).
114 + pub key_file: Option<String>,
115 + /// PEM CA bundle used to verify client certificates (mTLS). Required when
116 + /// `cert_reqs` is non-zero.
117 + pub ca_certs: Option<String>,
118 + /// Client-certificate requirement, mirroring Python's `ssl.CERT_*`:
119 + /// 0 = none, 1 = optional, 2 = required.
120 + pub cert_reqs: i32,
121 + /// OpenSSL cipher string for TLS 1.2 and below, mirroring Python's
122 + /// `ssl.set_ciphers`. `None` keeps the forward-secret AEAD default.
123 + pub ciphers: Option<String>,
124 +}
125 +
126 +impl TlsConfig {
127 + /// Structurally validate the TLS arguments; the cert/key material is parsed
128 + /// later, when the OpenSSL context is built.
129 + pub fn validate(&self) -> Result<()> {
130 + if self.cert_file.is_none() {
131 + bail!(
132 + "--ssl-certfile is required to enable TLS; \
133 + --ssl-keyfile/--ssl-ca-certs/--ssl-cert-reqs/--ssl-ciphers \
134 + cannot be used without it"
135 + );
136 + }
137 + if !matches!(self.cert_reqs, 0..=2) {
138 + bail!(
139 + "--ssl-cert-reqs must be 0 (none), 1 (optional), or 2 (required), got {}",
140 + self.cert_reqs
141 + );
142 + }
143 + if self.cert_reqs != 0 && self.ca_certs.is_none() {
144 + bail!(
145 + "--ssl-ca-certs is required when --ssl-cert-reqs is {} \
146 + (client certificate verification)",
147 + self.cert_reqs
148 + );
149 + }
150 + Ok(())
151 + }
152 +}
153 +
154 /// Normalized runtime configuration for the minimal OpenAI-compatible server.
155 #[derive(Educe, Clone, PartialEq, Eq, Serialize)]
156 #[educe(Debug)]
@@ -138,6 +190,9 @@ pub struct Config {
190 pub api_server_options: ApiServerOptions,
191 /// CORS settings applied to every HTTP response.
192 pub cors: CorsConfig,
193 + /// TLS settings. `None` serves plaintext HTTP; `Some` terminates TLS at the
194 + /// listener.
195 + pub tls: Option<TlsConfig>,
196 /// API keys accepted as bearer tokens for guarded routes.
197 #[serde(skip_serializing)]
198 #[educe(Debug(method(fmt_redacted_api_keys)))]
@@ -150,6 +205,9 @@ pub struct Config {
205 pub grpc_port: Option<u16>,
206 /// Maximum time to wait for active HTTP/gRPC requests to drain on shutdown.
207 pub shutdown_timeout: Duration,
208 + /// Maximum idle time on a keep-alive HTTP connection before the server
209 + /// closes it (`VLLM_HTTP_TIMEOUT_KEEP_ALIVE`, default 5s).
210 + pub keep_alive_timeout: Duration,
211 }
212
213 impl Config {
@@ -158,6 +216,9 @@ impl Config {
216 pub fn validate(&self) -> Result<()> {
217 vllm_chat::validate_parser_overrides(&self.tool_call_parser, &self.reasoning_parser)?;
218 self.cors.validate()?;
219 + if let Some(tls) = &self.tls {
220 + tls.validate()?;
221 + }
222 if let Some(max_logprobs) = self.max_logprobs
223 && max_logprobs < -1
224 {
rust/src/server/src/grpc/mod.rs
+78 -1
@@ -4,16 +4,21 @@ mod convert;
4
5 use std::pin::Pin;
6 use std::sync::Arc;
7 +use std::task::{Context, Poll};
8
8 -use futures::{Stream, StreamExt as _};
9 +use futures::{Stream, StreamExt as _, stream};
10 use thiserror_ext::AsReport as _;
11 +use tokio::io::{AsyncRead, AsyncWrite, ReadBuf};
12 use tokio::sync::mpsc;
13 +use tokio_openssl::SslStream;
14 use tokio_stream::wrappers::ReceiverStream;
15 +use tonic::transport::server::{Connected, TcpConnectInfo};
16 use tonic::{Request, Response, Status};
17 use tracing::info;
18 use vllm_text::{DecodedTextEvent, TextOutputStreamExt as _};
19
20 use self::convert::ResponseOpts;
21 +use crate::listener::{Listener, ListenerIo};
22 use crate::state::AppState;
23
24 /// Generated protobuf/gRPC types for the `vllm` package.
@@ -26,6 +31,78 @@ pub use pb::generate_server::GenerateServer;
31 #[cfg(test)]
32 mod tests;
33
34 +/// Newtype over `tokio-openssl`'s `SslStream` so we can implement tonic's
35 +/// [`Connected`] on it (the orphan rule blocks doing so on the foreign type).
36 +pub(crate) struct GrpcTlsStream {
37 + inner: SslStream<ListenerIo>,
38 +}
39 +
40 +impl GrpcTlsStream {
41 + pub(crate) fn new(inner: SslStream<ListenerIo>) -> Self {
42 + Self { inner }
43 + }
44 +}
45 +
46 +impl AsyncRead for GrpcTlsStream {
47 + fn poll_read(
48 + self: Pin<&mut Self>,
49 + cx: &mut Context<'_>,
50 + buf: &mut ReadBuf<'_>,
51 + ) -> Poll<std::io::Result<()>> {
52 + Pin::new(&mut self.get_mut().inner).poll_read(cx, buf)
53 + }
54 +}
55 +
56 +impl AsyncWrite for GrpcTlsStream {
57 + fn poll_write(
58 + self: Pin<&mut Self>,
59 + cx: &mut Context<'_>,
60 + buf: &[u8],
61 + ) -> Poll<std::io::Result<usize>> {
62 + Pin::new(&mut self.get_mut().inner).poll_write(cx, buf)
63 + }
64 +
65 + fn poll_flush(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<std::io::Result<()>> {
66 + Pin::new(&mut self.get_mut().inner).poll_flush(cx)
67 + }
68 +
69 + fn poll_shutdown(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<std::io::Result<()>> {
70 + Pin::new(&mut self.get_mut().inner).poll_shutdown(cx)
71 + }
72 +}
73 +
74 +impl Connected for GrpcTlsStream {
75 + type ConnectInfo = TcpConnectInfo;
76 +
77 + fn connect_info(&self) -> TcpConnectInfo {
78 + self.inner.get_ref().connect_info()
79 + }
80 +}
81 +
82 +/// Adapt the shared server listener into tonic's incoming stream shape.
83 +pub(crate) fn incoming(listener: Listener) -> impl Stream<Item = std::io::Result<ListenerIo>> {
84 + stream::unfold(listener, |mut listener| async move {
85 + let (io, _) = axum::serve::Listener::accept(&mut listener).await;
86 + Some((Ok(io), listener))
87 + })
88 +}
89 +
90 +/// Wrap the gRPC listener so each accepted connection completes a TLS handshake
91 +/// before tonic serves it.
92 +pub(crate) fn tls_incoming(
93 + listener: Listener,
94 + context: openssl::ssl::SslContext,
95 + handshake_timeout: std::time::Duration,
96 +) -> impl Stream<Item = std::io::Result<GrpcTlsStream>> {
97 + tls_listener::builder(context)
98 + .handshake_timeout(handshake_timeout)
99 + .listen(listener)
100 + .map(|res| {
101 + res.map(|(inner, _addr)| GrpcTlsStream::new(inner))
102 + .map_err(std::io::Error::other)
103 + })
104 +}
105 +
106 /// gRPC Generate service implementation backed by the shared application state.
107 pub struct GenerateServiceImpl {
108 state: Arc<AppState>,
rust/src/server/src/grpc/tests.rs
+357 -15
@@ -1,11 +1,19 @@
1 use std::future::Future;
2 +use std::io;
3 use std::pin::Pin;
4 use std::sync::Arc;
5 use std::task::{Context, Poll};
6 +use std::time::Duration;
7
8 use futures::StreamExt as _;
9 +use hyper_util::rt::TokioIo;
10 +use openssl::ssl::{SslConnector, SslFiletype, SslMethod};
11 use serial_test::serial;
8 -use tonic::transport::Server as TonicServer;
12 +use tokio::io::{AsyncReadExt as _, AsyncWriteExt as _};
13 +use tokio::net::TcpStream;
14 +use tokio_openssl::SslStream;
15 +use tonic::transport::{Channel, Endpoint, Server as TonicServer, Uri};
16 +use tower::service_fn;
17 use vllm_chat::{
18 ChatBackend, ChatLlm, ChatRenderer, ChatRequest, ChatTextBackend, DefaultChatOutputProcessor,
19 DynChatOutputProcessor, DynChatRenderer, NewChatOutputProcessorOptions, RenderedPrompt,
@@ -22,8 +30,11 @@ use zeromq::prelude::{SocketRecv, SocketSend};
30 use zeromq::{DealerSocket, PushSocket, ZmqMessage};
31
32 use super::pb::generate_client::GenerateClient;
25 -use super::{GenerateServer, GenerateServiceImpl, pb};
33 +use super::{GenerateServer, GenerateServiceImpl, incoming, pb, tls_incoming};
34 +use crate::listener::Listener;
35 use crate::state::AppState;
36 +use crate::tls;
37 +use crate::tls_tests::{TestCerts, server_tls};
38
39 // ========================================================================================
40 // Helpers (mirrors the patterns in routes/tests.rs)
@@ -211,17 +222,12 @@ impl ChatRenderer for FakeTextBackend {
222 }
223 }
224
214 -/// Spin up a gRPC server backed by a mock engine that serves a single request
215 -/// with the given output specs. Returns the client, the gRPC server task, and
216 -/// the mock engine task.
217 -async fn grpc_test_server(
225 +/// Build the gRPC service + mock engine that serves a single request with the
226 +/// given output specs. Shared by the plaintext and TLS server fixtures.
227 +async fn setup_grpc_service(
228 engine_id: impl Into<EngineId>,
229 output_specs: Vec<(Vec<u32>, Option<EngineCoreFinishReason>)>,
220 -) -> (
221 - GenerateClient<tonic::transport::Channel>,
222 - tokio::task::JoinHandle<()>,
223 - MockEngineTask,
224 -) {
230 +) -> (GenerateServer<GenerateServiceImpl>, MockEngineTask) {
231 let ipc = IpcNamespace::new().expect("create ipc namespace");
232 let handshake_address = ipc.handshake_endpoint();
233 let engine_id = engine_id.into();
@@ -259,14 +265,29 @@ async fn grpc_test_server(
265 Arc::new(FakeTextBackend) as Arc<dyn ChatTextBackend>,
266 );
267 let state = Arc::new(AppState::new(vec!["test-model".to_string()], chat));
262 - let svc = GenerateServer::new(GenerateServiceImpl::new(state));
268 + (
269 + GenerateServer::new(GenerateServiceImpl::new(state)),
270 + engine_task,
271 + )
272 +}
273 +
274 +/// Spin up a plaintext gRPC server backed by a mock engine. Returns the client,
275 +/// the gRPC server task, and the mock engine task.
276 +async fn grpc_test_server(
277 + engine_id: impl Into<EngineId>,
278 + output_specs: Vec<(Vec<u32>, Option<EngineCoreFinishReason>)>,
279 +) -> (
280 + GenerateClient<tonic::transport::Channel>,
281 + tokio::task::JoinHandle<()>,
282 + MockEngineTask,
283 +) {
284 + let (svc, engine_task) = setup_grpc_service(engine_id, output_specs).await;
285
264 - // Bind to an OS-assigned port.
286 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.expect("bind grpc listener");
287 let addr = listener.local_addr().expect("local addr");
288
289 let server_task = tokio::spawn(async move {
269 - let incoming = tokio_stream::wrappers::TcpListenerStream::new(listener);
290 + let incoming = incoming(Listener::Tcp(listener));
291 TonicServer::builder()
292 .add_service(svc)
293 .serve_with_incoming(incoming)
@@ -274,7 +295,6 @@ async fn grpc_test_server(
295 .expect("grpc server");
296 });
297
277 - // Connect the client.
298 let grpc_client = GenerateClient::connect(format!("http://{addr}"))
299 .await
300 .expect("connect grpc client");
@@ -282,6 +302,158 @@ async fn grpc_test_server(
302 (grpc_client, server_task, engine_task)
303 }
304
305 +/// Spin up a TLS gRPC server (server cert from `certs`, `cert_reqs` mTLS mode).
306 +/// Returns the address, the server task, and the mock engine task.
307 +async fn grpc_tls_test_server(
308 + engine_id: impl Into<EngineId>,
309 + output_specs: Vec<(Vec<u32>, Option<EngineCoreFinishReason>)>,
310 + certs: &TestCerts,
311 + cert_reqs: i32,
312 +) -> (String, tokio::task::JoinHandle<()>, MockEngineTask) {
313 + let (svc, engine_task) = setup_grpc_service(engine_id, output_specs).await;
314 + let context = tls::build_grpc_server_config(&server_tls(certs, cert_reqs))
315 + .expect("build grpc tls config");
316 +
317 + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.expect("bind grpc listener");
318 + let addr = listener.local_addr().expect("local addr").to_string();
319 +
320 + let server_task = tokio::spawn(async move {
321 + let incoming = tls_incoming(Listener::Tcp(listener), context, tls::TLS_HANDSHAKE_TIMEOUT);
322 + TonicServer::builder()
323 + .add_service(svc)
324 + .serve_with_incoming(incoming)
325 + .await
326 + .expect("grpc tls server");
327 + });
328 +
329 + (addr, server_task, engine_task)
330 +}
331 +
332 +/// Build a tonic `Generate` client over a tokio-openssl connector, optionally
333 +/// with a client identity for mTLS. Hand-rolled because tonic 0.14 ships no
334 +/// OpenSSL transport.
335 +async fn grpc_tls_client(
336 + certs: &TestCerts,
337 + addr: &str,
338 + identity: Option<&str>,
339 +) -> Result<GenerateClient<Channel>, tonic::transport::Error> {
340 + let ca = certs.path("ca.pem");
341 + let identity = identity.map(|name| {
342 + (
343 + certs.path(&format!("{name}.pem")),
344 + certs.path(&format!("{name}.key")),
345 + )
346 + });
347 + let target = addr.to_string();
348 +
349 + let connector = service_fn(move |_: Uri| {
350 + let ca = ca.clone();
351 + let identity = identity.clone();
352 + let target = target.clone();
353 + async move {
354 + let tcp = TcpStream::connect(&target).await?;
355 + let mut builder =
356 + SslConnector::builder(SslMethod::tls_client()).map_err(io::Error::other)?;
357 + builder.set_ca_file(&ca).map_err(io::Error::other)?;
358 + if let Some((cert, key)) = &identity {
359 + builder.set_certificate_chain_file(cert).map_err(io::Error::other)?;
360 + builder.set_private_key_file(key, SslFiletype::PEM).map_err(io::Error::other)?;
361 + }
362 + let mut config = builder.build().configure().map_err(io::Error::other)?;
363 + config.set_verify_hostname(false);
364 + config.set_alpn_protos(b"\x02h2").map_err(io::Error::other)?;
365 + let ssl = config.into_ssl("127.0.0.1").map_err(io::Error::other)?;
366 + let mut stream = SslStream::new(ssl, tcp).map_err(io::Error::other)?;
367 + Pin::new(&mut stream).connect().await.map_err(io::Error::other)?;
368 + Ok::<_, io::Error>(TokioIo::new(stream))
369 + }
370 + });
371 +
372 + let channel = Endpoint::from_shared(format!("https://{addr}"))
373 + .expect("grpc endpoint")
374 + .connect_with_connector(connector)
375 + .await?;
376 + Ok(GenerateClient::new(channel))
377 +}
378 +
379 +/// Complete a raw TLS handshake against the gRPC port (offering ALPN `h2`) for
380 +/// the ALPN-negotiation assertion.
381 +async fn grpc_tls_handshake(
382 + certs: &TestCerts,
383 + addr: &str,
384 +) -> io::Result<Pin<Box<SslStream<TcpStream>>>> {
385 + let tcp = TcpStream::connect(addr).await?;
386 + let mut builder = SslConnector::builder(SslMethod::tls_client()).map_err(io::Error::other)?;
387 + builder.set_ca_file(certs.path("ca.pem")).map_err(io::Error::other)?;
388 + let mut config = builder.build().configure().map_err(io::Error::other)?;
389 + config.set_verify_hostname(false);
390 + config.set_alpn_protos(b"\x02h2").map_err(io::Error::other)?;
391 + let ssl = config.into_ssl("127.0.0.1").map_err(io::Error::other)?;
392 + let mut stream = Box::pin(SslStream::new(ssl, tcp).map_err(io::Error::other)?);
393 + stream.as_mut().connect().await.map_err(io::Error::other)?;
394 + Ok(stream)
395 +}
396 +
397 +/// Spin up a plaintext gRPC server, optionally with HTTP/2 keepalive set to
398 +/// `keepalive` for both the PING interval and the unanswered-PING timeout.
399 +async fn grpc_server_with_keepalive(
400 + engine_id: impl Into<EngineId>,
401 + keepalive: Option<Duration>,
402 +) -> (String, tokio::task::JoinHandle<()>, MockEngineTask) {
403 + let (svc, engine_task) = setup_grpc_service(engine_id, default_stream_output_specs()).await;
404 +
405 + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.expect("bind grpc listener");
406 + let addr = listener.local_addr().expect("local addr").to_string();
407 +
408 + let mut builder = TonicServer::builder();
409 + if let Some(interval) = keepalive {
410 + builder = builder
411 + .http2_keepalive_interval(Some(interval))
412 + .http2_keepalive_timeout(Some(interval));
413 + }
414 +
415 + let server_task = tokio::spawn(async move {
416 + let incoming = incoming(Listener::Tcp(listener));
417 + builder
418 + .add_service(svc)
419 + .serve_with_incoming(incoming)
420 + .await
421 + .expect("grpc server");
422 + });
423 +
424 + (addr, server_task, engine_task)
425 +}
426 +
427 +/// Establish an HTTP/2 connection (preface + SETTINGS exchange) then go silent,
428 +/// ACKing the server's SETTINGS but never its keepalive PINGs. Returns whether
429 +/// the SERVER closes the connection within `wait`. A minimal hand-rolled h2 peer
430 +/// because a real client auto-ACKs PINGs and so can never be kept-alive-evicted.
431 +async fn h2_unresponsive_peer_closed_within(addr: &str, wait: Duration) -> bool {
432 + let mut tcp = TcpStream::connect(addr).await.expect("connect");
433 + tcp.write_all(b"PRI * HTTP/2.0\r\n\r\nSM\r\n\r\n").await.expect("preface");
434 + tcp.write_all(&[0, 0, 0, 0x4, 0, 0, 0, 0, 0]).await.expect("client settings");
435 +
436 + let closed = tokio::time::timeout(wait, async {
437 + let mut header = [0u8; 9];
438 + while tcp.read_exact(&mut header).await.is_ok() {
439 + let len = u32::from_be_bytes([0, header[0], header[1], header[2]]) as usize;
440 + let frame_type = header[3];
441 + let flags = header[4];
442 + let mut payload = vec![0u8; len];
443 + if tcp.read_exact(&mut payload).await.is_err() {
444 + return;
445 + }
446 + // ACK the server's SETTINGS so the only thing left unanswered is PINGs.
447 + if frame_type == 0x4 && flags & 0x1 == 0 {
448 + let _ = tcp.write_all(&[0, 0, 0, 0x4, 0x1, 0, 0, 0, 0]).await;
449 + }
450 + }
451 + })
452 + .await;
453 +
454 + closed.is_ok()
455 +}
456 +
457 // ========================================================================================
458 // Tests
459 // ========================================================================================
@@ -720,3 +892,173 @@ async fn unary_generate_output_text_defaults_to_true() {
892 engine_task.await.expect("mock engine task");
893 server_task.abort();
894 }
895 +
896 +#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
897 +#[serial]
898 +async fn grpc_generate_succeeds_over_tls() {
899 + let certs = TestCerts::generate();
900 + let (addr, server_task, engine_task) = grpc_tls_test_server(
901 + b"engine-grpc-tls-unary",
902 + default_stream_output_specs(),
903 + &certs,
904 + 0,
905 + )
906 + .await;
907 +
908 + let mut client = grpc_tls_client(&certs, &addr, None).await.expect("tls client");
909 + let response = client
910 + .generate(pb::GenerateRequest {
911 + request_id: "test-tls-unary".to_string(),
912 + model: "test-model".to_string(),
913 + prompt: Some(pb::generate_request::Prompt::Text("hello".to_string())),
914 + stopping: Some(pb::StoppingCriteria {
915 + max_new_tokens: 10,
916 + ..Default::default()
917 + }),
918 + response: Some(pb::ResponseOptions {
919 + output_text: Some(true),
920 + ..Default::default()
921 + }),
922 + ..Default::default()
923 + })
924 + .await
925 + .expect("unary generate over tls")
926 + .into_inner();
927 +
928 + assert_eq!(response.outputs.expect("outputs present").text, "hi");
929 +
930 + engine_task.await.expect("mock engine task");
931 + server_task.abort();
932 +}
933 +
934 +#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
935 +#[serial]
936 +async fn grpc_tls_negotiates_h2_alpn() {
937 + let certs = TestCerts::generate();
938 + let (addr, server_task, _engine_task) = grpc_tls_test_server(
939 + b"engine-grpc-tls-alpn",
940 + default_stream_output_specs(),
941 + &certs,
942 + 0,
943 + )
944 + .await;
945 +
946 + let stream = grpc_tls_handshake(&certs, &addr).await.expect("handshake");
947 + assert_eq!(
948 + stream.ssl().selected_alpn_protocol(),
949 + Some(&b"h2"[..]),
950 + "server must negotiate h2 ALPN"
951 + );
952 +
953 + server_task.abort();
954 +}
955 +
956 +#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
957 +#[serial]
958 +async fn grpc_mtls_required_rejects_client_without_certificate() {
959 + let certs = TestCerts::generate();
960 + let (addr, server_task, _engine_task) = grpc_tls_test_server(
961 + b"engine-grpc-tls-mtls-reject",
962 + default_stream_output_specs(),
963 + &certs,
964 + 2,
965 + )
966 + .await;
967 +
968 + // With TLS 1.3 the missing-client-cert rejection surfaces on first use, not
969 + // at the handshake, so drive an RPC and assert the call fails.
970 + let outcome = match grpc_tls_client(&certs, &addr, None).await {
971 + Err(_) => Err(()),
972 + Ok(mut client) => client
973 + .generate(pb::GenerateRequest {
974 + request_id: "test-tls-mtls-reject".to_string(),
975 + model: "test-model".to_string(),
976 + prompt: Some(pb::generate_request::Prompt::Text("hello".to_string())),
977 + stopping: Some(pb::StoppingCriteria {
978 + max_new_tokens: 10,
979 + ..Default::default()
980 + }),
981 + ..Default::default()
982 + })
983 + .await
984 + .map(|_| ())
985 + .map_err(|_| ()),
986 + };
987 + assert!(
988 + outcome.is_err(),
989 + "mTLS-required gRPC must reject a client without a certificate"
990 + );
991 +
992 + server_task.abort();
993 +}
994 +
995 +#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
996 +#[serial]
997 +async fn grpc_mtls_required_accepts_valid_client_certificate() {
998 + let certs = TestCerts::generate();
999 + let (addr, server_task, engine_task) = grpc_tls_test_server(
1000 + b"engine-grpc-tls-mtls-accept",
1001 + default_stream_output_specs(),
1002 + &certs,
1003 + 2,
1004 + )
1005 + .await;
1006 +
1007 + let mut client = grpc_tls_client(&certs, &addr, Some("client")).await.expect("mtls client");
1008 + let response = client
1009 + .generate(pb::GenerateRequest {
1010 + request_id: "test-tls-mtls".to_string(),
1011 + model: "test-model".to_string(),
1012 + prompt: Some(pb::generate_request::Prompt::Text("hello".to_string())),
1013 + stopping: Some(pb::StoppingCriteria {
1014 + max_new_tokens: 10,
1015 + ..Default::default()
1016 + }),
1017 + response: Some(pb::ResponseOptions {
1018 + output_text: Some(true),
1019 + ..Default::default()
1020 + }),
1021 + ..Default::default()
1022 + })
1023 + .await
1024 + .expect("mtls generate over tls")
1025 + .into_inner();
1026 +
1027 + assert_eq!(response.outputs.expect("outputs present").text, "hi");
1028 +
1029 + engine_task.await.expect("mock engine task");
1030 + server_task.abort();
1031 +}
1032 +
1033 +#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
1034 +#[serial]
1035 +async fn grpc_keepalive_closes_unresponsive_connection() {
1036 + let (addr, server_task, _engine_task) =
1037 + grpc_server_with_keepalive(b"engine-grpc-keepalive", Some(Duration::from_millis(150)))
1038 + .await;
1039 +
1040 + let closed = h2_unresponsive_peer_closed_within(&addr, Duration::from_secs(5)).await;
1041 + assert!(
1042 + closed,
1043 + "keepalive must close a peer that stops answering PINGs"
1044 + );
1045 +
1046 + server_task.abort();
1047 +}
1048 +
1049 +#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
1050 +#[serial]
1051 +async fn grpc_without_keepalive_keeps_unresponsive_connection_open() {
1052 + // Without keepalive the same unresponsive peer is NOT
1053 + // closed, proving the close above is attributable to keepalive.
1054 + let (addr, server_task, _engine_task) =
1055 + grpc_server_with_keepalive(b"engine-grpc-no-keepalive", None).await;
1056 +
1057 + let closed = h2_unresponsive_peer_closed_within(&addr, Duration::from_secs(1)).await;
1058 + assert!(
1059 + !closed,
1060 + "without keepalive an idle h2 connection must stay open"
1061 + );
1062 +
1063 + server_task.abort();
1064 +}
rust/src/server/src/lib.rs
+205 -35
@@ -10,20 +10,34 @@ mod routes;
10 mod runtime;
11 mod server_info;
12 mod state;
13 +mod tls;
14 +#[cfg(test)]
15 +mod tls_tests;
16 mod utils;
17
18 +use std::future::Future;
19 use std::sync::{Arc, OnceLock};
20 +use std::time::Duration;
21
22 use anyhow::{Context as _, Result};
23 use axum::Router;
19 -use axum::serve::ListenerExt as _;
20 -pub use config::{ApiServerOptions, Config, CoordinatorMode, CorsConfig, HttpListenerMode};
24 +use axum::body::Body;
25 +use axum::http::Request;
26 +pub use config::{
27 + ApiServerOptions, Config, CoordinatorMode, CorsConfig, DEFAULT_KEEP_ALIVE_TIMEOUT,
28 + HttpListenerMode, TlsConfig,
29 +};
30 +use futures::FutureExt as _;
31 +use hyper::body::Incoming;
32 +use hyper::server::conn::http1;
33 +use hyper_util::rt::{TokioIo, TokioTimer};
34 +use hyper_util::server::graceful::GracefulShutdown;
35 +use hyper_util::service::TowerToHyperService;
36 use tokio::net::TcpListener;
37 use tokio::time::{Instant, sleep_until};
23 -use tokio_stream::wrappers::TcpListenerStream;
24 -use tokio_util::either::Either;
38 use tokio_util::sync::CancellationToken;
39 use tonic::transport::Server as TonicServer;
40 +use tower::ServiceExt as _;
41 use tracing::{info, trace, warn};
42 use vllm_chat::{ChatLlm, LoadModelBackendsOptions, load_model_backends};
43 pub use vllm_chat::{ChatTemplateContentFormatOption, ParserSelection, RendererSelection};
@@ -36,6 +50,13 @@ use crate::routes::build_router;
50 use crate::server_info::ServerInfoSnapshot;
51 use crate::state::AppState;
52
53 +/// How often the server PINGs an idle gRPC connection to reap a dead peer;
54 +/// tonic enables no keepalive by default. 2h matches the gRPC-core default.
55 +const GRPC_KEEPALIVE_INTERVAL: Duration = Duration::from_secs(7200);
56 +/// How long the server waits for a keepalive PING reply before dropping the gRPC
57 +/// connection. 20s matches the gRPC-core default.
58 +const GRPC_KEEPALIVE_TIMEOUT: Duration = Duration::from_secs(20);
59 +
60 /// Resolve the public model names accepted by the frontend.
61 fn effective_served_model_names(model: &str, served_model_name: &[String]) -> Vec<String> {
62 if served_model_name.is_empty() {
@@ -45,6 +66,17 @@ fn effective_served_model_names(model: &str, served_model_name: &[String]) -> Ve
66 }
67 }
68
69 +/// Choose the gRPC listener host. It follows the HTTP TCP host when there is
70 +/// one; otherwise (unix socket or inherited fd) it defaults to IPv4 loopback
71 +/// rather than all interfaces, so the side-car is never accidentally
72 +/// network-exposed.
73 +fn grpc_bind_host(listener_mode: &HttpListenerMode) -> &str {
74 + match listener_mode {
75 + HttpListenerMode::BindTcp { host, .. } => host.as_str(),
76 + HttpListenerMode::BindUnix { .. } | HttpListenerMode::InheritedFd { .. } => "127.0.0.1",
77 + }
78 +}
79 +
80 /// Build the shared application state for one configured model and one engine
81 /// client.
82 async fn build_state(config: &Config) -> Result<Arc<AppState>> {
@@ -130,6 +162,15 @@ where
162 {
163 config.validate().context("invalid OpenAI frontend configuration")?;
164
165 + // Build the TLS server config once, up front, so a bad cert/key fails fast
166 + // before the (potentially long) engine handshake.
167 + let tls_config = config
168 + .tls
169 + .as_ref()
170 + .map(tls::build_server_config)
171 + .transpose()
172 + .context("invalid TLS configuration")?;
173 +
174 // Also check shutdown during the (potentially long) startup handshake.
175 let state = tokio::select! {
176 result = build_state(&config) => result?,
@@ -144,40 +185,39 @@ where
185
186 // Optionally bind the gRPC Generate server on a separate port. Bind
187 // synchronously here so bind errors (port in use, permission denied, ...)
147 - // surface before we start serving, rather than being deferred until
148 - // shutdown. The gRPC listener follows the same host as the HTTP listener so
149 - // that enabling --grpc-port does not accidentally expose the service on all
150 - // interfaces when HTTP is intentionally local-only.
188 + // surface before serving rather than being deferred until shutdown.
189 let grpc_setup = if let Some(grpc_port) = config.grpc_port {
152 - let grpc_host = match &config.listener_mode {
153 - HttpListenerMode::BindTcp { host, .. } => host.as_str(),
154 - HttpListenerMode::BindUnix { .. } | HttpListenerMode::InheritedFd { .. } => "0.0.0.0",
155 - };
190 + let grpc_host = grpc_bind_host(&config.listener_mode);
191 let grpc_listener = TcpListener::bind((grpc_host, grpc_port))
192 .await
193 .with_context(|| format!("failed to bind gRPC listener on {grpc_host}:{grpc_port}"))?;
194 let addr = grpc_listener.local_addr()?;
195 + let grpc_listener = Listener::Tcp(grpc_listener);
196 + // gRPC reuses the HTTP TLS config (same SslContext) plus ALPN h2.
197 + let grpc_tls = config
198 + .tls
199 + .as_ref()
200 + .map(tls::build_grpc_server_config)
201 + .transpose()
202 + .context("invalid gRPC TLS configuration")?;
203 let svc = grpc::GenerateServer::new(grpc::GenerateServiceImpl::new(state.clone()));
204 let svc = TonicServer::builder()
205 + .http2_keepalive_interval(Some(GRPC_KEEPALIVE_INTERVAL))
206 + .http2_keepalive_timeout(Some(GRPC_KEEPALIVE_TIMEOUT))
207 .layer(middleware::request_runtime_layer(state.clone()))
208 .add_service(svc);
164 - info!(%addr, "starting gRPC server");
165 - Some((grpc_listener, svc))
209 + info!(%addr, tls = grpc_tls.is_some(), "starting gRPC server");
210 + Some((grpc_listener, svc, grpc_tls))
211 } else {
212 None
213 };
214
170 - info!(%bind_address, %model, "starting OpenAI server");
171 -
172 - // Set TCP_NODELAY on accepted connections to reduce latency.
173 - // By `tap_io` we will do this on every accepted connection.
174 - let listener = listener.tap_io(|io| {
175 - if let Either::Left(tcp_stream) = io
176 - && let Err(err) = tcp_stream.set_nodelay(true)
177 - {
178 - trace!(error = %err, "failed to enable TCP_NODELAY on accepted HTTP connection");
179 - }
180 - });
215 + let scheme = if tls_config.is_some() {
216 + "https"
217 + } else {
218 + "http"
219 + };
220 + info!(%bind_address, %scheme, %model, "starting OpenAI server");
221
222 // Run HTTP and gRPC concurrently under a child token of the caller's shutdown
223 // token. Caller cancellation propagates into both protocols; if either
@@ -208,17 +248,27 @@ where
248 }
249 });
250
251 + // 0 disables keep-alive but still bounds the head read (default), so a
252 + // silent client cannot hold the connection open.
253 + let keep_alive_timeout = config.keep_alive_timeout;
254 + let timeouts = ConnectionTimeouts {
255 + handshake: tls::TLS_HANDSHAKE_TIMEOUT,
256 + header_read: if keep_alive_timeout.is_zero() {
257 + DEFAULT_KEEP_ALIVE_TIMEOUT
258 + } else {
259 + keep_alive_timeout
260 + },
261 + keep_alive_enabled: !keep_alive_timeout.is_zero(),
262 + };
263 +
264 let http_fut = {
265 let shutdown = server_shutdown.child_token();
266 let server_shutdown = server_shutdown.clone();
267 let force_shutdown = force_shutdown.clone();
268 async move {
216 - let server =
217 - axum::serve(listener, app).with_graceful_shutdown(shutdown.cancelled_owned());
218 -
269 let result = tokio::select! {
220 - result = server => {
221 - result.context("HTTP server failed")
270 + result = serve_listener(listener, tls_config, app, shutdown.cancelled_owned(), timeouts) => {
271 + result
272 }
273 _ = force_shutdown.cancelled() => {
274 warn!("HTTP graceful shutdown deadline elapsed; aborting server");
@@ -236,16 +286,24 @@ where
286 let server_shutdown = server_shutdown.clone();
287 let force_shutdown = force_shutdown.clone();
288 async move {
239 - let Some((grpc_listener, svc)) = grpc_setup else {
289 + let Some((grpc_listener, svc, grpc_tls)) = grpc_setup else {
290 // No gRPC configured: just wait for shutdown so we do not race the
291 // join! by resolving early and tripping the cancellation token.
292 shutdown.cancelled().await;
293 return Ok(());
294 };
245 - let server = svc.serve_with_incoming_shutdown(
246 - TcpListenerStream::new(grpc_listener),
247 - shutdown.cancelled_owned(),
248 - );
295 + // Box to unify the TLS and plaintext arms' different stream types.
296 + let server = match grpc_tls {
297 + Some(context) => {
298 + let incoming =
299 + grpc::tls_incoming(grpc_listener, context, tls::TLS_HANDSHAKE_TIMEOUT);
300 + svc.serve_with_incoming_shutdown(incoming, shutdown.cancelled_owned()).boxed()
301 + }
302 + None => {
303 + let incoming = grpc::incoming(grpc_listener);
304 + svc.serve_with_incoming_shutdown(incoming, shutdown.cancelled_owned()).boxed()
305 + }
306 + };
307
308 let result = tokio::select! {
309 result = server => {
@@ -272,6 +330,99 @@ where
330 state.shutdown(shutdown_deadline).await
331 }
332
333 +/// Per-connection timeouts applied while serving HTTP/HTTPS.
334 +#[derive(Clone, Copy)]
335 +pub(crate) struct ConnectionTimeouts {
336 + /// Max time for a client to complete the TLS handshake (TLS path only).
337 + pub(crate) handshake: Duration,
338 + /// HTTP/1 header-read timeout (bounds idle keep-alive and the head read).
339 + pub(crate) header_read: Duration,
340 + /// Whether HTTP/1 keep-alive is enabled; `false` closes after each response.
341 + pub(crate) keep_alive_enabled: bool,
342 +}
343 +
344 +/// Apply optional TLS termination and per-connection HTTP timeouts, then serve
345 +/// `app`. Shared by [`serve_with_router_extension`] and the TLS tests.
346 +async fn serve_listener(
347 + listener: Listener,
348 + tls: Option<openssl::ssl::SslContext>,
349 + app: Router,
350 + shutdown: impl Future<Output = ()> + Send + 'static,
351 + timeouts: ConnectionTimeouts,
352 +) -> Result<()> {
353 + match tls {
354 + Some(context) => {
355 + // tls-listener terminates TLS (handshake + timeout); serve_connections
356 + // owns the HTTP keep-alive/idle bound that axum::serve cannot express.
357 + // Failed handshakes (incl. timeouts) log at ERROR via tls-listener.
358 + let listener = tls_listener::builder(context)
359 + .handshake_timeout(timeouts.handshake)
360 + .listen(listener);
361 + serve_connections(
362 + listener,
363 + app,
364 + shutdown,
365 + timeouts.header_read,
366 + timeouts.keep_alive_enabled,
367 + )
368 + .await
369 + .context("HTTPS server failed")
370 + }
371 + None => serve_connections(
372 + listener,
373 + app,
374 + shutdown,
375 + timeouts.header_read,
376 + timeouts.keep_alive_enabled,
377 + )
378 + .await
379 + .context("HTTP server failed"),
380 + }
381 +}
382 +
383 +/// Serve `app` per connection (HTTP/1) with a keep-alive idle timeout and
384 +/// graceful drain. Hand-rolled on hyper because [`axum::serve()`] takes no config.
385 +async fn serve_connections<L>(
386 + mut listener: L,
387 + app: Router,
388 + shutdown: impl Future<Output = ()> + Send,
389 + header_read: Duration,
390 + keep_alive_enabled: bool,
391 +) -> Result<()>
392 +where
393 + L: axum::serve::Listener,
394 +{
395 + let graceful = GracefulShutdown::new();
396 + let mut shutdown = std::pin::pin!(shutdown);
397 + loop {
398 + let (io, _addr) = tokio::select! {
399 + conn = listener.accept() => conn,
400 + () = &mut shutdown => break,
401 + };
402 +
403 + let service = TowerToHyperService::new(
404 + app.clone().map_request(|req: Request<Incoming>| req.map(Body::new)),
405 + );
406 + let mut builder = http1::Builder::new();
407 + builder.timer(TokioTimer::new()).header_read_timeout(header_read);
408 + if !keep_alive_enabled {
409 + builder.keep_alive(false);
410 + }
411 + let connection = builder.serve_connection(TokioIo::new(io), service);
412 + let connection = graceful.watch(connection);
413 +
414 + tokio::spawn(async move {
415 + if let Err(err) = connection.await {
416 + trace!(error = %err, "failed to serve connection");
417 + }
418 + });
419 + }
420 +
421 + drop(listener);
422 + graceful.shutdown().await;
423 + Ok(())
424 +}
425 +
426 #[cfg(test)]
427 mod tests {
428 use super::*;
@@ -293,4 +444,23 @@ mod tests {
444 served_names
445 );
446 }
447 +
448 + #[test]
449 + fn grpc_bind_host_follows_http_tcp_host() {
450 + let mode = HttpListenerMode::BindTcp {
451 + host: "0.0.0.0".to_string(),
452 + port: 8000,
453 + };
454 + assert_eq!(grpc_bind_host(&mode), "0.0.0.0");
455 + }
456 +
457 + #[test]
458 + fn grpc_bind_host_defaults_to_loopback_without_tcp_host() {
459 + let unix = HttpListenerMode::BindUnix {
460 + path: "/tmp/vllm.sock".to_string(),
461 + };
462 + let inherited = HttpListenerMode::InheritedFd { fd: 3 };
463 + assert_eq!(grpc_bind_host(&unix), "127.0.0.1");
464 + assert_eq!(grpc_bind_host(&inherited), "127.0.0.1");
465 + }
466 }
rust/src/server/src/listener.rs
+96 -14
@@ -1,28 +1,49 @@
1 -//! Unified HTTP listener wrapper for the Rust frontend.
1 +//! Unified listener wrapper for the Rust frontend.
2 //!
3 //! This module hides the difference between TCP and Unix-domain listeners so
4 //! the rest of the server can bind or inherit one socket and pass it to
5 //! `axum::serve(...)` through a single type.
6
7 use std::io::Result;
8 -use std::net::TcpListener as StdTcpListener;
8 +use std::net::{SocketAddr, TcpListener as StdTcpListener};
9 use std::os::fd::{FromRawFd, IntoRawFd, OwnedFd};
10 use std::os::unix::net::UnixListener as StdUnixListener;
11 +use std::pin::Pin;
12 +use std::task::{Context, Poll, ready};
13
14 +use auto_enums::enum_derive;
15 use socket2::Socket;
16 +use tls_listener::{AsyncAccept, AsyncListener};
17 use tokio::net::{TcpListener, TcpStream, UnixListener, UnixStream};
14 -use tokio_util::either::Either;
18 +use tonic::transport::server::{Connected, TcpConnectInfo};
19 +use tracing::trace;
20
21 use crate::HttpListenerMode;
22
18 -/// Runtime listener type used by the OpenAI-compatible HTTP server, which is
19 -/// either a TCP listener or a Unix-domain listener.
23 +/// Runtime listener type used by the OpenAI-compatible HTTP or gRPC server,
24 +/// which is either a TCP listener or a Unix-domain listener.
25 #[derive(Debug)]
26 pub enum Listener {
27 Tcp(TcpListener),
28 Unix(UnixListener),
29 }
30
31 +/// Runtime listener I/O type which is either a TCP stream or a Unix-domain stream.
32 +#[derive(Debug)]
33 +#[enum_derive(tokio1::AsyncRead, tokio1::AsyncWrite)]
34 +pub enum ListenerIo {
35 + Tcp(TcpStream),
36 + Unix(UnixStream),
37 +}
38 +
39 +/// Runtime listener address type which is either a TCP address or a Unix-domain address.
40 +#[derive(Debug)]
41 +#[allow(dead_code)]
42 +pub enum ListenerAddr {
43 + Tcp(SocketAddr),
44 + Unix(tokio::net::unix::SocketAddr),
45 +}
46 +
47 impl Listener {
48 /// Bind or adopt the listener described by the frontend configuration.
49 ///
@@ -70,34 +91,95 @@ impl Listener {
91 Ok(Self::Tcp(TcpListener::from_std(std_listener)?))
92 }
93 }
94 +
95 + fn listener_addr(&self) -> Result<ListenerAddr> {
96 + match self {
97 + Self::Tcp(listener) => listener.local_addr().map(ListenerAddr::Tcp),
98 + Self::Unix(listener) => listener.local_addr().map(ListenerAddr::Unix),
99 + }
100 + }
101 +}
102 +
103 +impl Connected for ListenerIo {
104 + type ConnectInfo = TcpConnectInfo;
105 +
106 + fn connect_info(&self) -> TcpConnectInfo {
107 + match self {
108 + Self::Tcp(stream) => stream.connect_info(),
109 + Self::Unix(_) => TcpConnectInfo {
110 + local_addr: None,
111 + remote_addr: None,
112 + },
113 + }
114 + }
115 +}
116 +
117 +/// Attempt to set `TCP_NODELAY` on the accepted TCP stream.
118 +fn enable_tcp_nodelay(stream: TcpStream) -> TcpStream {
119 + if let Err(err) = stream.set_nodelay(true) {
120 + trace!(error = %err, "failed to enable TCP_NODELAY on accepted TCP connection");
121 + }
122 + stream
123 }
124
125 /// Allow the unified listener to plug directly into `axum::serve(...)`.
126 impl axum::serve::Listener for Listener {
77 - type Addr = Either<std::net::SocketAddr, tokio::net::unix::SocketAddr>;
78 - type Io = Either<TcpStream, UnixStream>;
127 + type Addr = ListenerAddr;
128 + type Io = ListenerIo;
129
130 async fn accept(&mut self) -> (Self::Io, Self::Addr) {
131 match self {
132 Self::Tcp(listener) => {
83 - let (io, addr) = listener.accept().await;
84 - (Either::Left(io), Either::Left(addr))
133 + let (io, addr) = axum::serve::Listener::accept(listener).await;
134 + (
135 + ListenerIo::Tcp(enable_tcp_nodelay(io)),
136 + ListenerAddr::Tcp(addr),
137 + )
138 }
139 Self::Unix(listener) => {
87 - let (io, addr) = listener.accept().await;
88 - (Either::Right(io), Either::Right(addr))
140 + let (io, addr) = axum::serve::Listener::accept(listener).await;
141 + (ListenerIo::Unix(io), ListenerAddr::Unix(addr))
142 }
143 }
144 }
145
146 fn local_addr(&self) -> Result<Self::Addr> {
94 - match self {
95 - Self::Tcp(listener) => listener.local_addr().map(Either::Left),
96 - Self::Unix(listener) => listener.local_addr().map(Either::Right),
147 + self.listener_addr()
148 + }
149 +}
150 +
151 +/// Allow the unified listener to be adaptable to `tls_listener`.
152 +impl AsyncAccept for Listener {
153 + type Connection = ListenerIo;
154 + type Address = ListenerAddr;
155 + type Error = std::io::Error;
156 +
157 + fn poll_accept(
158 + self: Pin<&mut Self>,
159 + cx: &mut Context<'_>,
160 + ) -> Poll<Result<(Self::Connection, Self::Address)>> {
161 + match self.get_mut() {
162 + Self::Tcp(listener) => {
163 + let (io, addr) = ready!(listener.poll_accept(cx))?;
164 + Poll::Ready(Ok((
165 + ListenerIo::Tcp(enable_tcp_nodelay(io)),
166 + ListenerAddr::Tcp(addr),
167 + )))
168 + }
169 + Self::Unix(listener) => {
170 + let (io, addr) = ready!(listener.poll_accept(cx))?;
171 + Poll::Ready(Ok((ListenerIo::Unix(io), ListenerAddr::Unix(addr))))
172 + }
173 }
174 }
175 }
176
177 +impl AsyncListener for Listener {
178 + fn local_addr(&self) -> Result<Self::Address> {
179 + self.listener_addr()
180 + }
181 +}
182 +
183 #[cfg(test)]
184 mod tests {
185 use std::net::{Ipv4Addr, SocketAddrV4};
rust/src/server/src/tls.rs new
+119
@@ -0,0 +1,119 @@
1 +//! OpenSSL server-config construction for TLS termination.
2 +//!
3 +//! Builds an OpenSSL [`SslContext`] from the uvicorn-style `ssl_*` arguments
4 +//! (certificate chain, private key, mTLS client verifier, optional cipher list).
5 +//! The `tls-listener` crate drives the handshake on each accepted connection.
6 +//!
7 +//! Crypto runs through whichever OpenSSL the binary links (system by default,
8 +//! vendored when built with that feature).
9 +
10 +use std::path::Path;
11 +use std::time::Duration;
12 +
13 +use anyhow::{Context as _, Result};
14 +use openssl::ssl::{
15 + AlpnError, SslAcceptor, SslAcceptorBuilder, SslContext, SslContextBuilder, SslFiletype,
16 + SslMethod, SslOptions, SslVerifyMode, select_next_proto,
17 +};
18 +
19 +use crate::config::TlsConfig;
20 +
21 +/// Time a client has to complete the TLS handshake before the connection is dropped.
22 +pub(crate) const TLS_HANDSHAKE_TIMEOUT: Duration = Duration::from_secs(60);
23 +
24 +/// ALPN wire bytes for HTTP/2 (length-prefixed).
25 +const ALPN_H2: &[u8] = b"\x02h2";
26 +
27 +/// Build the shared OpenSSL acceptor from validated [`TlsConfig`]: the full
28 +/// certificate chain, the private key (`key_file`, or the certificate file when
29 +/// unset), the mTLS client verifier, and an optional cipher list.
30 +///
31 +/// Starts from the Mozilla intermediate baseline (forward-secret AEAD suites,
32 +/// TLS 1.2 floor, server cipher preference, no compression), a slightly
33 +/// stricter subset of the Python frontend's default suites; `--ssl-ciphers`
34 +/// overrides it.
35 +fn build_server_builder(tls: &TlsConfig) -> Result<SslAcceptorBuilder> {
36 + let cert_file = tls.cert_file.as_deref().context("--ssl-certfile is required to enable TLS")?;
37 +
38 + let mut builder = SslAcceptor::mozilla_intermediate_v5(SslMethod::tls_server())
39 + .context("failed to initialize TLS")?;
40 + builder.set_options(SslOptions::CIPHER_SERVER_PREFERENCE);
41 +
42 + // Load the whole chain (leaf + intermediates), not just the leaf, so
43 + // deployments behind an intermediate CA serve a complete chain.
44 + ensure_exists(cert_file, "--ssl-certfile")?;
45 + builder.set_certificate_chain_file(cert_file).with_context(|| {
46 + format!("failed to parse certificate chain in --ssl-certfile {cert_file:?}")
47 + })?;
48 +
49 + // When `key_file` is unset the key is read from the certificate file
50 + // (combined PEM).
51 + let key_file = tls.key_file.as_deref().unwrap_or(cert_file);
52 + ensure_exists(key_file, "private key file")?;
53 + builder
54 + .set_private_key_file(key_file, SslFiletype::PEM)
55 + .with_context(|| format!("failed to parse private key in {key_file:?}"))?;
56 + builder
57 + .check_private_key()
58 + .context("the certificate and private key do not match")?;
59 +
60 + configure_client_auth(&mut builder, tls)?;
61 +
62 + if let Some(ciphers) = tls.ciphers.as_deref().filter(|c| !c.is_empty()) {
63 + builder
64 + .set_cipher_list(ciphers)
65 + .with_context(|| format!("invalid --ssl-ciphers {ciphers:?}"))?;
66 + }
67 +
68 + Ok(builder)
69 +}
70 +
71 +/// Build the HTTP [`SslContext`] (HTTP/1.1; no ALPN, matching uvicorn).
72 +pub(crate) fn build_server_config(tls: &TlsConfig) -> Result<SslContext> {
73 + Ok(build_server_builder(tls)?.build().into_context())
74 +}
75 +
76 +/// Build the gRPC [`SslContext`]: identical to [`build_server_config`] but
77 +/// negotiates ALPN `h2`, which HTTP/2 over TLS requires.
78 +pub(crate) fn build_grpc_server_config(tls: &TlsConfig) -> Result<SslContext> {
79 + let mut builder = build_server_builder(tls)?;
80 + builder.set_alpn_select_callback(|_ssl, client| {
81 + select_next_proto(ALPN_H2, client).ok_or(AlpnError::NOACK)
82 + });
83 + Ok(builder.build().into_context())
84 +}
85 +
86 +/// Fail loudly with a flag-named message when a configured file is missing,
87 +/// distinguishing it from a malformed-PEM error raised later by OpenSSL (whose
88 +/// `ErrorStack` does not name the offending file).
89 +fn ensure_exists(path: &str, what: &str) -> Result<()> {
90 + std::fs::metadata(Path::new(path))
91 + .map(drop)
92 + .with_context(|| format!("failed to read {what} {path:?}"))
93 +}
94 +
95 +/// Apply the `cert_reqs` client-certificate policy: 0 = none, 1 = optional
96 +/// (verify if presented, allow anonymous), 2 = required. `PEER` without a custom
97 +/// verify callback still rejects a presented-but-untrusted certificate.
98 +fn configure_client_auth(builder: &mut SslContextBuilder, tls: &TlsConfig) -> Result<()> {
99 + if tls.cert_reqs == 0 {
100 + builder.set_verify(SslVerifyMode::NONE);
101 + return Ok(());
102 + }
103 +
104 + let ca_file = tls
105 + .ca_certs
106 + .as_deref()
107 + .context("--ssl-ca-certs is required for client certificate verification")?;
108 + ensure_exists(ca_file, "--ssl-ca-certs")?;
109 + builder
110 + .set_ca_file(ca_file)
111 + .with_context(|| format!("failed to parse --ssl-ca-certs {ca_file:?}"))?;
112 +
113 + let mut mode = SslVerifyMode::PEER;
114 + if tls.cert_reqs == 2 {
115 + mode |= SslVerifyMode::FAIL_IF_NO_PEER_CERT;
116 + }
117 + builder.set_verify(mode);
118 + Ok(())
119 +}
rust/src/server/src/tls_tests.rs new
+688
@@ -0,0 +1,688 @@
1 +//! TLS tests: `build_server_config` unit checks plus end-to-end OpenSSL handshakes
2 +//! through the production `serve_listener` path, with a trivial router since TLS
3 +//! terminates below the app.
4 +
5 +use std::pin::Pin;
6 +use std::time::Duration;
7 +
8 +use axum::Router;
9 +use axum::routing::get;
10 +use openssl::asn1::Asn1Time;
11 +use openssl::bn::{BigNum, MsbOption};
12 +use openssl::ec::{EcGroup, EcKey};
13 +use openssl::hash::MessageDigest;
14 +use openssl::nid::Nid;
15 +use openssl::pkey::{PKey, Private};
16 +use openssl::ssl::{SslConnector, SslFiletype, SslMethod, SslVersion};
17 +use openssl::x509::extension::{BasicConstraints, KeyUsage, SubjectAlternativeName};
18 +use openssl::x509::{X509, X509NameBuilder};
19 +use tempfile::TempDir;
20 +use tokio::io::{AsyncReadExt as _, AsyncWriteExt as _};
21 +use tokio::net::TcpStream;
22 +use tokio_openssl::SslStream;
23 +use tokio_util::sync::CancellationToken;
24 +
25 +use crate::config::{HttpListenerMode, TlsConfig};
26 +use crate::listener::Listener;
27 +use crate::{ConnectionTimeouts, serve_listener, tls};
28 +
29 +// ============================================================================
30 +// Test infrastructure
31 +// ============================================================================
32 +
33 +/// A throwaway CA + server/client/untrusted/chain cert set as PEM files in a
34 +/// temp dir; dropping it deletes them.
35 +pub(crate) struct TestCerts {
36 + dir: TempDir,
37 +}
38 +
39 +impl TestCerts {
40 + pub(crate) fn generate() -> Self {
41 + let dir = tempfile::tempdir().expect("tempdir");
42 +
43 + let (ca, ca_key) = build_ca();
44 + let (server, server_key) = build_leaf("server", &["127.0.0.1", "localhost"], &ca, &ca_key);
45 + let (client, client_key) = build_leaf("client", &[], &ca, &ca_key);
46 + let (untrusted, untrusted_key) = build_self_signed("untrusted client");
47 +
48 + // Leaf signed by an intermediate (itself signed by the root); the cert
49 + // file holds leaf + intermediate, for the chain-serving test.
50 + let (intermediate, intermediate_key) = build_intermediate(&ca, &ca_key);
51 + let (chain_leaf, chain_leaf_key) = build_leaf(
52 + "chain",
53 + &["127.0.0.1", "localhost"],
54 + &intermediate,
55 + &intermediate_key,
56 + );
57 +
58 + let server_pem = pem(&server);
59 + let server_key_pem = key_pem(&server_key);
60 + let files = [
61 + ("ca.pem", pem(&ca)),
62 + ("server.pem", server_pem.clone()),
63 + ("server.key", server_key_pem.clone()),
64 + ("client.pem", pem(&client)),
65 + ("client.key", key_pem(&client_key)),
66 + ("untrusted_client.pem", pem(&untrusted)),
67 + ("untrusted_client.key", key_pem(&untrusted_key)),
68 + (
69 + "server_combined.pem",
70 + format!("{server_pem}{server_key_pem}"),
71 + ),
72 + (
73 + "server_chain.pem",
74 + format!("{}{}", pem(&chain_leaf), pem(&intermediate)),
75 + ),
76 + ("server_chain.key", key_pem(&chain_leaf_key)),
77 + ];
78 + for (name, contents) in files {
79 + std::fs::write(dir.path().join(name), contents).expect("write fixture");
80 + }
81 + Self { dir }
82 + }
83 +
84 + /// Absolute path to a fixture by name; the file need not exist.
85 + pub(crate) fn path(&self, name: &str) -> String {
86 + self.dir.path().join(name).to_str().expect("utf-8 path").to_string()
87 + }
88 +}
89 +
90 +fn gen_key() -> PKey<Private> {
91 + let group = EcGroup::from_curve_name(Nid::X9_62_PRIME256V1).expect("ec group");
92 + let ec = EcKey::generate(&group).expect("ec key");
93 + PKey::from_ec_key(ec).expect("pkey")
94 +}
95 +
96 +fn serial() -> openssl::asn1::Asn1Integer {
97 + let mut bn = BigNum::new().expect("bignum");
98 + bn.rand(159, MsbOption::MAYBE_ZERO, false).expect("rand serial");
99 + bn.to_asn1_integer().expect("asn1 serial")
100 +}
101 +
102 +fn x509_name(cn: &str) -> openssl::x509::X509Name {
103 + let mut builder = X509NameBuilder::new().expect("name builder");
104 + builder.append_entry_by_text("CN", cn).expect("cn");
105 + builder.build()
106 +}
107 +
108 +fn pem(cert: &X509) -> String {
109 + String::from_utf8(cert.to_pem().expect("cert pem")).expect("utf-8 cert")
110 +}
111 +
112 +fn key_pem(key: &PKey<Private>) -> String {
113 + String::from_utf8(key.private_key_to_pem_pkcs8().expect("key pem")).expect("utf-8 key")
114 +}
115 +
116 +/// A self-signed CA used to sign the server/client leaf certs.
117 +fn build_ca() -> (X509, PKey<Private>) {
118 + let key = gen_key();
119 + let name = x509_name("vLLM Test CA");
120 + let mut builder = X509::builder().expect("x509 builder");
121 + builder.set_version(2).expect("version");
122 + builder.set_serial_number(&serial()).expect("serial");
123 + builder.set_subject_name(&name).expect("subject");
124 + builder.set_issuer_name(&name).expect("issuer");
125 + builder.set_pubkey(&key).expect("pubkey");
126 + builder
127 + .set_not_before(&Asn1Time::days_from_now(0).expect("nb"))
128 + .expect("set nb");
129 + builder
130 + .set_not_after(&Asn1Time::days_from_now(3650).expect("na"))
131 + .expect("set na");
132 + builder
133 + .append_extension(BasicConstraints::new().critical().ca().build().expect("bc"))
134 + .expect("ext bc");
135 + builder
136 + .append_extension(
137 + KeyUsage::new().critical().key_cert_sign().crl_sign().build().expect("ku"),
138 + )
139 + .expect("ext ku");
140 + builder.sign(&key, MessageDigest::sha256()).expect("sign ca");
141 + (builder.build(), key)
142 +}
143 +
144 +/// A CA-signed leaf cert with optional subject-alternative names (IP or DNS).
145 +fn build_leaf(cn: &str, sans: &[&str], ca: &X509, ca_key: &PKey<Private>) -> (X509, PKey<Private>) {
146 + let key = gen_key();
147 + let mut builder = X509::builder().expect("x509 builder");
148 + builder.set_version(2).expect("version");
149 + builder.set_serial_number(&serial()).expect("serial");
150 + builder.set_subject_name(&x509_name(cn)).expect("subject");
151 + builder.set_issuer_name(ca.subject_name()).expect("issuer");
152 + builder.set_pubkey(&key).expect("pubkey");
153 + builder
154 + .set_not_before(&Asn1Time::days_from_now(0).expect("nb"))
155 + .expect("set nb");
156 + builder
157 + .set_not_after(&Asn1Time::days_from_now(3650).expect("na"))
158 + .expect("set na");
159 + builder
160 + .append_extension(BasicConstraints::new().build().expect("bc"))
161 + .expect("ext bc");
162 + if !sans.is_empty() {
163 + let mut san = SubjectAlternativeName::new();
164 + for entry in sans {
165 + if entry.parse::<std::net::IpAddr>().is_ok() {
166 + san.ip(entry);
167 + } else {
168 + san.dns(entry);
169 + }
170 + }
171 + let ext = san.build(&builder.x509v3_context(Some(ca), None)).expect("san");
172 + builder.append_extension(ext).expect("ext san");
173 + }
174 + builder.sign(ca_key, MessageDigest::sha256()).expect("sign leaf");
175 + (builder.build(), key)
176 +}
177 +
178 +/// A self-signed leaf not chained to the CA, for the untrusted-client test.
179 +fn build_self_signed(cn: &str) -> (X509, PKey<Private>) {
180 + let key = gen_key();
181 + let name = x509_name(cn);
182 + let mut builder = X509::builder().expect("x509 builder");
183 + builder.set_version(2).expect("version");
184 + builder.set_serial_number(&serial()).expect("serial");
185 + builder.set_subject_name(&name).expect("subject");
186 + builder.set_issuer_name(&name).expect("issuer");
187 + builder.set_pubkey(&key).expect("pubkey");
188 + builder
189 + .set_not_before(&Asn1Time::days_from_now(0).expect("nb"))
190 + .expect("set nb");
191 + builder
192 + .set_not_after(&Asn1Time::days_from_now(3650).expect("na"))
193 + .expect("set na");
194 + builder
195 + .append_extension(BasicConstraints::new().build().expect("bc"))
196 + .expect("ext bc");
197 + builder.sign(&key, MessageDigest::sha256()).expect("sign self");
198 + (builder.build(), key)
199 +}
200 +
201 +/// A CA-capable intermediate signed by the root, for the full-chain test.
202 +fn build_intermediate(ca: &X509, ca_key: &PKey<Private>) -> (X509, PKey<Private>) {
203 + let key = gen_key();
204 + let mut builder = X509::builder().expect("x509 builder");
205 + builder.set_version(2).expect("version");
206 + builder.set_serial_number(&serial()).expect("serial");
207 + builder
208 + .set_subject_name(&x509_name("vLLM Test Intermediate CA"))
209 + .expect("subject");
210 + builder.set_issuer_name(ca.subject_name()).expect("issuer");
211 + builder.set_pubkey(&key).expect("pubkey");
212 + builder
213 + .set_not_before(&Asn1Time::days_from_now(0).expect("nb"))
214 + .expect("set nb");
215 + builder
216 + .set_not_after(&Asn1Time::days_from_now(3650).expect("na"))
217 + .expect("set na");
218 + builder
219 + .append_extension(BasicConstraints::new().critical().ca().build().expect("bc"))
220 + .expect("ext bc");
221 + builder
222 + .append_extension(
223 + KeyUsage::new().critical().key_cert_sign().crl_sign().build().expect("ku"),
224 + )
225 + .expect("ext ku");
226 + builder.sign(ca_key, MessageDigest::sha256()).expect("sign intermediate");
227 + (builder.build(), key)
228 +}
229 +
230 +pub(crate) fn server_tls(certs: &TestCerts, cert_reqs: i32) -> TlsConfig {
231 + TlsConfig {
232 + cert_file: Some(certs.path("server.pem")),
233 + key_file: Some(certs.path("server.key")),
234 + ca_certs: (cert_reqs != 0).then(|| certs.path("ca.pem")),
235 + cert_reqs,
236 + ciphers: None,
237 + }
238 +}
239 +
240 +/// A plaintext-listener TLS config for `build_server_config` checks (`cert_reqs`
241 +/// 0, no client auth), with the cert/key files chosen by the caller.
242 +fn build_tls(certs: &TestCerts, cert: &str, key: Option<&str>) -> TlsConfig {
243 + TlsConfig {
244 + cert_file: Some(certs.path(cert)),
245 + key_file: key.map(|k| certs.path(k)),
246 + ca_certs: None,
247 + cert_reqs: 0,
248 + ciphers: None,
249 + }
250 +}
251 +
252 +/// Generous per-connection timeouts that never fire during the fast tests.
253 +const TEST_TIMEOUTS: ConnectionTimeouts = ConnectionTimeouts {
254 + handshake: Duration::from_secs(60),
255 + header_read: Duration::from_secs(5),
256 + keep_alive_enabled: true,
257 +};
258 +
259 +async fn spawn_server(tls_config: Option<TlsConfig>) -> (String, CancellationToken) {
260 + spawn_server_with_timeouts(tls_config, TEST_TIMEOUTS).await
261 +}
262 +
263 +/// Bind an ephemeral listener and serve a trivial router via the production
264 +/// `serve_listener`, optionally with TLS. The listener is bound (and thus
265 +/// accepting into the backlog) before returning, so a client may connect
266 +/// immediately without a sleep.
267 +async fn spawn_server_with_timeouts(
268 + tls_config: Option<TlsConfig>,
269 + timeouts: ConnectionTimeouts,
270 +) -> (String, CancellationToken) {
271 + let listener = Listener::bind(&HttpListenerMode::BindTcp {
272 + host: "127.0.0.1".to_string(),
273 + port: 0,
274 + })
275 + .await
276 + .expect("bind listener");
277 + let addr = listener.local_addr().expect("local addr");
278 +
279 + let server_config =
280 + tls_config.map(|cfg| tls::build_server_config(&cfg).expect("build server config"));
281 + let app = Router::new().route("/health", get(|| async { "ok" }));
282 + let shutdown = CancellationToken::new();
283 + let server_shutdown = shutdown.clone();
284 + tokio::spawn(async move {
285 + let _ = serve_listener(
286 + listener,
287 + server_config,
288 + app,
289 + server_shutdown.cancelled_owned(),
290 + timeouts,
291 + )
292 + .await;
293 + });
294 + (addr, shutdown)
295 +}
296 +
297 +/// Open a TLS connection trusting the test CA and finish the handshake,
298 +/// optionally presenting a client identity (`<name>.pem` + `<name>.key`) for
299 +/// mTLS. Hostname verification is disabled (the IP-SAN match is not under test);
300 +/// chain verification stays on, so an untrusted server cert is still rejected.
301 +async fn connect_tls(
302 + certs: &TestCerts,
303 + addr: &str,
304 + identity: Option<&str>,
305 +) -> std::io::Result<Pin<Box<SslStream<TcpStream>>>> {
306 + let tcp = TcpStream::connect(addr).await?;
307 +
308 + let mut builder = SslConnector::builder(SslMethod::tls_client()).expect("connector builder");
309 + builder.set_ca_file(certs.path("ca.pem")).expect("trust ca");
310 + if let Some(name) = identity {
311 + builder
312 + .set_certificate_chain_file(certs.path(&format!("{name}.pem")))
313 + .expect("client cert");
314 + builder
315 + .set_private_key_file(certs.path(&format!("{name}.key")), SslFiletype::PEM)
316 + .expect("client key");
317 + }
318 + let connector = builder.build();
319 + let mut config = connector.configure().expect("configure");
320 + config.set_verify_hostname(false);
321 + let ssl = config.into_ssl("127.0.0.1").expect("ssl");
322 +
323 + let mut stream = Box::pin(SslStream::new(ssl, tcp).expect("client ssl stream"));
324 + stream.as_mut().connect().await.map_err(std::io::Error::other)?;
325 + Ok(stream)
326 +}
327 +
328 +/// Issue an HTTPS GET (with `Connection: close`), optionally with an mTLS identity.
329 +async fn https_get(
330 + certs: &TestCerts,
331 + addr: &str,
332 + identity: Option<&str>,
333 +) -> std::io::Result<String> {
334 + let mut stream = connect_tls(certs, addr, identity).await?;
335 + stream
336 + .write_all(b"GET /health HTTP/1.1\r\nHost: 127.0.0.1\r\nConnection: close\r\n\r\n")
337 + .await?;
338 + let mut response = String::new();
339 + stream.read_to_string(&mut response).await?;
340 + Ok(response)
341 +}
342 +
343 +/// Attempt a handshake offering only a legacy CBC+SHA1 suite over TLS 1.2,
344 +/// capping the version so TLS 1.3 cannot rescue the negotiation.
345 +async fn legacy_suite_handshake(certs: &TestCerts, addr: &str) -> std::io::Result<()> {
346 + let tcp = TcpStream::connect(addr).await?;
347 +
348 + let mut builder = SslConnector::builder(SslMethod::tls_client()).expect("connector builder");
349 + builder.set_ca_file(certs.path("ca.pem")).expect("trust ca");
350 + builder.set_max_proto_version(Some(SslVersion::TLS1_2)).expect("cap tls1.2");
351 + builder
352 + .set_cipher_list("ECDHE-ECDSA-AES256-SHA:@SECLEVEL=0")
353 + .expect("legacy cipher");
354 + let connector = builder.build();
355 + let mut config = connector.configure().expect("configure");
356 + config.set_verify_hostname(false);
357 + let ssl = config.into_ssl("127.0.0.1").expect("ssl");
358 +
359 + let stream = SslStream::new(ssl, tcp).expect("client ssl stream");
360 + tokio::pin!(stream);
361 + stream.as_mut().connect().await.map_err(std::io::Error::other)
362 +}
363 +
364 +async fn plain_get(addr: &str) -> std::io::Result<String> {
365 + let mut tcp = TcpStream::connect(addr).await?;
366 + tcp.write_all(b"GET /health HTTP/1.1\r\nHost: 127.0.0.1\r\nConnection: close\r\n\r\n")
367 + .await?;
368 + let mut response = String::new();
369 + tcp.read_to_string(&mut response).await?;
370 + Ok(response)
371 +}
372 +
373 +// ============================================================================
374 +// Tests
375 +// ============================================================================
376 +
377 +#[test]
378 +fn builds_from_combined_pem() {
379 + // Key omitted: it is read from the combined cert+key file.
380 + let certs = TestCerts::generate();
381 + assert!(tls::build_server_config(&build_tls(&certs, "server_combined.pem", None)).is_ok());
382 +}
383 +
384 +#[test]
385 +fn rejects_missing_cert_file() {
386 + let certs = TestCerts::generate();
387 + assert!(tls::build_server_config(&build_tls(&certs, "does_not_exist.pem", None)).is_err());
388 +}
389 +
390 +#[test]
391 +fn accepts_valid_cipher_list() {
392 + let certs = TestCerts::generate();
393 + let mut cfg = build_tls(&certs, "server.pem", Some("server.key"));
394 + cfg.ciphers = Some("ECDHE-ECDSA-AES256-GCM-SHA384".to_string());
395 + assert!(tls::build_server_config(&cfg).is_ok());
396 +}
397 +
398 +#[test]
399 +fn rejects_invalid_cipher_list() {
400 + let certs = TestCerts::generate();
401 + let mut cfg = build_tls(&certs, "server.pem", Some("server.key"));
402 + cfg.ciphers = Some("THIS-IS-NOT-A-CIPHER".to_string());
403 + assert!(tls::build_server_config(&cfg).is_err());
404 +}
405 +
406 +#[test]
407 +fn rejects_mismatched_cert_and_key() {
408 + // check_private_key must reject a key that does not match the certificate.
409 + let certs = TestCerts::generate();
410 + let tls = build_tls(&certs, "client.pem", Some("server.key"));
411 + assert!(tls::build_server_config(&tls).is_err());
412 +}
413 +
414 +#[tokio::test]
415 +async fn https_request_succeeds_over_tls() {
416 + let certs = TestCerts::generate();
417 + let (addr, shutdown) = spawn_server(Some(server_tls(&certs, 0))).await;
418 + let response = https_get(&certs, &addr, None).await.expect("https request");
419 + assert!(response.starts_with("HTTP/1.1 200"), "{response}");
420 + shutdown.cancel();
421 +}
422 +
423 +#[tokio::test]
424 +async fn serves_full_certificate_chain() {
425 + // Cert file holds leaf + intermediate; a client trusting only the root can
426 + // verify only if the server sends the intermediate, guarding against a
427 + // leaf-only load.
428 + let certs = TestCerts::generate();
429 + let tls = TlsConfig {
430 + cert_file: Some(certs.path("server_chain.pem")),
431 + key_file: Some(certs.path("server_chain.key")),
432 + ca_certs: None,
433 + cert_reqs: 0,
434 + ciphers: None,
435 + };
436 + let (addr, shutdown) = spawn_server(Some(tls)).await;
437 + let response = https_get(&certs, &addr, None).await.expect("chained https request");
438 + assert!(response.starts_with("HTTP/1.1 200"), "{response}");
439 + shutdown.cancel();
440 +}
441 +
442 +#[tokio::test]
443 +async fn rejects_legacy_cipher_only_client() {
444 + let certs = TestCerts::generate();
445 + let (addr, shutdown) = spawn_server(Some(server_tls(&certs, 0))).await;
446 + let result = legacy_suite_handshake(&certs, &addr).await;
447 + assert!(result.is_err(), "legacy-only client must be rejected");
448 + shutdown.cancel();
449 +}
450 +
451 +#[tokio::test]
452 +async fn ssl_ciphers_override_widens_past_preset() {
453 + // Counterpart to rejects_legacy_cipher_only_client: --ssl-ciphers set to that
454 + // same legacy suite lets the client through, proving the override beats the preset.
455 + let certs = TestCerts::generate();
456 + let mut tls = server_tls(&certs, 0);
457 + tls.ciphers = Some("ECDHE-ECDSA-AES256-SHA:@SECLEVEL=0".to_string());
458 + let (addr, shutdown) = spawn_server(Some(tls)).await;
459 + let result = legacy_suite_handshake(&certs, &addr).await;
460 + assert!(
461 + result.is_ok(),
462 + "override must allow the legacy suite: {result:?}"
463 + );
464 + shutdown.cancel();
465 +}
466 +
467 +#[tokio::test]
468 +async fn mtls_required_rejects_client_without_certificate() {
469 + let certs = TestCerts::generate();
470 + let (addr, shutdown) = spawn_server(Some(server_tls(&certs, 2))).await;
471 + let result = https_get(&certs, &addr, None).await;
472 + assert!(
473 + result.is_err(),
474 + "handshake must fail without a client certificate"
475 + );
476 + shutdown.cancel();
477 +}
478 +
479 +#[tokio::test]
480 +async fn mtls_required_accepts_valid_client_certificate() {
481 + let certs = TestCerts::generate();
482 + let (addr, shutdown) = spawn_server(Some(server_tls(&certs, 2))).await;
483 + let response = https_get(&certs, &addr, Some("client")).await.expect("mtls request");
484 + assert!(response.starts_with("HTTP/1.1 200"), "{response}");
485 + shutdown.cancel();
486 +}
487 +
488 +#[tokio::test]
489 +async fn mtls_optional_allows_anonymous_and_authenticated() {
490 + let certs = TestCerts::generate();
491 + let (addr, shutdown) = spawn_server(Some(server_tls(&certs, 1))).await;
492 + let anonymous = https_get(&certs, &addr, None).await.expect("anonymous request");
493 + assert!(anonymous.starts_with("HTTP/1.1 200"), "{anonymous}");
494 + let authenticated =
495 + https_get(&certs, &addr, Some("client")).await.expect("authenticated request");
496 + assert!(authenticated.starts_with("HTTP/1.1 200"), "{authenticated}");
497 + shutdown.cancel();
498 +}
499 +
500 +#[tokio::test]
501 +async fn mtls_rejects_untrusted_client_certificate() {
502 + // Optional (1) still verifies a presented cert, so a self-signed cert not
503 + // chained to the CA is rejected in both modes, not just required (2).
504 + let certs = TestCerts::generate();
505 + for cert_reqs in [1, 2] {
506 + let (addr, shutdown) = spawn_server(Some(server_tls(&certs, cert_reqs))).await;
507 + let result = https_get(&certs, &addr, Some("untrusted_client")).await;
508 + assert!(
509 + result.is_err(),
510 + "cert_reqs={cert_reqs}: untrusted client cert must be rejected"
511 + );
512 + shutdown.cancel();
513 + }
514 +}
515 +
516 +#[tokio::test]
517 +async fn plain_http_serves_when_tls_is_disabled() {
518 + let (addr, shutdown) = spawn_server(None).await;
519 + let response = plain_get(&addr).await.expect("http request");
520 + assert!(response.starts_with("HTTP/1.1 200"), "{response}");
521 + shutdown.cancel();
522 +}
523 +
524 +#[tokio::test]
525 +async fn tls_handshake_timeout_drops_silent_client() {
526 + // Silent client (no ClientHello) must be dropped at the handshake deadline.
527 + let certs = TestCerts::generate();
528 + let timeouts = ConnectionTimeouts {
529 + handshake: Duration::from_millis(150),
530 + header_read: Duration::from_secs(5),
531 + keep_alive_enabled: true,
532 + };
533 + let (addr, shutdown) = spawn_server_with_timeouts(Some(server_tls(&certs, 0)), timeouts).await;
534 +
535 + let mut tcp = TcpStream::connect(&addr).await.expect("connect");
536 + let mut buf = [0u8; 1];
537 + let read = tokio::time::timeout(Duration::from_secs(5), tcp.read(&mut buf)).await;
538 + assert!(
539 + matches!(read, Ok(Ok(0)) | Ok(Err(_))),
540 + "server must drop a stalled TLS handshake (expected close, got {read:?})"
541 + );
542 + shutdown.cancel();
543 +}
544 +
545 +#[tokio::test]
546 +async fn keep_alive_timeout_closes_idle_connection() {
547 + // Idle keep-alive connection must be closed at the deadline.
548 + let timeouts = ConnectionTimeouts {
549 + handshake: Duration::from_secs(60),
550 + header_read: Duration::from_millis(150),
551 + keep_alive_enabled: true,
552 + };
553 + let (addr, shutdown) = spawn_server_with_timeouts(None, timeouts).await;
554 +
555 + let mut tcp = TcpStream::connect(&addr).await.expect("connect");
556 + // No `Connection: close`, so it stays alive until the idle deadline.
557 + tcp.write_all(b"GET /health HTTP/1.1\r\nHost: 127.0.0.1\r\n\r\n")
558 + .await
559 + .expect("write request");
560 +
561 + let drained = tokio::time::timeout(Duration::from_secs(5), async {
562 + let mut buf = [0u8; 1024];
563 + loop {
564 + match tcp.read(&mut buf).await {
565 + Ok(0) => return Ok(()),
566 + Ok(_) => continue,
567 + Err(err) => return Err(err),
568 + }
569 + }
570 + })
571 + .await;
572 + assert!(
573 + matches!(drained, Ok(Ok(()))),
574 + "server must close an idle keep-alive connection (got {drained:?})"
575 + );
576 + shutdown.cancel();
577 +}
578 +
579 +#[tokio::test]
580 +async fn keep_alive_timeout_closes_idle_tls_connection() {
581 + // The keep-alive idle bound lives in serve_connections, below TLS; assert it
582 + // still fires through tls-listener's post-handshake SslStream, not just plaintext.
583 + let certs = TestCerts::generate();
584 + let timeouts = ConnectionTimeouts {
585 + handshake: Duration::from_secs(60),
586 + header_read: Duration::from_millis(150),
587 + keep_alive_enabled: true,
588 + };
589 + let (addr, shutdown) = spawn_server_with_timeouts(Some(server_tls(&certs, 0)), timeouts).await;
590 +
591 + let mut stream = connect_tls(&certs, &addr, None).await.expect("handshake");
592 + // No `Connection: close`, so the connection stays alive until the idle deadline.
593 + stream
594 + .write_all(b"GET /health HTTP/1.1\r\nHost: 127.0.0.1\r\n\r\n")
595 + .await
596 + .expect("write request");
597 +
598 + let closed = tokio::time::timeout(Duration::from_secs(5), async {
599 + let mut buf = [0u8; 1024];
600 + loop {
601 + // A clean close_notify (Ok(0)) or an abrupt TLS EOF both mean the
602 + // server closed; only the outer timeout (still open) is a failure.
603 + match stream.read(&mut buf).await {
604 + Ok(0) | Err(_) => break,
605 + Ok(_) => continue,
606 + }
607 + }
608 + })
609 + .await;
610 + assert!(
611 + closed.is_ok(),
612 + "server must close an idle keep-alive TLS connection at the deadline"
613 + );
614 + shutdown.cancel();
615 +}
616 +
617 +#[tokio::test]
618 +async fn idle_timeout_closes_silent_client() {
619 + // Silent client closed by the header-read timeout (http1-only arms it from byte 0).
620 + let timeouts = ConnectionTimeouts {
621 + handshake: Duration::from_secs(60),
622 + header_read: Duration::from_millis(150),
623 + keep_alive_enabled: true,
624 + };
625 + let (addr, shutdown) = spawn_server_with_timeouts(None, timeouts).await;
626 +
627 + let mut tcp = TcpStream::connect(&addr).await.expect("connect");
628 + let mut buf = [0u8; 1];
629 + let read = tokio::time::timeout(Duration::from_secs(5), tcp.read(&mut buf)).await;
630 + assert!(
631 + matches!(read, Ok(Ok(0)) | Ok(Err(_))),
632 + "server must close a silent client (expected close, got {read:?})"
633 + );
634 + shutdown.cancel();
635 +}
636 +
637 +#[tokio::test]
638 +async fn keep_alive_zero_disables_keep_alive() {
639 + // 0 disables keep-alive (serve, then close), like uvicorn's timeout_keep_alive=0.
640 + let timeouts = ConnectionTimeouts {
641 + handshake: Duration::from_secs(60),
642 + header_read: Duration::from_secs(5),
643 + keep_alive_enabled: false,
644 + };
645 + let (addr, shutdown) = spawn_server_with_timeouts(None, timeouts).await;
646 +
647 + let mut tcp = TcpStream::connect(&addr).await.expect("connect");
648 + tcp.write_all(b"GET /health HTTP/1.1\r\nHost: 127.0.0.1\r\n\r\n")
649 + .await
650 + .expect("write request");
651 +
652 + let mut response = String::new();
653 + let read =
654 + tokio::time::timeout(Duration::from_secs(5), tcp.read_to_string(&mut response)).await;
655 + assert!(
656 + read.is_ok(),
657 + "server must close after one response, not hang"
658 + );
659 + assert!(response.starts_with("HTTP/1.1 200"), "{response}");
660 + // Assert `Connection: close`, not just 200: a 0 header-read timeout would also
661 + // serve an immediate request, so 200 alone wouldn't prove keep-alive is off.
662 + assert!(
663 + response.to_ascii_lowercase().contains("connection: close"),
664 + "keep-alive must be disabled (expected Connection: close): {response}"
665 + );
666 + shutdown.cancel();
667 +}
668 +
669 +#[tokio::test]
670 +async fn disabled_keep_alive_still_closes_silent_client() {
671 + // Even with keep-alive off, the head read stays bounded, so a silent client
672 + // is dropped rather than held open.
673 + let timeouts = ConnectionTimeouts {
674 + handshake: Duration::from_secs(60),
675 + header_read: Duration::from_millis(150),
676 + keep_alive_enabled: false,
677 + };
678 + let (addr, shutdown) = spawn_server_with_timeouts(None, timeouts).await;
679 +
680 + let mut tcp = TcpStream::connect(&addr).await.expect("connect");
681 + let mut buf = [0u8; 1];
682 + let read = tokio::time::timeout(Duration::from_secs(5), tcp.read(&mut buf)).await;
683 + assert!(
684 + matches!(read, Ok(Ok(0)) | Ok(Err(_))),
685 + "disabled keep-alive must still close a silent client (got {read:?})"
686 + );
687 + shutdown.cancel();
688 +}
vllm/v1/utils.py
+4 -2
@@ -378,9 +378,11 @@ class RustFrontendProcessManager:
378 # The Rust `frontend` subcommand parses --args-json via serde_json,
379 # which bypasses clap and therefore ignores any `#[arg(env = ...)]`
380 # declarations on SharedRuntimeArgs fields. Forward the env-driven
381 - # ready timeout explicitly so VLLM_ENGINE_READY_TIMEOUT_S behaves the
382 - # same on both Python and Rust frontends.
381 + # values explicitly so VLLM_ENGINE_READY_TIMEOUT_S and
382 + # VLLM_HTTP_TIMEOUT_KEEP_ALIVE behave the same on both Python and Rust
383 + # frontends.
384 args_dict["engine_ready_timeout_secs"] = envs.VLLM_ENGINE_READY_TIMEOUT_S
385 + args_dict["http_timeout_keep_alive"] = envs.VLLM_HTTP_TIMEOUT_KEEP_ALIVE
386 args_json = json.dumps(args_dict, sort_keys=True)
387 cmd.extend(["--args-json", args_json])
388