mirror of
https://github.com/rustfs/rustfs.git
synced 2026-08-19 19:16:17 +00:00
1246 lines
47 KiB
Rust
1246 lines
47 KiB
Rust
// Copyright 2024 RustFS Team
|
|
//
|
|
// Licensed under the Apache License, Version 2.0 (the "License");
|
|
// you may not use this file except in compliance with the License.
|
|
// You may obtain a copy of the License at
|
|
//
|
|
// http://www.apache.org/licenses/LICENSE-2.0
|
|
//
|
|
// Unless required by applicable law or agreed to in writing, software
|
|
// distributed under the License is distributed on an "AS IS" BASIS,
|
|
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
// See the License for the specific language governing permissions and
|
|
// limitations under the License.
|
|
|
|
use crate::admin::runtime_sources::{current_federated_identity_service, default_admin_usecase};
|
|
use crate::admin::storage_api::access::RequestContext;
|
|
use crate::license::has_valid_license;
|
|
use crate::server::has_path_prefix;
|
|
use crate::server::rate_limit::{
|
|
LABEL_RATE_LIMIT_DIMENSION, LABEL_RATE_LIMIT_SCOPE, METRIC_HTTP_SERVER_REQUESTS_RATE_LIMITED_TOTAL,
|
|
RATE_LIMIT_DIMENSION_CLIENT_IP, RATE_LIMIT_SCOPE_CONSOLE, RateLimitDecision, RateLimitQuota, RateLimiter,
|
|
apply_throttle_headers, client_ip,
|
|
};
|
|
use crate::server::{
|
|
APPLE_TOUCH_ICON_PATH, APPLE_TOUCH_ICON_PRECOMPOSED_PATH, CONSOLE_PREFIX, FAVICON_PATH, HEALTH_PREFIX, HEALTH_READY_PATH,
|
|
HeaderMapCarrier, HealthProbe, LICENSE, RUSTFS_ADMIN_PREFIX, RequestContextLayer, VERSION, build_health_response_parts,
|
|
collect_probe_readiness,
|
|
};
|
|
use crate::version::build;
|
|
use axum::{
|
|
Extension, Json, Router,
|
|
body::Body,
|
|
extract::Request,
|
|
middleware,
|
|
response::{IntoResponse, Response},
|
|
routing::get,
|
|
};
|
|
use http::{HeaderMap, HeaderName, HeaderValue, Method, StatusCode, Uri};
|
|
use mime_guess::from_path;
|
|
use opentelemetry::global;
|
|
use rust_embed::RustEmbed;
|
|
use serde::Serialize;
|
|
use std::{
|
|
net::{IpAddr, SocketAddr},
|
|
sync::{Arc, OnceLock},
|
|
time::Duration,
|
|
};
|
|
use tower_http::catch_panic::CatchPanicLayer;
|
|
use tower_http::compression::CompressionLayer;
|
|
use tower_http::cors::{AllowOrigin, Any, CorsLayer};
|
|
use tower_http::limit::RequestBodyLimitLayer;
|
|
use tower_http::request_id::{MakeRequestUuid, PropagateRequestIdLayer, SetRequestIdLayer};
|
|
use tower_http::timeout::TimeoutLayer;
|
|
use tower_http::trace::TraceLayer;
|
|
use tracing::{Span, debug, error, info, instrument, warn};
|
|
use tracing_opentelemetry::OpenTelemetrySpanExt;
|
|
|
|
#[derive(RustEmbed)]
|
|
#[folder = "$CARGO_MANIFEST_DIR/static"]
|
|
struct StaticFiles;
|
|
|
|
/// Static file handler
|
|
///
|
|
/// Serves static files embedded in the binary using rust-embed.
|
|
/// If the requested file is not found, it serves index.html as a fallback.
|
|
/// If index.html is also not found, it returns a 404 Not Found response.
|
|
///
|
|
/// # Arguments:
|
|
/// - `uri`: The request URI.
|
|
///
|
|
/// # Returns:
|
|
/// - An `impl IntoResponse` containing the static file content or a 404 response.
|
|
///
|
|
async fn static_handler(uri: Uri) -> impl IntoResponse {
|
|
let mut path = uri.path().trim_start_matches('/');
|
|
if path.is_empty() {
|
|
path = "index.html"
|
|
}
|
|
|
|
// Try the exact path first
|
|
if let Some(file) = StaticFiles::get(path) {
|
|
let mime_type = from_path(path).first_or_octet_stream();
|
|
return Response::builder()
|
|
.status(StatusCode::OK)
|
|
.header("Content-Type", mime_type.to_string())
|
|
.body(Body::from(file.data))
|
|
.unwrap();
|
|
}
|
|
|
|
// For directory paths (trailing slash), try <path>index.html
|
|
if path.ends_with('/') {
|
|
let index_path = format!("{path}index.html");
|
|
if let Some(file) = StaticFiles::get(&index_path) {
|
|
let mime_type = from_path(&index_path).first_or_octet_stream();
|
|
return Response::builder()
|
|
.status(StatusCode::OK)
|
|
.header("Content-Type", mime_type.to_string())
|
|
.body(Body::from(file.data))
|
|
.unwrap();
|
|
}
|
|
}
|
|
|
|
// SPA fallback: serve root index.html for client-side routing
|
|
if let Some(file) = StaticFiles::get("index.html") {
|
|
let mime_type = from_path("index.html").first_or_octet_stream();
|
|
Response::builder()
|
|
.status(StatusCode::OK)
|
|
.header("Content-Type", mime_type.to_string())
|
|
.body(Body::from(file.data))
|
|
.unwrap()
|
|
} else {
|
|
Response::builder()
|
|
.status(StatusCode::NOT_FOUND)
|
|
.body(Body::from(" 404 Not Found \n RustFS "))
|
|
.unwrap()
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Serialize, Clone)]
|
|
pub(crate) struct Config {
|
|
#[serde(skip)]
|
|
#[allow(
|
|
dead_code,
|
|
reason = "reachable only from this file's tests: no route registers config_handler (backlog#1823)"
|
|
)]
|
|
port: u16,
|
|
api: Api,
|
|
s3: S3,
|
|
release: Release,
|
|
license: License,
|
|
doc: String,
|
|
#[serde(skip_serializing_if = "Vec::is_empty")]
|
|
oidc: Vec<OidcProviderInfo>,
|
|
}
|
|
|
|
#[derive(Debug, Serialize, Clone)]
|
|
struct OidcProviderInfo {
|
|
provider_id: String,
|
|
display_name: String,
|
|
}
|
|
|
|
impl Config {
|
|
fn new(local_ip: IpAddr, port: u16, version: &str, date: &str) -> Self {
|
|
let http_prefix = rustfs_config::RUSTFS_HTTP_PREFIX;
|
|
|
|
// Collect OIDC provider info if available
|
|
let oidc = current_federated_identity_service()
|
|
.map(|federation| {
|
|
federation
|
|
.list_visible_providers()
|
|
.into_iter()
|
|
.map(|p| OidcProviderInfo {
|
|
provider_id: p.provider_id,
|
|
display_name: p.display_name,
|
|
})
|
|
.collect()
|
|
})
|
|
.unwrap_or_default();
|
|
|
|
Config {
|
|
port,
|
|
api: Api {
|
|
base_url: build_console_api_base_url(&format!("{http_prefix}{local_ip}:{port}")),
|
|
discovery: console_api_discovery(),
|
|
},
|
|
s3: S3 {
|
|
endpoint: format!("{http_prefix}{local_ip}:{port}"),
|
|
region: rustfs_config::RUSTFS_REGION.to_string(),
|
|
},
|
|
release: Release {
|
|
version: version.to_string(),
|
|
date: date.to_string(),
|
|
},
|
|
license: License {
|
|
name: rustfs_config::RUSTFS_LICENSE.to_string(),
|
|
url: rustfs_config::RUSTFS_LICENSE_URL.to_string(),
|
|
},
|
|
doc: rustfs_config::RUSTFS_DOCS_URL.to_string(),
|
|
oidc,
|
|
}
|
|
}
|
|
|
|
#[allow(
|
|
dead_code,
|
|
reason = "reachable only from this file's tests: no route registers config_handler (backlog#1823)"
|
|
)]
|
|
fn to_json(&self) -> String {
|
|
serde_json::to_string(self).unwrap_or_default()
|
|
}
|
|
|
|
pub(crate) fn version_info(&self) -> String {
|
|
format!(
|
|
"RELEASE.{}@{} (rust {} {})",
|
|
self.release.date.clone(),
|
|
self.release.version.clone().trim_start_matches('@'),
|
|
build::RUST_VERSION,
|
|
build::BUILD_TARGET
|
|
)
|
|
}
|
|
}
|
|
|
|
fn build_console_api_base_url(base_url: &str) -> String {
|
|
format!("{base_url}{RUSTFS_ADMIN_PREFIX}")
|
|
}
|
|
|
|
#[derive(Debug, Serialize, Clone)]
|
|
struct Api {
|
|
#[serde(rename = "baseURL")]
|
|
base_url: String,
|
|
discovery: ApiDiscovery,
|
|
}
|
|
|
|
#[derive(Debug, Serialize, Clone)]
|
|
struct ApiDiscovery {
|
|
#[serde(rename = "runtimeCapabilities")]
|
|
runtime_capabilities: String,
|
|
#[serde(rename = "clusterSnapshot")]
|
|
cluster_snapshot: String,
|
|
#[serde(rename = "extensionsCatalog")]
|
|
extensions_catalog: String,
|
|
#[serde(rename = "ilmExpiryStatus")]
|
|
ilm_expiry_status: String,
|
|
#[serde(rename = "ilmTransitionRun")]
|
|
ilm_transition_run: String,
|
|
#[serde(rename = "ilmTransitionJob")]
|
|
ilm_transition_job: String,
|
|
}
|
|
|
|
fn console_api_discovery() -> ApiDiscovery {
|
|
let usecase = default_admin_usecase();
|
|
ApiDiscovery {
|
|
runtime_capabilities: usecase.runtime_capabilities_route().to_string(),
|
|
cluster_snapshot: usecase.cluster_snapshot_route().to_string(),
|
|
extensions_catalog: usecase.extensions_catalog_route().to_string(),
|
|
ilm_expiry_status: format!("{RUSTFS_ADMIN_PREFIX}/ilm/expiry/status"),
|
|
ilm_transition_run: format!("{RUSTFS_ADMIN_PREFIX}/ilm/transition/run"),
|
|
ilm_transition_job: format!("{RUSTFS_ADMIN_PREFIX}/ilm/transition/jobs/{{job_id}}"),
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Serialize, Clone)]
|
|
struct S3 {
|
|
endpoint: String,
|
|
region: String,
|
|
}
|
|
|
|
#[derive(Debug, Serialize, Clone)]
|
|
struct Release {
|
|
version: String,
|
|
date: String,
|
|
}
|
|
|
|
#[derive(Debug, Serialize, Clone)]
|
|
struct License {
|
|
name: String,
|
|
url: String,
|
|
}
|
|
|
|
/// Global console configuration
|
|
static CONSOLE_CONFIG: OnceLock<Config> = OnceLock::new();
|
|
|
|
#[allow(clippy::const_is_empty)]
|
|
pub(crate) fn init_console_cfg(local_ip: IpAddr, port: u16) {
|
|
CONSOLE_CONFIG.get_or_init(|| {
|
|
let ver = {
|
|
if !build::TAG.is_empty() {
|
|
build::TAG.to_string()
|
|
} else if !build::SHORT_COMMIT.is_empty() {
|
|
format!("@{}", build::SHORT_COMMIT)
|
|
} else {
|
|
build::PKG_VERSION.to_string()
|
|
}
|
|
};
|
|
|
|
Config::new(local_ip, port, ver.as_str(), build::COMMIT_DATE_3339)
|
|
});
|
|
}
|
|
|
|
#[derive(Serialize)]
|
|
struct LicensePublicStatus {
|
|
licensed: bool,
|
|
}
|
|
|
|
/// Returns coarse public license status without exposing license metadata.
|
|
#[instrument]
|
|
async fn license_handler() -> impl IntoResponse {
|
|
Json(LicensePublicStatus {
|
|
licensed: has_valid_license(),
|
|
})
|
|
}
|
|
|
|
/// Check if the given IP address is a private IP
|
|
fn _is_private_ip(ip: IpAddr) -> bool {
|
|
match ip {
|
|
IpAddr::V4(ip) => {
|
|
let octets = ip.octets();
|
|
// 10.0.0.0/8
|
|
octets[0] == 10 ||
|
|
// 172.16.0.0/12
|
|
(octets[0] == 172 && (octets[1] >= 16 && octets[1] <= 31)) ||
|
|
// 192.168.0.0/16
|
|
(octets[0] == 192 && octets[1] == 168)
|
|
}
|
|
IpAddr::V6(_) => false,
|
|
}
|
|
}
|
|
|
|
/// Version handler
|
|
/// Returns the current version information of the console.
|
|
///
|
|
/// # Returns:
|
|
/// - 200 OK with JSON body containing version details if configuration is initialized.
|
|
/// - 500 Internal Server Error if configuration is not initialized.
|
|
#[instrument]
|
|
async fn version_handler() -> impl IntoResponse {
|
|
match CONSOLE_CONFIG.get() {
|
|
Some(cfg) => Response::builder()
|
|
.header("content-type", "application/json")
|
|
.status(StatusCode::OK)
|
|
.body(Body::from(
|
|
serde_json::json!({
|
|
"version": cfg.release.version,
|
|
"version_info": cfg.version_info(),
|
|
"date": cfg.release.date,
|
|
})
|
|
.to_string(),
|
|
))
|
|
.unwrap(),
|
|
None => Response::builder()
|
|
.status(StatusCode::INTERNAL_SERVER_ERROR)
|
|
.body(Body::from("Console configuration not initialized"))
|
|
.unwrap(),
|
|
}
|
|
}
|
|
|
|
/// Configuration handler
|
|
/// Returns the current console configuration in JSON format.
|
|
/// The configuration is dynamically adjusted based on the request's host and scheme.
|
|
///
|
|
/// # Arguments:
|
|
/// - `uri`: The request URI.
|
|
/// - `headers`: The request headers.
|
|
///
|
|
/// # Returns:
|
|
/// - 200 OK with JSON body containing the console configuration if initialized.
|
|
/// - 500 Internal Server Error if configuration is not initialized.
|
|
#[instrument(fields(uri))]
|
|
#[allow(
|
|
dead_code,
|
|
reason = "reachable only from this file's tests: no route registers it (backlog#1823)"
|
|
)]
|
|
async fn config_handler(uri: Uri, headers: HeaderMap) -> impl IntoResponse {
|
|
// Get the scheme from the headers or use the URI scheme
|
|
let scheme = headers
|
|
.get(HeaderName::from_static("x-forwarded-proto"))
|
|
.and_then(|value| value.to_str().ok())
|
|
.unwrap_or_else(|| uri.scheme().map(|s| s.as_str()).unwrap_or("http"));
|
|
|
|
// Prefer URI host, fallback to `Host` header
|
|
let header_host = headers
|
|
.get(http::header::HOST)
|
|
.and_then(|v| v.to_str().ok())
|
|
.unwrap_or_default();
|
|
|
|
let raw_host = uri.host().unwrap_or(header_host);
|
|
|
|
let host_for_url = if let Ok(socket_addr) = raw_host.parse::<SocketAddr>() {
|
|
// Successfully parsed, it's in IP:Port format.
|
|
// For IPv6, we need to enclose it in brackets to form a valid URL.
|
|
let ip = socket_addr.ip();
|
|
if ip.is_ipv6() { format!("[{ip}]") } else { format!("{ip}") }
|
|
} else if let Ok(ip) = raw_host.parse::<IpAddr>() {
|
|
// Pure IP (no ports)
|
|
if ip.is_ipv6() { format!("[{ip}]") } else { ip.to_string() }
|
|
} else {
|
|
// The domain name may not be able to resolve directly to IP, remove the port
|
|
raw_host.split(':').next().unwrap_or(raw_host).to_string()
|
|
};
|
|
|
|
// Make a copy of the current configuration
|
|
let mut cfg = match CONSOLE_CONFIG.get() {
|
|
Some(cfg) => cfg.clone(),
|
|
None => {
|
|
error!("Console configuration not initialized");
|
|
return Response::builder()
|
|
.status(StatusCode::INTERNAL_SERVER_ERROR)
|
|
.body(Body::from("Console configuration not initialized"))
|
|
.unwrap();
|
|
}
|
|
};
|
|
|
|
let url = format!("{}://{}:{}", scheme, host_for_url, cfg.port);
|
|
cfg.api.base_url = build_console_api_base_url(&url);
|
|
cfg.s3.endpoint = url;
|
|
|
|
Response::builder()
|
|
.header("content-type", "application/json")
|
|
.status(StatusCode::OK)
|
|
.body(Body::from(cfg.to_json()))
|
|
.unwrap()
|
|
}
|
|
|
|
/// Console access logging middleware
|
|
/// Logs each console access with method, URI, status code, and duration.
|
|
///
|
|
/// # Arguments:
|
|
/// - `req`: The incoming request.
|
|
/// - `next`: The next middleware or handler in the chain.
|
|
///
|
|
/// # Returns:
|
|
/// - The response from the next middleware or handler.
|
|
async fn console_logging_middleware(req: Request, next: middleware::Next) -> Response {
|
|
let method = req.method().clone();
|
|
let uri = req.uri().clone();
|
|
let start = std::time::Instant::now();
|
|
let response = next.run(req).await;
|
|
let duration = start.elapsed();
|
|
let request_id = response
|
|
.headers()
|
|
.get("x-request-id")
|
|
.and_then(|v| v.to_str().ok())
|
|
.unwrap_or("unknown")
|
|
.to_string();
|
|
|
|
info!(
|
|
target: "rustfs::console::access",
|
|
request_id = %request_id,
|
|
method = %method,
|
|
uri = %uri,
|
|
status = %response.status(),
|
|
duration_ms = %duration.as_millis(),
|
|
"Console access"
|
|
);
|
|
|
|
response
|
|
}
|
|
|
|
/// Get console configuration from environment variables
|
|
/// Returns a tuple containing console configuration values from environment variables.
|
|
///
|
|
/// # Returns:
|
|
/// - rate_limit_enable: bool indicating if rate limiting is enabled.
|
|
/// - rate_limit_rpm: u32 indicating the rate limit in requests per minute.
|
|
/// - auth_timeout: u64 indicating the authentication timeout in seconds.
|
|
/// - cors_allowed_origins: String containing allowed CORS origins.
|
|
///
|
|
fn get_console_config_from_env() -> (bool, u32, u64, String) {
|
|
let rate_limit_enable = std::env::var(rustfs_config::ENV_CONSOLE_RATE_LIMIT_ENABLE)
|
|
.unwrap_or_else(|_| rustfs_config::DEFAULT_CONSOLE_RATE_LIMIT_ENABLE.to_string())
|
|
.parse::<bool>()
|
|
.unwrap_or(rustfs_config::DEFAULT_CONSOLE_RATE_LIMIT_ENABLE);
|
|
|
|
let rate_limit_rpm = std::env::var(rustfs_config::ENV_CONSOLE_RATE_LIMIT_RPM)
|
|
.unwrap_or_else(|_| rustfs_config::DEFAULT_CONSOLE_RATE_LIMIT_RPM.to_string())
|
|
.parse::<u32>()
|
|
.unwrap_or(rustfs_config::DEFAULT_CONSOLE_RATE_LIMIT_RPM);
|
|
|
|
let auth_timeout = std::env::var(rustfs_config::ENV_CONSOLE_AUTH_TIMEOUT)
|
|
.unwrap_or_else(|_| rustfs_config::DEFAULT_CONSOLE_AUTH_TIMEOUT.to_string())
|
|
.parse::<u64>()
|
|
.unwrap_or(rustfs_config::DEFAULT_CONSOLE_AUTH_TIMEOUT);
|
|
let cors_allowed_origins = std::env::var(rustfs_config::ENV_CONSOLE_CORS_ALLOWED_ORIGINS)
|
|
.unwrap_or_else(|_| rustfs_config::DEFAULT_CONSOLE_CORS_ALLOWED_ORIGINS.to_string())
|
|
.parse::<String>()
|
|
.unwrap_or_else(|_| rustfs_config::DEFAULT_CONSOLE_CORS_ALLOWED_ORIGINS.to_string());
|
|
|
|
(rate_limit_enable, rate_limit_rpm, auth_timeout, cors_allowed_origins)
|
|
}
|
|
|
|
/// Check if the given path is for console access
|
|
///
|
|
/// # Arguments:
|
|
/// - `path`: The request path.
|
|
///
|
|
/// # Returns:
|
|
/// - `true` if the path is for console access, `false` otherwise.
|
|
pub fn is_console_path(path: &str) -> bool {
|
|
matches!(path, FAVICON_PATH | APPLE_TOUCH_ICON_PATH | APPLE_TOUCH_ICON_PRECOMPOSED_PATH)
|
|
|| has_path_prefix(path, CONSOLE_PREFIX)
|
|
}
|
|
|
|
/// Setup comprehensive middleware stack with tower-http features
|
|
///
|
|
/// # Arguments:
|
|
/// - `cors_layer`: The CORS layer to apply.
|
|
/// - `rate_limit_enable`: bool indicating if rate limiting is enabled.
|
|
/// - `rate_limit_rpm`: u32 indicating the rate limit in requests per minute.
|
|
/// - `auth_timeout`: u64 indicating the authentication timeout in seconds.
|
|
///
|
|
/// # Returns:
|
|
/// - A `Router` with the configured middleware stack.
|
|
fn setup_console_middleware_stack(
|
|
cors_layer: CorsLayer,
|
|
rate_limit_enable: bool,
|
|
rate_limit_rpm: u32,
|
|
auth_timeout: u64,
|
|
) -> Router {
|
|
let mut app = Router::new()
|
|
.route(FAVICON_PATH, get(static_handler))
|
|
.route(&format!("{CONSOLE_PREFIX}{LICENSE}"), get(license_handler))
|
|
.route(&format!("{CONSOLE_PREFIX}{VERSION}"), get(version_handler))
|
|
.nest(CONSOLE_PREFIX, Router::new().fallback_service(get(static_handler)))
|
|
.fallback_service(get(static_handler));
|
|
|
|
if rustfs_utils::get_env_bool(rustfs_config::ENV_HEALTH_ENDPOINT_ENABLE, rustfs_config::DEFAULT_HEALTH_ENDPOINT_ENABLE) {
|
|
app = app
|
|
.route(&format!("{CONSOLE_PREFIX}{HEALTH_PREFIX}"), get(health_check).head(health_check))
|
|
.route(
|
|
&format!("{CONSOLE_PREFIX}{}", crate::server::HEALTH_COMPAT_LIVE_PATH),
|
|
get(health_check).head(health_check),
|
|
)
|
|
.route(&format!("{CONSOLE_PREFIX}{HEALTH_READY_PATH}"), get(health_check).head(health_check));
|
|
} else {
|
|
// Keep disabled health probes from falling through to the SPA fallback.
|
|
app = app
|
|
.route(
|
|
&format!("{CONSOLE_PREFIX}{HEALTH_PREFIX}"),
|
|
get(health_route_disabled).head(health_route_disabled),
|
|
)
|
|
.route(
|
|
&format!("{CONSOLE_PREFIX}{}", crate::server::HEALTH_COMPAT_LIVE_PATH),
|
|
get(health_route_disabled).head(health_route_disabled),
|
|
)
|
|
.route(
|
|
&format!("{CONSOLE_PREFIX}{HEALTH_READY_PATH}"),
|
|
get(health_route_disabled).head(health_route_disabled),
|
|
);
|
|
}
|
|
|
|
// Add comprehensive middleware layers using tower-http features
|
|
app = app
|
|
.layer(CatchPanicLayer::new())
|
|
.layer(
|
|
TraceLayer::new_for_http()
|
|
.make_span_with(|request: &Request| {
|
|
let request_context = request.extensions().get::<RequestContext>();
|
|
let request_id = request_context.map(|ctx| ctx.request_id.as_str()).unwrap_or("unknown");
|
|
let trace_id = request_context.and_then(|ctx| ctx.trace_id.as_deref()).unwrap_or("unknown");
|
|
let span_id = request_context.and_then(|ctx| ctx.span_id.as_deref()).unwrap_or("unknown");
|
|
|
|
let parent_context = global::get_text_map_propagator(|propagator| {
|
|
propagator.extract(&HeaderMapCarrier::new(request.headers()))
|
|
});
|
|
|
|
let span = tracing::info_span!(
|
|
"console-request",
|
|
request_id = %request_id,
|
|
trace_id = %trace_id,
|
|
span_id = %span_id,
|
|
method = %request.method(),
|
|
uri = %request.uri(),
|
|
status_code = tracing::field::Empty,
|
|
);
|
|
|
|
if span.is_disabled() {
|
|
return span;
|
|
}
|
|
|
|
if let Err(err) = span.set_parent(parent_context) {
|
|
debug!(error = ?err, "Failed to propagate tracing context for console request");
|
|
}
|
|
|
|
span
|
|
})
|
|
.on_response(|response: &Response, _latency: Duration, span: &Span| {
|
|
span.record("status_code", tracing::field::display(response.status()));
|
|
}),
|
|
)
|
|
.layer(PropagateRequestIdLayer::x_request_id())
|
|
.layer(RequestContextLayer)
|
|
.layer(SetRequestIdLayer::x_request_id(MakeRequestUuid))
|
|
// Compress responses
|
|
.layer(CompressionLayer::new())
|
|
.layer(middleware::from_fn(console_logging_middleware))
|
|
.layer(cors_layer)
|
|
// Add timeout layer - convert auth_timeout from seconds to Duration
|
|
.layer(TimeoutLayer::with_status_code(
|
|
StatusCode::REQUEST_TIMEOUT,
|
|
Duration::from_secs(auth_timeout),
|
|
))
|
|
// Add request body limit (10MB for console uploads)
|
|
.layer(RequestBodyLimitLayer::new(5 * 1024 * 1024 * 1024));
|
|
|
|
// Per-client console rate limiting, sharing the S3 API limiter core
|
|
// (`server::rate_limit`). Added last so it is the outermost layer and
|
|
// over-limit requests are rejected before any other middleware runs.
|
|
// The client identity comes from validated request extensions only
|
|
// (trusted-proxy `ClientInfo`, then socket peer address); requests
|
|
// without one fail open rather than trusting spoofable headers.
|
|
if rate_limit_enable {
|
|
if let Some(quota) = RateLimitQuota::per_minute(rate_limit_rpm, 0) {
|
|
let limiter: Arc<RateLimiter> = Arc::new(RateLimiter::new(quota));
|
|
app = app.layer(middleware::from_fn(move |req: Request, next: middleware::Next| {
|
|
let limiter = limiter.clone();
|
|
async move {
|
|
match client_ip(&req).map(|ip| limiter.check(&ip)) {
|
|
Some(RateLimitDecision::Limited(throttle)) => {
|
|
metrics::counter!(
|
|
METRIC_HTTP_SERVER_REQUESTS_RATE_LIMITED_TOTAL,
|
|
LABEL_RATE_LIMIT_SCOPE => RATE_LIMIT_SCOPE_CONSOLE,
|
|
LABEL_RATE_LIMIT_DIMENSION => RATE_LIMIT_DIMENSION_CLIENT_IP
|
|
)
|
|
.increment(1);
|
|
debug!(retry_after_secs = throttle.retry_after_secs, "Console request rejected by rate limit");
|
|
let mut response = StatusCode::TOO_MANY_REQUESTS.into_response();
|
|
apply_throttle_headers(response.headers_mut(), limiter.quota().requests_per_minute, &throttle);
|
|
response
|
|
}
|
|
_ => next.run(req).await,
|
|
}
|
|
}
|
|
}));
|
|
info!("Console rate limiting enabled: {} requests per minute", rate_limit_rpm);
|
|
} else {
|
|
warn!("Console rate limiting enabled but RPM is 0; it stays inactive");
|
|
}
|
|
}
|
|
|
|
app
|
|
}
|
|
|
|
/// Console health check handler with comprehensive health information
|
|
///
|
|
/// # Arguments:
|
|
/// - `method`: The HTTP method of the request.
|
|
///
|
|
/// # Returns:
|
|
/// - A `Response` containing the health check result.
|
|
#[instrument]
|
|
async fn health_check(
|
|
method: Method,
|
|
uri: Uri,
|
|
server_ctx: Option<Extension<Arc<crate::runtime_sources::ServerContextSlot>>>,
|
|
) -> Response {
|
|
let probe = if uri.path().strip_prefix(CONSOLE_PREFIX) == Some(HEALTH_READY_PATH) {
|
|
HealthProbe::Readiness
|
|
} else {
|
|
HealthProbe::Liveness
|
|
};
|
|
let app_context = match server_ctx {
|
|
Some(Extension(server_ctx)) => server_ctx.installed_app_context(),
|
|
None => crate::runtime_sources::current_app_context(),
|
|
};
|
|
let object_traffic_health = app_context.map(|context| context.object_traffic_health());
|
|
let readiness_report = collect_probe_readiness(probe, object_traffic_health.as_deref()).await;
|
|
let uptime = std::time::SystemTime::now()
|
|
.duration_since(std::time::UNIX_EPOCH)
|
|
.unwrap_or_default()
|
|
.as_secs();
|
|
let response_parts =
|
|
build_health_response_parts(method.clone(), probe, readiness_report.as_ref(), "rustfs-console", Some(uptime), None);
|
|
|
|
let builder = Response::builder()
|
|
.status(response_parts.status_code)
|
|
.header("content-type", "application/json");
|
|
|
|
match method {
|
|
// GET: Returns complete JSON
|
|
Method::GET => {
|
|
let body_json = response_parts.payload.unwrap_or_else(|| {
|
|
serde_json::json!({
|
|
"status": "error",
|
|
"service": "rustfs-console",
|
|
})
|
|
});
|
|
|
|
// Return a minimal JSON when serialization fails to avoid panic
|
|
let body_str = serde_json::to_string(&body_json).unwrap_or_else(|e| {
|
|
error!(
|
|
target: "rustfs::console::health",
|
|
"failed to serialize health check body: {}",
|
|
e
|
|
);
|
|
// Simplified back-up JSON
|
|
"{\"status\":\"error\",\"service\":\"rustfs-console\"}".to_string()
|
|
});
|
|
builder.body(Body::from(body_str)).unwrap_or_else(|e| {
|
|
error!(
|
|
target: "rustfs::console::health",
|
|
"failed to build GET health response: {}",
|
|
e
|
|
);
|
|
Response::builder()
|
|
.status(StatusCode::INTERNAL_SERVER_ERROR)
|
|
.body(Body::from("failed to build response"))
|
|
.unwrap_or_else(|_| Response::new(Body::from("")))
|
|
})
|
|
}
|
|
|
|
// HEAD: Only status + headers are returned, body is empty
|
|
Method::HEAD => builder.body(Body::empty()).unwrap_or_else(|e| {
|
|
error!(
|
|
target: "rustfs::console::health",
|
|
"failed to build HEAD health response: {}",
|
|
e
|
|
);
|
|
Response::builder()
|
|
.status(StatusCode::INTERNAL_SERVER_ERROR)
|
|
.body(Body::from("failed to build response"))
|
|
.unwrap_or_else(|e| {
|
|
error!(
|
|
target: "rustfs::console::health",
|
|
"failed to build HEAD health empty response, reason: {}",
|
|
e
|
|
);
|
|
Response::new(Body::from(""))
|
|
})
|
|
}),
|
|
|
|
// Other methods: 405
|
|
_ => Response::builder()
|
|
.status(StatusCode::METHOD_NOT_ALLOWED)
|
|
.header("allow", "GET, HEAD")
|
|
.body(Body::from("Method Not Allowed"))
|
|
.unwrap_or_else(|e| {
|
|
error!(
|
|
target: "rustfs::console::health",
|
|
"failed to build 405 response: {}",
|
|
e
|
|
);
|
|
Response::new(Body::from("Method Not Allowed"))
|
|
}),
|
|
}
|
|
}
|
|
|
|
async fn health_route_disabled() -> StatusCode {
|
|
StatusCode::NOT_FOUND
|
|
}
|
|
|
|
/// Parse CORS allowed origins from configuration.
|
|
///
|
|
/// When no origins are configured (None or an empty string), the layer is
|
|
/// left without `Access-Control-Allow-Origin` so browsers treat responses
|
|
/// as same-origin only. Operators that need cross-origin access set
|
|
/// `RUSTFS_CONSOLE_CORS_ALLOWED_ORIGINS` to a comma-separated allow-list,
|
|
/// or to `*` to allow any origin.
|
|
///
|
|
/// # Arguments
|
|
/// - `origins`: An optional reference to a string containing allowed origins.
|
|
///
|
|
/// # Returns
|
|
/// - A `CorsLayer` configured with the specified origins.
|
|
pub fn parse_cors_origins(origins: Option<&String>) -> CorsLayer {
|
|
let cors_layer = CorsLayer::new()
|
|
.allow_methods([Method::GET, Method::POST, Method::PUT, Method::DELETE, Method::OPTIONS])
|
|
.allow_headers(Any);
|
|
|
|
match origins {
|
|
Some(origins_str) if origins_str.trim() == "*" => cors_layer.allow_origin(Any).expose_headers(Any),
|
|
Some(origins_str) if !origins_str.trim().is_empty() => {
|
|
let origins: Vec<&str> = origins_str.split(',').map(|s| s.trim()).filter(|s| !s.is_empty()).collect();
|
|
let mut valid_origins = Vec::new();
|
|
for origin in origins {
|
|
match origin.parse::<HeaderValue>() {
|
|
Ok(header_value) => valid_origins.push(header_value),
|
|
Err(e) => warn!("Invalid CORS origin '{}': {}", origin, e),
|
|
}
|
|
}
|
|
|
|
if valid_origins.is_empty() {
|
|
warn!("No valid CORS origins parsed from configuration; defaulting to same-origin only");
|
|
cors_layer
|
|
} else {
|
|
info!("Console CORS origins configured: {:?}", valid_origins);
|
|
cors_layer.allow_origin(AllowOrigin::list(valid_origins)).expose_headers(Any)
|
|
}
|
|
}
|
|
_ => {
|
|
debug!("No CORS origins configured for console; same-origin only");
|
|
cors_layer
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Create and configure the console server router
|
|
///
|
|
/// # Returns:
|
|
/// - A `Router` configured for the console server with middleware.
|
|
pub(crate) fn make_console_server() -> Router {
|
|
let (rate_limit_enable, rate_limit_rpm, auth_timeout, cors_allowed_origins) = get_console_config_from_env();
|
|
// String to Option<&String>
|
|
let cors_allowed_origins = if cors_allowed_origins.is_empty() {
|
|
None
|
|
} else {
|
|
Some(&cors_allowed_origins)
|
|
};
|
|
// Configure CORS based on settings
|
|
let cors_layer = parse_cors_origins(cors_allowed_origins);
|
|
|
|
// Build console router with enhanced middleware stack using tower-http features
|
|
setup_console_middleware_stack(cors_layer, rate_limit_enable, rate_limit_rpm, auth_timeout)
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use axum::body::Body;
|
|
use axum::routing::get;
|
|
use http::{Request, StatusCode};
|
|
use http_body_util::BodyExt;
|
|
use serial_test::serial;
|
|
use std::io;
|
|
use std::net::{IpAddr, Ipv4Addr};
|
|
use std::sync::{Arc, Mutex};
|
|
use temp_env::async_with_vars;
|
|
use tower::ServiceExt;
|
|
use tracing_subscriber::{Registry, layer::SubscriberExt};
|
|
|
|
#[derive(Clone, Default)]
|
|
struct SharedWriter {
|
|
inner: Arc<Mutex<Vec<u8>>>,
|
|
}
|
|
|
|
struct SharedWriterGuard {
|
|
inner: Arc<Mutex<Vec<u8>>>,
|
|
}
|
|
|
|
impl<'writer> tracing_subscriber::fmt::MakeWriter<'writer> for SharedWriter {
|
|
type Writer = SharedWriterGuard;
|
|
|
|
fn make_writer(&'writer self) -> Self::Writer {
|
|
SharedWriterGuard {
|
|
inner: Arc::clone(&self.inner),
|
|
}
|
|
}
|
|
}
|
|
|
|
impl io::Write for SharedWriterGuard {
|
|
fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
|
|
let mut inner = self.inner.lock().expect("shared writer lock should not be poisoned");
|
|
inner.extend_from_slice(buf);
|
|
Ok(buf.len())
|
|
}
|
|
|
|
fn flush(&mut self) -> io::Result<()> {
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn console_api_base_url_keeps_rustfs_admin_prefix() {
|
|
let cfg = Config::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 9001, "test", "2026-03-16T00:00:00Z");
|
|
|
|
assert!(
|
|
cfg.api.base_url.ends_with("/rustfs/admin/v3"),
|
|
"console baseURL must keep using the RustFS admin prefix"
|
|
);
|
|
assert!(
|
|
!cfg.api.base_url.ends_with("/minio/admin/v3"),
|
|
"console baseURL must not switch to the MinIO admin prefix by default"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn console_api_base_url_builder_preserves_existing_console_contract() {
|
|
assert_eq!(
|
|
build_console_api_base_url("http://127.0.0.1:9001"),
|
|
"http://127.0.0.1:9001/rustfs/admin/v3"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn console_config_exposes_admin_discovery_paths() {
|
|
let cfg = Config::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 9001, "test", "2026-03-16T00:00:00Z");
|
|
|
|
assert_eq!(cfg.api.discovery.runtime_capabilities, "/rustfs/admin/v4/runtime/capabilities");
|
|
assert_eq!(cfg.api.discovery.cluster_snapshot, "/rustfs/admin/v4/cluster/snapshot");
|
|
assert_eq!(cfg.api.discovery.extensions_catalog, "/rustfs/admin/v4/extensions/catalog");
|
|
assert_eq!(cfg.api.discovery.ilm_expiry_status, "/rustfs/admin/v3/ilm/expiry/status");
|
|
assert_eq!(cfg.api.discovery.ilm_transition_run, "/rustfs/admin/v3/ilm/transition/run");
|
|
assert_eq!(cfg.api.discovery.ilm_transition_job, "/rustfs/admin/v3/ilm/transition/jobs/{job_id}");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn console_config_handler_serializes_admin_discovery_paths() {
|
|
init_console_cfg(IpAddr::V4(Ipv4Addr::LOCALHOST), 9001);
|
|
|
|
let response = config_handler(Uri::from_static("http://127.0.0.1:9001/rustfs/console/api/v1/config"), HeaderMap::new())
|
|
.await
|
|
.into_response();
|
|
|
|
assert_eq!(response.status(), StatusCode::OK);
|
|
let body = response.into_body();
|
|
let bytes = body.collect().await.expect("collect console config body").to_bytes();
|
|
let value: serde_json::Value = serde_json::from_slice(&bytes).expect("console config JSON should deserialize");
|
|
|
|
assert_eq!(value["api"]["discovery"]["runtimeCapabilities"], "/rustfs/admin/v4/runtime/capabilities");
|
|
assert_eq!(value["api"]["discovery"]["clusterSnapshot"], "/rustfs/admin/v4/cluster/snapshot");
|
|
assert_eq!(value["api"]["discovery"]["extensionsCatalog"], "/rustfs/admin/v4/extensions/catalog");
|
|
assert_eq!(value["api"]["discovery"]["ilmExpiryStatus"], "/rustfs/admin/v3/ilm/expiry/status");
|
|
assert_eq!(value["api"]["discovery"]["ilmTransitionRun"], "/rustfs/admin/v3/ilm/transition/run");
|
|
assert_eq!(
|
|
value["api"]["discovery"]["ilmTransitionJob"],
|
|
"/rustfs/admin/v3/ilm/transition/jobs/{job_id}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn external_admin_paths_are_not_console_paths() {
|
|
assert!(is_console_path("/rustfs/console/"));
|
|
assert!(is_console_path("/apple-touch-icon.png"));
|
|
assert!(is_console_path("/apple-touch-icon-precomposed.png"));
|
|
assert!(!is_console_path("/minio/admin/v3/info"));
|
|
assert!(!is_console_path("/rustfs/admin/v3/info"));
|
|
}
|
|
|
|
// setup_console_middleware_stack reads ENV_HEALTH_ENDPOINT_ENABLE; serialise
|
|
// with other tests that override that env var to avoid cross-task leakage.
|
|
#[tokio::test]
|
|
#[serial]
|
|
async fn console_middleware_stack_propagates_request_id_header() {
|
|
let app = setup_console_middleware_stack(parse_cors_origins(None), false, 0, 30);
|
|
let request = Request::builder()
|
|
.uri(format!("{CONSOLE_PREFIX}{HEALTH_PREFIX}"))
|
|
.body(Body::empty())
|
|
.expect("failed to build request");
|
|
|
|
let response = app.oneshot(request).await.expect("request should succeed");
|
|
assert_eq!(response.status(), StatusCode::OK);
|
|
assert!(
|
|
response.headers().contains_key("x-request-id"),
|
|
"console response should include propagated x-request-id header"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
#[serial]
|
|
async fn console_readiness_uses_the_request_server_object_progress() {
|
|
temp_env::async_with_vars([(rustfs_config::ENV_HEALTH_MINIMAL_RESPONSE_ENABLE, Some("false"))], async {
|
|
let object_traffic_health =
|
|
Arc::new(crate::app::object_traffic_health::ObjectTrafficHealth::enabled_for_test(Duration::ZERO));
|
|
let stalled = object_traffic_health
|
|
.track_write_storage()
|
|
.expect("write tracking must be enabled");
|
|
let app_context =
|
|
crate::app::gating_test_env::app_context_with_object_traffic_health(Arc::clone(&object_traffic_health)).await;
|
|
let server_ctx = crate::runtime_sources::ServerContextSlot::new();
|
|
assert!(server_ctx.install(app_context));
|
|
|
|
let response = health_check(
|
|
Method::GET,
|
|
format!("{CONSOLE_PREFIX}{HEALTH_READY_PATH}")
|
|
.parse()
|
|
.expect("console readiness URI"),
|
|
Some(Extension(server_ctx)),
|
|
)
|
|
.await;
|
|
|
|
assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE);
|
|
let body = response
|
|
.into_body()
|
|
.collect()
|
|
.await
|
|
.expect("console readiness body")
|
|
.to_bytes();
|
|
let payload: serde_json::Value = serde_json::from_slice(&body).expect("console readiness JSON");
|
|
assert_eq!(payload["ready"], false);
|
|
assert_eq!(payload["degradedReasons"], serde_json::json!(["object_write_stalled"]));
|
|
drop(stalled);
|
|
})
|
|
.await;
|
|
}
|
|
|
|
// setup_console_middleware_stack reads ENV_HEALTH_ENDPOINT_ENABLE (see above).
|
|
#[tokio::test]
|
|
#[serial]
|
|
async fn console_rate_limit_rejects_over_limit_clients_with_429() {
|
|
let app = setup_console_middleware_stack(parse_cors_origins(None), true, 1, 30);
|
|
|
|
let request_for = |last_octet: u8| {
|
|
let mut request = Request::builder()
|
|
.uri(format!("{CONSOLE_PREFIX}/index.html"))
|
|
.body(Body::empty())
|
|
.expect("failed to build request");
|
|
request.extensions_mut().insert(crate::server::RemoteAddr(SocketAddr::new(
|
|
IpAddr::from([198, 51, 100, last_octet]),
|
|
50000,
|
|
)));
|
|
request
|
|
};
|
|
|
|
let first = app.clone().oneshot(request_for(7)).await.expect("request should succeed");
|
|
assert_ne!(first.status(), StatusCode::TOO_MANY_REQUESTS);
|
|
|
|
let second = app.clone().oneshot(request_for(7)).await.expect("request should succeed");
|
|
assert_eq!(second.status(), StatusCode::TOO_MANY_REQUESTS);
|
|
assert!(
|
|
second.headers().contains_key(http::header::RETRY_AFTER),
|
|
"console 429 must carry Retry-After"
|
|
);
|
|
|
|
// A different client IP has its own budget.
|
|
let other = app.oneshot(request_for(8)).await.expect("request should succeed");
|
|
assert_ne!(other.status(), StatusCode::TOO_MANY_REQUESTS);
|
|
}
|
|
|
|
#[tokio::test(flavor = "current_thread")]
|
|
async fn console_trace_layer_records_request_id_on_current_span() {
|
|
let writer = SharedWriter::default();
|
|
let subscriber = Registry::default().with(
|
|
tracing_subscriber::fmt::layer()
|
|
.without_time()
|
|
.with_target(false)
|
|
.with_level(false)
|
|
.with_ansi(false)
|
|
.json()
|
|
.flatten_event(true)
|
|
.with_current_span(true)
|
|
.with_span_list(true)
|
|
.with_writer(writer.clone()),
|
|
);
|
|
let _guard = tracing::subscriber::set_default(subscriber);
|
|
|
|
let app = Router::new()
|
|
.route(
|
|
"/trace-test",
|
|
get(|| async {
|
|
tracing::info!("console handler log");
|
|
StatusCode::OK
|
|
}),
|
|
)
|
|
.layer(
|
|
TraceLayer::new_for_http()
|
|
.make_span_with(|request: &http::Request<_>| {
|
|
let request_context = request.extensions().get::<RequestContext>();
|
|
let request_id = request_context.map(|ctx| ctx.request_id.as_str()).unwrap_or("unknown");
|
|
let trace_id = request_context.and_then(|ctx| ctx.trace_id.as_deref()).unwrap_or("unknown");
|
|
let span_id = request_context.and_then(|ctx| ctx.span_id.as_deref()).unwrap_or("unknown");
|
|
|
|
let parent_context = global::get_text_map_propagator(|propagator| {
|
|
propagator.extract(&HeaderMapCarrier::new(request.headers()))
|
|
});
|
|
|
|
let span = tracing::info_span!(
|
|
"console-request",
|
|
request_id = %request_id,
|
|
trace_id = %trace_id,
|
|
span_id = %span_id,
|
|
method = %request.method(),
|
|
uri = %request.uri(),
|
|
status_code = tracing::field::Empty,
|
|
);
|
|
|
|
if span.is_disabled() {
|
|
return span;
|
|
}
|
|
|
|
if let Err(err) = span.set_parent(parent_context) {
|
|
debug!(error = ?err, "Failed to propagate tracing context for console request");
|
|
}
|
|
|
|
span
|
|
})
|
|
.on_response(|response: &Response, _latency: Duration, span: &Span| {
|
|
span.record("status_code", tracing::field::display(response.status()));
|
|
}),
|
|
)
|
|
.layer(RequestContextLayer)
|
|
.layer(SetRequestIdLayer::x_request_id(MakeRequestUuid));
|
|
|
|
app.oneshot(
|
|
Request::builder()
|
|
.uri("/trace-test")
|
|
.body(Body::empty())
|
|
.expect("failed to build trace test request"),
|
|
)
|
|
.await
|
|
.expect("trace test request should complete");
|
|
|
|
let output = String::from_utf8(
|
|
writer
|
|
.inner
|
|
.lock()
|
|
.expect("shared writer lock should not be poisoned")
|
|
.clone(),
|
|
)
|
|
.expect("console trace log output should be valid UTF-8");
|
|
|
|
let log = output
|
|
.lines()
|
|
.map(|line| serde_json::from_str::<serde_json::Value>(line).expect("console log line should be valid JSON"))
|
|
.find(|value| value.get("message").and_then(serde_json::Value::as_str) == Some("console handler log"))
|
|
.expect("expected console handler log entry");
|
|
|
|
assert_eq!(log["span"]["method"], serde_json::Value::String("GET".to_string()));
|
|
assert_eq!(log["span"]["name"], serde_json::Value::String("console-request".to_string()));
|
|
assert!(
|
|
log.get("span")
|
|
.and_then(|span| span.get("request_id"))
|
|
.and_then(serde_json::Value::as_str)
|
|
.is_some(),
|
|
"{output}"
|
|
);
|
|
assert_ne!(
|
|
log.get("span")
|
|
.and_then(|span| span.get("request_id"))
|
|
.and_then(serde_json::Value::as_str),
|
|
Some("unknown"),
|
|
"{output}"
|
|
);
|
|
}
|
|
|
|
/// Regression: when no console CORS origins are configured (the new
|
|
/// default), the layer must NOT emit `Access-Control-Allow-Origin`, so
|
|
/// browsers treat responses as same-origin only.
|
|
#[tokio::test]
|
|
#[serial]
|
|
async fn default_console_cors_is_same_origin_only() {
|
|
let app = setup_console_middleware_stack(parse_cors_origins(None), false, 0, 30);
|
|
|
|
let request = Request::builder()
|
|
.method("OPTIONS")
|
|
.uri(format!("{CONSOLE_PREFIX}/license"))
|
|
.header("origin", "https://example.com")
|
|
.header("access-control-request-method", "GET")
|
|
.body(Body::empty())
|
|
.expect("build preflight");
|
|
|
|
let response = app.oneshot(request).await.expect("preflight should complete");
|
|
|
|
assert!(
|
|
response.headers().get("access-control-allow-origin").is_none(),
|
|
"default console CORS must not emit Access-Control-Allow-Origin"
|
|
);
|
|
assert!(
|
|
response.headers().get("access-control-allow-credentials").is_none(),
|
|
"default console CORS must not emit Access-Control-Allow-Credentials"
|
|
);
|
|
}
|
|
|
|
/// Operators that opt in to wildcard origins (via `*`) keep the previous
|
|
/// permissive behavior.
|
|
#[tokio::test]
|
|
#[serial]
|
|
async fn explicit_wildcard_console_cors_allows_any_origin() {
|
|
let star = "*".to_string();
|
|
let app = setup_console_middleware_stack(parse_cors_origins(Some(&star)), false, 0, 30);
|
|
|
|
let request = Request::builder()
|
|
.method("OPTIONS")
|
|
.uri(format!("{CONSOLE_PREFIX}/license"))
|
|
.header("origin", "https://example.com")
|
|
.header("access-control-request-method", "GET")
|
|
.body(Body::empty())
|
|
.expect("build preflight");
|
|
|
|
let response = app.oneshot(request).await.expect("preflight should complete");
|
|
|
|
assert_eq!(
|
|
response
|
|
.headers()
|
|
.get("access-control-allow-origin")
|
|
.and_then(|v| v.to_str().ok()),
|
|
Some("*"),
|
|
"explicit `*` origin must produce wildcard Allow-Origin"
|
|
);
|
|
}
|
|
|
|
/// Whitespace-padded wildcard ("` * `") must still be treated as wildcard
|
|
/// rather than falling into the comma-separated parser. Common when the
|
|
/// origin string is templated through env vars.
|
|
#[tokio::test]
|
|
#[serial]
|
|
async fn whitespace_padded_wildcard_console_cors_allows_any_origin() {
|
|
let star = " * ".to_string();
|
|
let app = setup_console_middleware_stack(parse_cors_origins(Some(&star)), false, 0, 30);
|
|
|
|
let request = Request::builder()
|
|
.method("OPTIONS")
|
|
.uri(format!("{CONSOLE_PREFIX}/license"))
|
|
.header("origin", "https://example.com")
|
|
.header("access-control-request-method", "GET")
|
|
.body(Body::empty())
|
|
.expect("build preflight");
|
|
|
|
let response = app.oneshot(request).await.expect("preflight should complete");
|
|
|
|
assert_eq!(
|
|
response
|
|
.headers()
|
|
.get("access-control-allow-origin")
|
|
.and_then(|v| v.to_str().ok()),
|
|
Some("*"),
|
|
"whitespace-padded `*` origin must produce wildcard Allow-Origin"
|
|
);
|
|
}
|
|
|
|
// Mutates the global ENV_HEALTH_ENDPOINT_ENABLE env var; serialise to
|
|
// avoid leaking the override into other async tests in the same module.
|
|
#[tokio::test]
|
|
#[serial]
|
|
async fn console_middleware_stack_hides_health_routes_when_disabled() {
|
|
async_with_vars([(rustfs_config::ENV_HEALTH_ENDPOINT_ENABLE, Some("false"))], async {
|
|
let app = setup_console_middleware_stack(parse_cors_origins(None), false, 0, 30);
|
|
|
|
let health_response = app
|
|
.clone()
|
|
.oneshot(
|
|
Request::builder()
|
|
.uri(format!("{CONSOLE_PREFIX}{HEALTH_PREFIX}"))
|
|
.body(Body::empty())
|
|
.expect("failed to build health request"),
|
|
)
|
|
.await
|
|
.expect("health request should complete");
|
|
assert_eq!(health_response.status(), StatusCode::NOT_FOUND);
|
|
|
|
let readiness_response = app
|
|
.oneshot(
|
|
Request::builder()
|
|
.uri(format!("{CONSOLE_PREFIX}{HEALTH_READY_PATH}"))
|
|
.body(Body::empty())
|
|
.expect("failed to build readiness request"),
|
|
)
|
|
.await
|
|
.expect("readiness request should complete");
|
|
assert_eq!(readiness_response.status(), StatusCode::NOT_FOUND);
|
|
})
|
|
.await;
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn console_license_route_returns_public_status_only() {
|
|
let app = setup_console_middleware_stack(parse_cors_origins(None), false, 0, 30);
|
|
let request = Request::builder()
|
|
.uri(format!("{CONSOLE_PREFIX}{LICENSE}"))
|
|
.body(Body::empty())
|
|
.expect("failed to build license request");
|
|
|
|
let response = app.oneshot(request).await.expect("license request should complete");
|
|
assert_eq!(response.status(), StatusCode::OK);
|
|
|
|
let body = response
|
|
.into_body()
|
|
.collect()
|
|
.await
|
|
.expect("license body should collect")
|
|
.to_bytes();
|
|
let value: serde_json::Value = serde_json::from_slice(&body).expect("license response should be valid JSON");
|
|
|
|
assert_eq!(value, serde_json::json!({ "licensed": false }));
|
|
assert!(value.get("name").is_none());
|
|
assert!(value.get("expired").is_none());
|
|
}
|
|
}
|