From ceffe21f7528c19e3b5c1aad9fb60c390ac225a1 Mon Sep 17 00:00:00 2001 From: houseme Date: Wed, 27 May 2026 15:23:58 +0800 Subject: [PATCH] feat(tls): add inspect command for TLS layouts (#3092) --- crates/tls-runtime/src/certs.rs | 178 ++++++++++++++++++++++++++++++- crates/tls-runtime/src/lib.rs | 3 +- rustfs/src/config/cli.rs | 31 +++++- rustfs/src/config/config_test.rs | 16 ++- rustfs/src/config/mod.rs | 1 + rustfs/src/config/opt.rs | 6 +- rustfs/src/lib.rs | 1 + rustfs/src/main.rs | 7 +- rustfs/src/tls.rs | 135 +++++++++++++++++++++++ 9 files changed, 371 insertions(+), 7 deletions(-) create mode 100644 rustfs/src/tls.rs diff --git a/crates/tls-runtime/src/certs.rs b/crates/tls-runtime/src/certs.rs index 994cde161..20260768f 100644 --- a/crates/tls-runtime/src/certs.rs +++ b/crates/tls-runtime/src/certs.rs @@ -20,7 +20,7 @@ use rustls::sign::CertifiedKey; use rustls_pki_types::{CertificateDer, PrivateKeyDer, pem::PemObject}; use std::collections::HashMap; use std::io::Error; -use std::path::PathBuf; +use std::path::{Path, PathBuf}; use std::sync::Arc; use std::{fs, io}; use tracing::{debug, warn}; @@ -224,10 +224,118 @@ pub fn certs_error(err: String) -> Error { Error::other(err) } +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum TlsCertPairStatus { + MissingBoth, + MissingCert, + MissingKey, + Valid, + Invalid { error: String }, +} + +impl TlsCertPairStatus { + pub fn is_valid(&self) -> bool { + matches!(self, Self::Valid) + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct TlsCertPairInspection { + pub cert_path: PathBuf, + pub key_path: PathBuf, + pub status: TlsCertPairStatus, +} + +impl TlsCertPairInspection { + pub fn is_valid(&self) -> bool { + self.status.is_valid() + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct TlsDomainInspection { + pub domain_name: String, + pub pair: TlsCertPairInspection, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct TlsDirectoryInspection { + pub directory: PathBuf, + pub canonical_directory: Option, + pub root_pair: TlsCertPairInspection, + pub domain_pairs: Vec, + pub skipped_directory_names: Vec, +} + +impl TlsDirectoryInspection { + pub fn valid_domain_names(&self) -> Vec<&str> { + self.domain_pairs + .iter() + .filter(|entry| entry.pair.is_valid()) + .map(|entry| entry.domain_name.as_str()) + .collect() + } + + pub fn has_valid_root_pair(&self) -> bool { + self.root_pair.is_valid() + } +} + fn is_discoverable_cert_domain_dir(domain_name: &str) -> bool { !domain_name.starts_with('.') } +pub fn inspect_cert_directory(options: CertDirectoryLoadOptions) -> io::Result { + options.validate()?; + + let dir = options.dir_path.as_path(); + if !dir.exists() || !dir.is_dir() { + return Err(certs_error(format!( + "The certificate directory does not exist or is not a directory: {}", + dir.display() + ))); + } + + let root_pair = inspect_cert_key_pair(dir, &options.cert_filename, &options.key_filename); + let canonical_directory = fs::canonicalize(dir).ok(); + let mut domain_pairs = Vec::new(); + let mut skipped_directory_names = Vec::new(); + + for entry in fs::read_dir(dir)? { + let entry = entry?; + let path = entry.path(); + + if !path.is_dir() { + continue; + } + + let domain_name = path + .file_name() + .and_then(|name| name.to_str()) + .ok_or_else(|| certs_error(format!("invalid domain name directory:{path:?}")))?; + if !is_discoverable_cert_domain_dir(domain_name) { + skipped_directory_names.push(domain_name.to_string()); + continue; + } + + domain_pairs.push(TlsDomainInspection { + domain_name: domain_name.to_string(), + pair: inspect_cert_key_pair(&path, &options.cert_filename, &options.key_filename), + }); + } + + domain_pairs.sort_by(|left, right| left.domain_name.cmp(&right.domain_name)); + skipped_directory_names.sort(); + + Ok(TlsDirectoryInspection { + directory: dir.to_path_buf(), + canonical_directory, + root_pair, + domain_pairs, + skipped_directory_names, + }) +} + pub fn load_all_certs_from_directory( options: CertDirectoryLoadOptions, ) -> io::Result>, PrivateKeyDer<'static>)>> { @@ -327,6 +435,42 @@ fn load_cert_key_pair(cert_path: &str, key_path: &str) -> io::Result<(Vec TlsCertPairInspection { + let cert_path = dir.join(cert_filename); + let key_path = dir.join(key_filename); + let cert_exists = cert_path.exists(); + let key_exists = key_path.exists(); + + let status = match (cert_exists, key_exists) { + (false, false) => TlsCertPairStatus::MissingBoth, + (false, true) => TlsCertPairStatus::MissingCert, + (true, false) => TlsCertPairStatus::MissingKey, + (true, true) => match cert_key_pair_utf8_paths(&cert_path, &key_path) { + Ok((cert_path, key_path)) => match load_cert_key_pair(cert_path, key_path) { + Ok(_) => TlsCertPairStatus::Valid, + Err(err) => TlsCertPairStatus::Invalid { error: err.to_string() }, + }, + Err(err) => TlsCertPairStatus::Invalid { error: err.to_string() }, + }, + }; + + TlsCertPairInspection { + cert_path, + key_path, + status, + } +} + +fn cert_key_pair_utf8_paths<'a>(cert_path: &'a Path, key_path: &'a Path) -> io::Result<(&'a str, &'a str)> { + let cert_path = cert_path + .to_str() + .ok_or_else(|| certs_error(format!("Invalid UTF-8 in certificate path: {cert_path:?}")))?; + let key_path = key_path + .to_str() + .ok_or_else(|| certs_error(format!("Invalid UTF-8 in key path: {key_path:?}")))?; + Ok((cert_path, key_path)) +} + pub fn create_multi_cert_resolver( cert_key_pairs: HashMap>, PrivateKeyDer<'static>)>, ) -> io::Result { @@ -448,4 +592,36 @@ mod tests { assert!(!certs.contains_key("..data")); assert_eq!(certs.len(), 2); } + + #[test] + fn test_inspect_cert_directory_reports_valid_root_and_domain_pairs() { + let temp_dir = TempDir::new().expect("tempdir should create"); + write_test_cert_pair(temp_dir.path()); + + let domain_dir = temp_dir.path().join("example.com"); + fs::create_dir(&domain_dir).expect("domain dir should create"); + write_test_cert_pair(&domain_dir); + + let inspection = inspect_cert_directory(default_load_options(temp_dir.path())).expect("inspection should succeed"); + assert!(inspection.has_valid_root_pair()); + assert_eq!(inspection.valid_domain_names(), vec!["example.com"]); + assert_eq!(inspection.domain_pairs.len(), 1); + assert!(inspection.domain_pairs[0].pair.is_valid()); + } + + #[test] + fn test_inspect_cert_directory_reports_invalid_and_missing_pairs() { + let temp_dir = TempDir::new().expect("tempdir should create"); + fs::write(temp_dir.path().join("rustfs_cert.pem"), "invalid certificate").expect("invalid cert should write"); + fs::write(temp_dir.path().join("rustfs_key.pem"), "invalid key").expect("invalid key should write"); + + let domain_dir = temp_dir.path().join("broken.example.com"); + fs::create_dir(&domain_dir).expect("domain dir should create"); + fs::write(domain_dir.join("rustfs_cert.pem"), "invalid certificate").expect("invalid cert should write"); + + let inspection = inspect_cert_directory(default_load_options(temp_dir.path())).expect("inspection should succeed"); + assert!(matches!(inspection.root_pair.status, TlsCertPairStatus::Invalid { .. })); + assert_eq!(inspection.domain_pairs.len(), 1); + assert_eq!(inspection.domain_pairs[0].pair.status, TlsCertPairStatus::MissingKey); + } } diff --git a/crates/tls-runtime/src/lib.rs b/crates/tls-runtime/src/lib.rs index dea4daf74..1d5bcf232 100644 --- a/crates/tls-runtime/src/lib.rs +++ b/crates/tls-runtime/src/lib.rs @@ -26,7 +26,8 @@ pub mod source; pub mod state; pub use certs::{ - CertDirectoryLoadOptions, WebPkiClientVerifierOptions, build_webpki_client_verifier, create_multi_cert_resolver, + CertDirectoryLoadOptions, TlsCertPairInspection, TlsCertPairStatus, TlsDirectoryInspection, TlsDomainInspection, + WebPkiClientVerifierOptions, build_webpki_client_verifier, create_multi_cert_resolver, inspect_cert_directory, load_all_certs_from_directory, load_cert_bundle_der_bytes, load_certs, load_private_key, }; pub use config::{ReloadApplyHint, ReloadDetectMode, TlsReloadOptions}; diff --git a/rustfs/src/config/cli.rs b/rustfs/src/config/cli.rs index 7720a91a3..4a2a60b9b 100644 --- a/rustfs/src/config/cli.rs +++ b/rustfs/src/config/cli.rs @@ -16,9 +16,10 @@ //! //! This module contains the command-line interface definitions including: //! - `Cli`: Main CLI parser -//! - `Commands`: Subcommands (Server, Info) +//! - `Commands`: Subcommands (Server, Info, Tls) //! - `ServerOpts`: Server subcommand options //! - `InfoOpts`: Info subcommand options +//! - `TlsOpts`: TLS diagnostic subcommand options //! - `InfoType`: Information type enum //! - `CommandResult`: Result of parsing command line arguments @@ -55,7 +56,7 @@ pub(super) const LONG_VERSION: &str = concat!( ); /// Known subcommands. When the first arg matches one of these, it is treated as a subcommand. -pub const KNOWN_SUBCOMMANDS: &[&str] = &["server", "info"]; +pub const KNOWN_SUBCOMMANDS: &[&str] = &["server", "info", "tls"]; /// Preprocess argv for legacy compatibility: `rustfs ` and `rustfs --address ...` are /// treated as `rustfs server ` and `rustfs server --address ...` respectively. @@ -102,6 +103,8 @@ pub enum Commands { Server(Box), /// Display system information Info(InfoOpts), + /// Inspect TLS certificate directory layout and parsing status + Tls(TlsOpts), } /// Information type to display @@ -135,6 +138,28 @@ pub struct InfoOpts { pub info_type: Option, } +/// TLS diagnostic subcommand options +#[derive(Args, Clone)] +pub struct TlsOpts { + #[command(subcommand)] + pub command: TlsCommands, +} + +/// TLS diagnostic subcommands +#[derive(Subcommand, Clone)] +pub enum TlsCommands { + /// Inspect a TLS certificate directory + Inspect(TlsInspectOpts), +} + +/// TLS inspect options +#[derive(Args, Clone)] +pub struct TlsInspectOpts { + /// TLS directory to inspect + #[arg(long, value_parser = NonEmptyStringValueParser::new())] + pub path: String, +} + /// Server subcommand options #[derive(Args, Clone)] pub struct ServerOpts { @@ -263,6 +288,8 @@ pub enum CommandResult { Server(Box), /// Info command with options Info(InfoOpts), + /// TLS command with options + Tls(TlsOpts), } /// Create default ServerOpts from environment variables diff --git a/rustfs/src/config/config_test.rs b/rustfs/src/config/config_test.rs index 4e13d5d90..ae1de2078 100644 --- a/rustfs/src/config/config_test.rs +++ b/rustfs/src/config/config_test.rs @@ -15,7 +15,7 @@ #[cfg(test)] #[allow(unsafe_op_in_unsafe_fn)] mod tests { - use crate::config::{Config, Opt}; + use crate::config::{CommandResult, Config, Opt, TlsCommands}; use rustfs_config::{DEFAULT_CONSOLE_ADDRESS, DEFAULT_CONSOLE_ENABLE, DEFAULT_OBS_ENDPOINT, RUSTFS_REGION}; use rustfs_credentials::{DEFAULT_ACCESS_KEY, DEFAULT_SECRET_KEY}; use rustfs_ecstore::disks_layout::DisksLayout; @@ -72,6 +72,20 @@ mod tests { assert_eq!(opt_legacy.console_address, opt_server.console_address); } + #[test] + #[serial] + fn test_tls_inspect_subcommand_parses() { + let result = + Opt::parse_command(["rustfs", "tls", "inspect", "--path", "/tmp/certs"]).expect("tls inspect command should parse"); + + match result { + CommandResult::Tls(opts) => match opts.command { + TlsCommands::Inspect(inspect) => assert_eq!(inspect.path, "/tmp/certs"), + }, + _ => panic!("expected TLS command result"), + } + } + #[test] #[serial] fn test_default_console_configuration() { diff --git a/rustfs/src/config/mod.rs b/rustfs/src/config/mod.rs index d51d920fb..60bc70853 100644 --- a/rustfs/src/config/mod.rs +++ b/rustfs/src/config/mod.rs @@ -51,6 +51,7 @@ mod config_test; // Re-export public types pub use cli::{CommandResult, InfoOpts, InfoType}; +pub use cli::{TlsCommands, TlsInspectOpts, TlsOpts}; pub use config_struct::Config; pub use info::execute_info; pub use opt::Opt; diff --git a/rustfs/src/config/opt.rs b/rustfs/src/config/opt.rs index c3d50ca2e..ce973d921 100644 --- a/rustfs/src/config/opt.rs +++ b/rustfs/src/config/opt.rs @@ -98,6 +98,9 @@ impl Opt { // This should not happen in parse_from, as it's handled by parse_command panic!("Info command should be handled by parse_command"); } + Some(Commands::Tls(_)) => { + panic!("TLS command should be handled by parse_command"); + } None => { // Default to server with empty volumes (will be filled from env) Self::from_server_opts(default_server_opts()) @@ -129,6 +132,7 @@ impl Opt { }; match cli.command { Some(Commands::Info(opts)) => Ok(CommandResult::Info(opts)), + Some(Commands::Tls(opts)) => Ok(CommandResult::Tls(opts)), Some(Commands::Server(opts)) => Self::server_command_result(Self::from_server_opts(*opts)), None => { // Default to server with empty volumes (will be filled from env) @@ -157,7 +161,7 @@ impl Opt { let cli = Cli::try_parse_from(args)?; match cli.command { Some(Commands::Server(opts)) => Ok(Self::from_server_opts(*opts)), - Some(Commands::Info(_)) => Err(clap::Error::new(clap::error::ErrorKind::DisplayHelp)), + Some(Commands::Info(_)) | Some(Commands::Tls(_)) => Err(clap::Error::new(clap::error::ErrorKind::DisplayHelp)), None => { // Default to server with empty volumes Ok(Self::from_server_opts(default_server_opts())) diff --git a/rustfs/src/lib.rs b/rustfs/src/lib.rs index 2b186d907..050eacf19 100644 --- a/rustfs/src/lib.rs +++ b/rustfs/src/lib.rs @@ -69,6 +69,7 @@ pub mod protocols; pub mod server; pub mod startup_fs_guard; pub mod storage; +pub mod tls; pub mod update; pub mod version; diff --git a/rustfs/src/main.rs b/rustfs/src/main.rs index d7b2f83d1..deccc5af1 100644 --- a/rustfs/src/main.rs +++ b/rustfs/src/main.rs @@ -159,10 +159,15 @@ async fn async_main() -> Result<()> { return Ok(()); } + if let rustfs::config::CommandResult::Tls(opts) = &command_result { + rustfs::tls::execute_tls(opts)?; + return Ok(()); + } + // Get config for server command let config = match command_result { rustfs::config::CommandResult::Server(cfg) => cfg, - rustfs::config::CommandResult::Info(_) => unreachable!(), + rustfs::config::CommandResult::Info(_) | rustfs::config::CommandResult::Tls(_) => unreachable!(), }; // Initialize the global config snapshot for info command diff --git a/rustfs/src/tls.rs b/rustfs/src/tls.rs new file mode 100644 index 000000000..a2bc1a49b --- /dev/null +++ b/rustfs/src/tls.rs @@ -0,0 +1,135 @@ +// 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::config::{TlsCommands, TlsInspectOpts, TlsOpts}; +use rustfs_config::{RUSTFS_TLS_CERT, RUSTFS_TLS_KEY}; +use rustfs_tls_runtime::{CertDirectoryLoadOptions, TlsCertPairInspection, TlsCertPairStatus, TlsSource, inspect_cert_directory}; +use std::io::{Error, Result}; + +pub fn execute_tls(opts: &TlsOpts) -> Result<()> { + match &opts.command { + TlsCommands::Inspect(inspect) => execute_tls_inspect(inspect), + } +} + +fn execute_tls_inspect(opts: &TlsInspectOpts) -> Result<()> { + let tls_source = TlsSource::from_directory(opts.path.trim()); + let tls_dir = tls_source.validate_directory().map_err(Error::other)?; + let inspection = inspect_cert_directory(CertDirectoryLoadOptions::builder(tls_dir, RUSTFS_TLS_CERT, RUSTFS_TLS_KEY).build())?; + + println!("TLS directory inspection"); + println!("path: {}", inspection.directory.display()); + if let Some(canonical_directory) = &inspection.canonical_directory { + println!("canonical path: {}", canonical_directory.display()); + } + println!("root pair: {}", format_pair_summary(&inspection.root_pair)); + + let valid_domains = inspection.valid_domain_names(); + let runtime_mode = if !valid_domains.is_empty() { + "multi-cert/SNI" + } else if inspection.has_valid_root_pair() { + "single-cert/default" + } else { + "no-usable-server-cert" + }; + println!("runtime mode: {}", runtime_mode); + println!("valid domain pairs: {}", valid_domains.len()); + if !valid_domains.is_empty() { + println!("valid domains: {}", valid_domains.join(", ")); + } + + if !inspection.skipped_directory_names.is_empty() { + println!("skipped internal directories: {}", inspection.skipped_directory_names.join(", ")); + } + + if inspection.domain_pairs.is_empty() { + println!("domain pairs: none"); + } else { + println!("domain pairs:"); + for domain in &inspection.domain_pairs { + println!(" - {}: {}", domain.domain_name, format_pair_summary(&domain.pair)); + } + } + + Ok(()) +} + +fn format_pair_summary(pair: &TlsCertPairInspection) -> String { + match &pair.status { + TlsCertPairStatus::MissingBoth => format!( + "missing cert/key (expected cert={}, key={})", + pair.cert_path.display(), + pair.key_path.display() + ), + TlsCertPairStatus::MissingCert => format!( + "missing cert (expected cert={}, key={})", + pair.cert_path.display(), + pair.key_path.display() + ), + TlsCertPairStatus::MissingKey => format!( + "missing key (expected cert={}, key={})", + pair.cert_path.display(), + pair.key_path.display() + ), + TlsCertPairStatus::Valid => { + format!("valid cert/key pair (cert={}, key={})", pair.cert_path.display(), pair.key_path.display()) + } + TlsCertPairStatus::Invalid { error } => format!( + "invalid cert/key pair (cert={}, key={}): {}", + pair.cert_path.display(), + pair.key_path.display(), + error + ), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + use tempfile::TempDir; + + fn write_test_cert_pair(dir: &std::path::Path) { + let rcgen::CertifiedKey { cert, signing_key } = + rcgen::generate_simple_self_signed(vec!["example.com".to_string()]).expect("cert should generate"); + fs::write(dir.join(RUSTFS_TLS_CERT), cert.pem()).expect("cert should write"); + fs::write(dir.join(RUSTFS_TLS_KEY), signing_key.serialize_pem()).expect("key should write"); + } + + #[test] + fn execute_tls_inspect_accepts_valid_directory() { + let temp_dir = TempDir::new().expect("tempdir should create"); + write_test_cert_pair(temp_dir.path()); + + let opts = TlsInspectOpts { + path: temp_dir.path().display().to_string(), + }; + + execute_tls_inspect(&opts).expect("tls inspect should succeed"); + } + + #[test] + fn format_pair_summary_reports_missing_key() { + let pair = TlsCertPairInspection { + cert_path: "/tmp/rustfs_cert.pem".into(), + key_path: "/tmp/rustfs_key.pem".into(), + status: TlsCertPairStatus::MissingKey, + }; + + let summary = format_pair_summary(&pair); + assert!(summary.contains("missing key")); + assert!(summary.contains("/tmp/rustfs_cert.pem")); + assert!(summary.contains("/tmp/rustfs_key.pem")); + } +}