Skip to content
6 changes: 4 additions & 2 deletions rust/src/server/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ use vllm_text::TextLlm;

use crate::listener::Listener;
use crate::routes::build_router;
use crate::state::AppState;
use crate::state::{AppState, ServerInfoSnapshot};

/// Build the shared application state for one configured model and one engine
/// client.
Expand Down Expand Up @@ -85,7 +85,9 @@ async fn build_state(config: &Config) -> Result<Arc<AppState>> {
};

Ok(Arc::new(
AppState::new(served_model_names, chat).with_log_requests(config.enable_log_requests),
AppState::new(served_model_names, chat)
.with_log_requests(config.enable_log_requests)
.with_server_info(ServerInfoSnapshot::from_config(config)),
))
}

Expand Down
2 changes: 2 additions & 0 deletions rust/src/server/src/routes.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ mod inference;
mod load;
mod metrics;
pub(crate) mod openai;
mod server_info;
mod sleep;
mod version;

Expand Down Expand Up @@ -37,6 +38,7 @@ fn build_router_with_dev_mode(state: Arc<AppState>, dev_mode_enabled: bool) -> R
.route("/metrics", get(metrics::scrape))
.route("/load", get(load::load))
.route("/version", get(version::version))
.route("/server_info", get(server_info::server_info))
// OpenAI-compatible endpoints
.route("/v1/models", get(openai::list_models))
.route("/v1/completions", post(openai::completions))
Expand Down
42 changes: 42 additions & 0 deletions rust/src/server/src/routes/server_info.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
use std::sync::Arc;

use axum::Json;
use axum::extract::{Query, State};
use serde::Deserialize;
use serde_json::Value;

use crate::state::{AppState, ServerInfoConfigFormat};

#[derive(Debug, Clone, Copy, Deserialize)]
#[serde(rename_all = "lowercase")]
enum ConfigFormat {
Text,
Json,
}

impl From<ConfigFormat> for ServerInfoConfigFormat {
fn from(value: ConfigFormat) -> Self {
match value {
ConfigFormat::Text => Self::Text,
ConfigFormat::Json => Self::Json,
}
}
}

fn default_config_format() -> ConfigFormat {
ConfigFormat::Text
}

#[derive(Debug, Deserialize)]
pub(crate) struct ServerInfoParams {
#[serde(default = "default_config_format")]
config_format: ConfigFormat,
}

/// Get server configuration and environment metadata.
pub async fn server_info(
State(state): State<Arc<AppState>>,
Query(params): Query<ServerInfoParams>,
) -> Json<Value> {
Json(state.server_info_response(params.config_format.into()))
}
52 changes: 52 additions & 0 deletions rust/src/server/src/routes/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1015,6 +1015,58 @@ async fn version_returns_engine_vllm_version() {
);
}

#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[serial]
async fn server_info_endpoint_returns_text_config_by_default() {
let mut app = test_app().await;
let response = app
.call(
Request::builder()
.uri("/server_info")
.body(Body::empty())
.expect("build request"),
)
.await
.expect("call app");

assert_eq!(response.status(), StatusCode::OK);
let body = to_bytes(response.into_body(), usize::MAX).await.expect("read body");
let json: serde_json::Value = serde_json::from_slice(&body).expect("decode json");
let config = json["vllm_config"].as_str().expect("text config");

assert!(config.contains("served_model_name"));
assert!(json["vllm_env"].is_object());
assert_eq!(json["system_env"]["arch"], std::env::consts::ARCH);
assert_eq!(json["system_env"]["family"], std::env::consts::FAMILY);
assert_eq!(json["system_env"]["os"], std::env::consts::OS);
}

#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[serial]
async fn server_info_endpoint_returns_json_config_when_requested() {
let mut app = test_app().await;
let response = app
.call(
Request::builder()
.uri("/server_info?config_format=json")
.body(Body::empty())
.expect("build request"),
)
.await
.expect("call app");

assert_eq!(response.status(), StatusCode::OK);
let body = to_bytes(response.into_body(), usize::MAX).await.expect("read body");
let json: serde_json::Value = serde_json::from_slice(&body).expect("decode json");

assert_eq!(
json["vllm_config"]["served_model_name"],
json!(["Qwen/Qwen1.5-0.5B-Chat"])
);
assert!(json["vllm_env"].is_object());
assert!(json["system_env"].is_object());
}

#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[serial]
async fn http_metrics_record_list_models_requests() {
Expand Down
103 changes: 103 additions & 0 deletions rust/src/server/src/state.rs
Original file line number Diff line number Diff line change
@@ -1,13 +1,101 @@
use std::collections::BTreeMap;
use std::sync::Arc;
use std::sync::atomic::{AtomicU64, Ordering};

use serde_json::{Value, json};
use tokio::time::{Duration, Instant, sleep_until};
use tracing::warn;
use vllm_chat::ChatLlm;
use vllm_engine_core_client::EngineCoreClient;

use crate::config::Config;

const SHUTDOWN_REFCOUNT_POLL_INTERVAL: Duration = Duration::from_millis(100);

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum ServerInfoConfigFormat {
Text,
Json,
}

/// Snapshot returned by `/server_info`.
#[derive(Debug, Clone)]
pub(crate) struct ServerInfoSnapshot {
vllm_config_text: String,
vllm_config_json: Value,
vllm_env: BTreeMap<String, String>,
system_env: BTreeMap<String, String>,
}

impl ServerInfoSnapshot {
fn from_served_model_names(served_model_names: &[String]) -> Self {
Self {
vllm_config_text: format!("served_model_name={served_model_names:?}"),
vllm_config_json: json!({
"served_model_name": served_model_names,
}),
vllm_env: collect_vllm_env(),
system_env: collect_system_env(),
}
}

/// Capture the runtime configuration fields available to the Rust frontend.
pub(crate) fn from_config(config: &Config) -> Self {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What about also moving the construction-related logic into a separate module?

let vllm_config_json = json!({
"transport_mode": format!("{:?}", config.transport_mode),
"coordinator_mode": format!("{:?}", config.coordinator_mode),
"model": config.model.clone(),
"served_model_name": config.served_model_name.clone(),
"listener_mode": format!("{:?}", config.listener_mode),
"tool_call_parser": format!("{:?}", config.tool_call_parser),
"reasoning_parser": format!("{:?}", config.reasoning_parser),
"renderer": format!("{:?}", config.renderer),
"chat_template": config.chat_template.clone(),
"default_chat_template_kwargs": config.default_chat_template_kwargs.clone(),
"chat_template_content_format": format!("{:?}", config.chat_template_content_format),
"enable_log_requests": config.enable_log_requests,
"disable_log_stats": config.disable_log_stats,
"grpc_port": config.grpc_port,
"shutdown_timeout_secs": config.shutdown_timeout.as_secs_f64(),
"engine_count": config.engine_count(),
});

Self {
vllm_config_text: format!("{config:#?}"),
vllm_config_json,
vllm_env: collect_vllm_env(),
system_env: collect_system_env(),
}
}

pub(crate) fn response(&self, config_format: ServerInfoConfigFormat) -> Value {
let vllm_config = match config_format {
ServerInfoConfigFormat::Text => Value::String(self.vllm_config_text.clone()),
ServerInfoConfigFormat::Json => self.vllm_config_json.clone(),
};

json!({
"vllm_config": vllm_config,
"vllm_env": self.vllm_env.clone(),
"system_env": self.system_env.clone(),
})
}
}

fn collect_vllm_env() -> BTreeMap<String, String> {
std::env::vars()
.filter(|(key, _)| key.starts_with("VLLM_") && !key.contains("KEY"))

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Severity: MEDIUM

Unlike the Python equivalent which iterates over a curated whitelist of ~240 known attributes in the vllm.envs module, this function reads all OS environment variables matching VLLM_*. Any custom deployment-set env var (e.g. VLLM_AUTH_TOKEN, VLLM_DB_PASSWORD) that doesn't contain "KEY" in its name would be leaked through the unauthenticated /server_info endpoint.
Helpful? Add 👍 / 👎

💡 Fix Suggestion

Suggestion: The collect_vllm_env() function reads ALL OS environment variables matching VLLM_*, unlike the Python equivalent which only iterates over a curated whitelist of ~240 known attributes defined in vllm.envs. Any deployment-custom env var (e.g. VLLM_AUTH_TOKEN, VLLM_DB_PASSWORD) that doesn't contain "KEY" in its name would be leaked through the unauthenticated /server_info endpoint.

The ideal fix is to replicate the Python behavior by maintaining an explicit allowlist of known VLLM environment variable names (the ~240 variables defined in vllm/envs.py), and only exposing those. This is a larger change requiring an allowlist constant to be kept in sync with the Python vllm.envs module.

As an immediate improvement, enhance the denylist filter to exclude additional common sensitive patterns such as SECRET, TOKEN, PASSWORD, CREDENTIAL, and AUTH. This reduces the attack surface but is not as robust as the allowlist approach.

⚠️ Experimental Feature: This code suggestion is automatically generated. Please review carefully.

Suggested change
fn collect_vllm_env() -> BTreeMap<String, String> {
std::env::vars()
.filter(|(key, _)| key.starts_with("VLLM_") && !key.contains("KEY"))
fn collect_vllm_env() -> BTreeMap<String, String> {
const SENSITIVE_PATTERNS: &[&str] = &[
"KEY", "SECRET", "TOKEN", "PASSWORD", "CREDENTIAL", "AUTH",
];
std::env::vars()
.filter(|(key, _)| {
key.starts_with("VLLM_")
&& !SENSITIVE_PATTERNS.iter().any(|pat| key.contains(pat))
})
.collect()
}

.collect()
}

fn collect_system_env() -> BTreeMap<String, String> {
BTreeMap::from([
("arch".to_string(), std::env::consts::ARCH.to_string()),
("family".to_string(), std::env::consts::FAMILY.to_string()),
("os".to_string(), std::env::consts::OS.to_string()),
])
}

/// Shared router state for the minimal single-model OpenAI server.
pub struct AppState {
/// All public model IDs served by this frontend. The first entry is the
Expand All @@ -17,6 +105,8 @@ pub struct AppState {
pub chat: ChatLlm,
/// Whether to log a summary line for each completed request.
pub enable_log_requests: bool,
/// Runtime server information returned by `/server_info`.
server_info: ServerInfoSnapshot,
/// Number of in-flight inference requests currently owned by this frontend.
server_load: AtomicU64,
}
Expand All @@ -35,10 +125,12 @@ impl AppState {
!served_model_names.is_empty(),
"served_model_names must not be empty"
);
let server_info = ServerInfoSnapshot::from_served_model_names(&served_model_names);
Self {
served_model_names,
chat,
enable_log_requests: false,
server_info,
server_load: AtomicU64::new(0),
}
}
Expand All @@ -49,6 +141,17 @@ impl AppState {
self
}

/// Attach the runtime server information snapshot used by `/server_info`.
pub(crate) fn with_server_info(mut self, server_info: ServerInfoSnapshot) -> Self {
self.server_info = server_info;
self
}

/// Build a `/server_info` response payload.
pub(crate) fn server_info_response(&self, config_format: ServerInfoConfigFormat) -> Value {
self.server_info.response(config_format)
}

/// The primary model name echoed back in API responses (the first served
/// name).
pub fn primary_model_name(&self) -> &str {
Expand Down
Loading