diff --git a/README.md b/README.md index 3ab0604..93aadb1 100644 --- a/README.md +++ b/README.md @@ -216,6 +216,20 @@ rc replicate add local/my-bucket \ --priority 1 \ --replicate delete,delete-marker,existing-objects +# Allow self-signed or otherwise untrusted target certificates for this +# replication target only. +rc replicate add local/my-bucket \ + --remote-bucket remote/target-bucket \ + --replicate delete,delete-marker,existing-objects \ + --insecure + +# Upload a local PEM CA bundle so RustFS can trust a private CA when it +# connects to the remote replication target. +rc replicate add local/my-bucket \ + --remote-bucket remote/target-bucket \ + --replicate delete,delete-marker,existing-objects \ + --ca-cert ./private-ca.pem + # List replication rules rc replicate list local/my-bucket diff --git a/crates/cli/src/commands/mod.rs b/crates/cli/src/commands/mod.rs index 04ef2a9..7ad2676 100644 --- a/crates/cli/src/commands/mod.rs +++ b/crates/cli/src/commands/mod.rs @@ -818,6 +818,35 @@ mod tests { } } + #[test] + fn cli_accepts_bucket_replication_add_tls_flags() { + let cli = Cli::try_parse_from([ + "rc", + "bucket", + "replication", + "add", + "local/my-bucket", + "--remote-bucket", + "backup/archive", + "--insecure", + ]) + .expect("parse bucket replication add with insecure"); + + match cli.command { + Commands::Bucket(args) => match args.command { + bucket::BucketCommands::Replication(replicate::ReplicateArgs { + command: replicate::ReplicateCommands::Add(arg), + }) => { + assert_eq!(arg.path, "local/my-bucket"); + assert_eq!(arg.remote_bucket, "backup/archive"); + assert!(arg.insecure); + } + other => panic!("expected bucket replication add command, got {:?}", other), + }, + other => panic!("expected bucket command, got {:?}", other), + } + } + #[test] fn cli_accepts_bucket_remove_subcommand() { let cli = Cli::try_parse_from(["rc", "bucket", "remove", "local/my-bucket"]) diff --git a/crates/cli/src/commands/replicate.rs b/crates/cli/src/commands/replicate.rs index b0c80e2..98ac215 100644 --- a/crates/cli/src/commands/replicate.rs +++ b/crates/cli/src/commands/replicate.rs @@ -14,6 +14,7 @@ use rc_core::{AliasManager, ObjectStore as _}; use rc_s3::{AdminClient, S3Client}; use serde::{Deserialize, Serialize}; use std::collections::{BTreeSet, HashMap}; +use std::path::{Path, PathBuf}; use crate::exit_code::ExitCode; use crate::output::{Formatter, OutputConfig}; @@ -30,7 +31,12 @@ const REPLICATE_ADD_AFTER_HELP: &str = "\ Examples: rc bucket replication add local/my-bucket --remote-bucket backup/archive rc replicate add local/my-bucket --remote-bucket backup/archive --prefix reports/ - rc bucket replication add local/my-bucket --remote-bucket backup/archive --replicate delete,existing-objects --sync"; + rc bucket replication add local/my-bucket --remote-bucket backup/archive --replicate delete,existing-objects --sync + rc bucket replication add local/my-bucket --remote-bucket backup/archive --insecure + rc bucket replication add local/my-bucket --remote-bucket backup/archive --ca-cert ./private-ca.pem"; + +const CA_CERT_LOCAL_PATH_SUGGESTION: &str = + "--ca-cert is a local CLI path; the certificate content will be uploaded to RustFS"; /// Manage bucket replication #[derive(Args, Debug)] @@ -120,6 +126,17 @@ pub struct AddArgs { #[arg(long)] pub disable_proxy: bool, + /// Skip TLS certificate verification for this bucket replication target. + /// Intended for development or test environments. + #[arg(long)] + pub insecure: bool, + + /// Read a local PEM CA certificate file and upload its content for this + /// bucket replication target. The path is resolved on the CLI machine, + /// not on the RustFS server. + #[arg(long, value_name = "FILE")] + pub ca_cert: Option, + /// Force operation even if capability detection fails #[arg(long)] pub force: bool, @@ -166,6 +183,17 @@ pub struct UpdateArgs { #[arg(long)] pub disable_proxy: Option, + /// Skip TLS certificate verification for this bucket replication target. + /// Intended for development or test environments. + #[arg(long)] + pub insecure: bool, + + /// Read a local PEM CA certificate file and upload its content for this + /// bucket replication target. The path is resolved on the CLI machine, + /// not on the RustFS server. + #[arg(long, value_name = "FILE")] + pub ca_cert: Option, + /// Enable or disable the rule #[arg(long)] pub status: Option, @@ -232,6 +260,12 @@ struct ReplicationExport { remote_targets: Vec, } +#[derive(Debug, Clone, Default, PartialEq, Eq)] +struct ReplicationTargetTlsSettings { + skip_tls_verify: Option, + ca_cert_pem: Option, +} + // ==================== execute ==================== /// Execute the replicate command @@ -251,6 +285,18 @@ pub async fn execute(args: ReplicateArgs, output_config: OutputConfig) -> ExitCo async fn execute_add(args: AddArgs, output_config: OutputConfig) -> ExitCode { let formatter = Formatter::new(output_config); + let tls_settings = + match build_replication_target_tls_settings(args.insecure, args.ca_cert.as_deref()) { + Ok(settings) => settings, + Err(error) => { + let suggestion = if args.ca_cert.is_some() { + CA_CERT_LOCAL_PATH_SUGGESTION + } else { + "Retry the command with either --insecure or --ca-cert, but not both." + }; + return formatter.fail_with_suggestion(ExitCode::UsageError, &error, suggestion); + } + }; let (source_alias, source_bucket) = match parse_bucket_path(&args.path) { Ok(parts) => parts, @@ -302,7 +348,7 @@ async fn execute_add(args: AddArgs, output_config: OutputConfig) -> ExitCode { .unwrap_or_else(|| DEFAULT_REPLICATION_STORAGE_CLASS.to_string()); // Build BucketTarget - let target = BucketTarget { + let mut target = BucketTarget { source_bucket: source_bucket.clone(), endpoint: target_endpoint, credentials: Some(BucketTargetCredentials { @@ -322,6 +368,7 @@ async fn execute_add(args: AddArgs, output_config: OutputConfig) -> ExitCode { disable_proxy: args.disable_proxy, ..Default::default() }; + apply_replication_target_tls_settings(&mut target, &tls_settings); // Register remote target via admin API → get ARN let arn = match admin_client @@ -413,6 +460,18 @@ async fn execute_add(args: AddArgs, output_config: OutputConfig) -> ExitCode { async fn execute_update(args: UpdateArgs, output_config: OutputConfig) -> ExitCode { let formatter = Formatter::new(output_config); + let tls_settings = + match build_replication_target_tls_settings(args.insecure, args.ca_cert.as_deref()) { + Ok(settings) => settings, + Err(error) => { + let suggestion = if args.ca_cert.is_some() { + CA_CERT_LOCAL_PATH_SUGGESTION + } else { + "Retry the command with either --insecure or --ca-cert, but not both." + }; + return formatter.fail_with_suggestion(ExitCode::UsageError, &error, suggestion); + } + }; let (source_alias, source_bucket) = match parse_bucket_path(&args.path) { Ok(parts) => parts, @@ -484,7 +543,7 @@ async fn execute_update(args: UpdateArgs, output_config: OutputConfig) -> ExitCo } }; - apply_target_updates(&mut target, &args); + apply_target_updates(&mut target, &args, &tls_settings); let updated_arn = match admin_client .set_remote_target(&source_bucket, target, true) @@ -1164,9 +1223,15 @@ fn target_level_updates_requested(args: &UpdateArgs) -> bool { || args.sync.is_some() || args.healthcheck_seconds.is_some() || args.disable_proxy.is_some() + || args.insecure + || args.ca_cert.is_some() } -fn apply_target_updates(target: &mut BucketTarget, args: &UpdateArgs) { +fn apply_target_updates( + target: &mut BucketTarget, + args: &UpdateArgs, + tls_settings: &ReplicationTargetTlsSettings, +) { if let Some(storage_class) = &args.storage_class { target.storage_class = storage_class.clone(); } @@ -1182,6 +1247,9 @@ fn apply_target_updates(target: &mut BucketTarget, args: &UpdateArgs) { if let Some(disable_proxy) = args.disable_proxy { target.disable_proxy = disable_proxy; } + if args.insecure || args.ca_cert.is_some() { + apply_replication_target_tls_settings(target, tls_settings); + } } fn remap_replication_arns( @@ -1243,6 +1311,55 @@ fn format_replication_flags(rule: &ReplicationRule) -> String { } } +fn build_replication_target_tls_settings( + insecure: bool, + ca_cert: Option<&Path>, +) -> Result { + if insecure && ca_cert.is_some() { + return Err("--insecure and --ca-cert cannot be used together".to_string()); + } + + if insecure { + return Ok(ReplicationTargetTlsSettings { + skip_tls_verify: Some(true), + ca_cert_pem: None, + }); + } + + let Some(path) = ca_cert else { + return Ok(ReplicationTargetTlsSettings::default()); + }; + + let pem = std::fs::read_to_string(path) + .map_err(|_| "--ca-cert must point to a readable local PEM certificate file".to_string())?; + + if pem.trim().is_empty() { + return Err("--ca-cert file is empty".to_string()); + } + + if !looks_like_pem_certificate(&pem) { + return Err("--ca-cert must point to a readable local PEM certificate file".to_string()); + } + + Ok(ReplicationTargetTlsSettings { + skip_tls_verify: Some(false), + ca_cert_pem: Some(pem), + }) +} + +fn apply_replication_target_tls_settings( + target: &mut BucketTarget, + tls_settings: &ReplicationTargetTlsSettings, +) { + target.skip_tls_verify = tls_settings.skip_tls_verify; + target.ca_cert_pem = tls_settings.ca_cert_pem.clone(); +} + +fn looks_like_pem_certificate(value: &str) -> bool { + let trimmed = value.trim(); + trimmed.contains("-----BEGIN CERTIFICATE-----") && trimmed.contains("-----END CERTIFICATE-----") +} + fn remote_target_endpoint(endpoint: &str, insecure: bool) -> (String, bool) { let trimmed = endpoint.trim().trim_end_matches('/'); @@ -1265,6 +1382,7 @@ fn strip_endpoint_path(endpoint: &str) -> String { mod tests { use super::*; use std::collections::HashMap; + use tempfile::NamedTempFile; #[test] fn test_parse_bucket_path_success() { @@ -1322,6 +1440,67 @@ mod tests { assert_eq!(default_replication_role(arn), arn); } + #[test] + fn test_build_replication_target_tls_settings_accepts_insecure() { + let settings = build_replication_target_tls_settings(true, None).expect("tls settings"); + assert_eq!( + settings, + ReplicationTargetTlsSettings { + skip_tls_verify: Some(true), + ca_cert_pem: None, + } + ); + } + + #[test] + fn test_build_replication_target_tls_settings_rejects_mutually_exclusive_flags() { + let cert = NamedTempFile::new().expect("temp cert"); + let error = build_replication_target_tls_settings(true, Some(cert.path())).unwrap_err(); + assert_eq!(error, "--insecure and --ca-cert cannot be used together"); + } + + #[test] + fn test_build_replication_target_tls_settings_rejects_missing_file() { + let missing = std::env::temp_dir().join("replication-missing-ca.pem"); + let error = + build_replication_target_tls_settings(false, Some(missing.as_path())).unwrap_err(); + assert_eq!( + error, + "--ca-cert must point to a readable local PEM certificate file" + ); + } + + #[test] + fn test_build_replication_target_tls_settings_rejects_empty_file() { + let cert = NamedTempFile::new().expect("temp cert"); + let error = build_replication_target_tls_settings(false, Some(cert.path())).unwrap_err(); + assert_eq!(error, "--ca-cert file is empty"); + } + + #[test] + fn test_build_replication_target_tls_settings_rejects_non_pem_content() { + let cert = NamedTempFile::new().expect("temp cert"); + std::fs::write(cert.path(), "not a pem").expect("write invalid cert"); + let error = build_replication_target_tls_settings(false, Some(cert.path())).unwrap_err(); + assert_eq!( + error, + "--ca-cert must point to a readable local PEM certificate file" + ); + } + + #[test] + fn test_build_replication_target_tls_settings_reads_pem_content() { + let cert = NamedTempFile::new().expect("temp cert"); + let pem = "-----BEGIN CERTIFICATE-----\nabc\n-----END CERTIFICATE-----\n"; + std::fs::write(cert.path(), pem).expect("write cert"); + + let settings = + build_replication_target_tls_settings(false, Some(cert.path())).expect("tls settings"); + + assert_eq!(settings.skip_tls_verify, Some(false)); + assert_eq!(settings.ca_cert_pem.as_deref(), Some(pem)); + } + #[test] fn test_collect_target_arns_deduplicates_destinations() { let config = ReplicationConfiguration { @@ -1430,6 +1609,61 @@ mod tests { assert_eq!(matched.arn, "arn:one"); } + #[test] + fn test_target_level_updates_requested_includes_tls_flags() { + let args = UpdateArgs { + path: "local/bucket".to_string(), + id: "rule-1".to_string(), + replicate: None, + priority: None, + storage_class: None, + bandwidth: None, + sync: None, + prefix: None, + healthcheck_seconds: None, + disable_proxy: None, + insecure: true, + ca_cert: None, + status: None, + force: false, + }; + + assert!(target_level_updates_requested(&args)); + } + + #[test] + fn test_apply_target_updates_clears_existing_ca_when_switching_to_insecure() { + let mut target = BucketTarget { + skip_tls_verify: Some(false), + ca_cert_pem: Some( + "-----BEGIN CERTIFICATE-----\nold\n-----END CERTIFICATE-----\n".to_string(), + ), + ..Default::default() + }; + let args = UpdateArgs { + path: "local/bucket".to_string(), + id: "rule-1".to_string(), + replicate: None, + priority: None, + storage_class: None, + bandwidth: None, + sync: None, + prefix: None, + healthcheck_seconds: None, + disable_proxy: None, + insecure: true, + ca_cert: None, + status: None, + force: false, + }; + let tls_settings = build_replication_target_tls_settings(true, None).expect("tls settings"); + + apply_target_updates(&mut target, &args, &tls_settings); + + assert_eq!(target.skip_tls_verify, Some(true)); + assert_eq!(target.ca_cert_pem, None); + } + #[test] fn test_format_replication_flags_includes_delete_replication() { let rule = ReplicationRule { @@ -1502,6 +1736,8 @@ mod tests { id: None, healthcheck_seconds: 60, disable_proxy: false, + insecure: false, + ca_cert: None, force: false, }), }; diff --git a/crates/cli/tests/help_contract.rs b/crates/cli/tests/help_contract.rs index dcea82e..9614443 100644 --- a/crates/cli/tests/help_contract.rs +++ b/crates/cli/tests/help_contract.rs @@ -596,10 +596,13 @@ fn nested_subcommand_help_contract() { usage: "Usage: rc bucket replication add [OPTIONS] --remote-bucket ", expected_tokens: &[ "--remote-bucket", + "--insecure", + "--ca-cert", "--priority", "--healthcheck-seconds", "Examples:", "rc bucket replication add local/my-bucket --remote-bucket backup/archive", + "The path is resolved on the CLI machine,", ], }, HelpCase { diff --git a/crates/core/src/replication.rs b/crates/core/src/replication.rs index 831a48e..39c000e 100644 --- a/crates/core/src/replication.rs +++ b/crates/core/src/replication.rs @@ -116,6 +116,16 @@ pub struct BucketTarget { #[serde(default)] pub secure: bool, + #[serde( + rename = "skipTlsVerify", + default, + skip_serializing_if = "Option::is_none" + )] + pub skip_tls_verify: Option, + + #[serde(rename = "caCertPem", default, skip_serializing_if = "Option::is_none")] + pub ca_cert_pem: Option, + #[serde(default)] pub path: String, @@ -226,6 +236,7 @@ mod tests { }), target_bucket: "dest-bucket".to_string(), secure: false, + skip_tls_verify: Some(true), target_type: "replication".to_string(), region: "us-east-1".to_string(), replication_sync: true, @@ -236,11 +247,13 @@ mod tests { assert!(json.contains("sourcebucket")); assert!(json.contains("targetbucket")); assert!(json.contains("replicationSync")); + assert!(json.contains("skipTlsVerify")); let decoded: BucketTarget = serde_json::from_str(&json).unwrap(); assert_eq!(decoded.source_bucket, "my-bucket"); assert_eq!(decoded.target_bucket, "dest-bucket"); assert!(decoded.replication_sync); + assert_eq!(decoded.skip_tls_verify, Some(true)); } #[test] @@ -252,4 +265,24 @@ mod tests { assert!(target.online); assert_eq!(target.target_type, "replication"); } + + #[test] + fn test_bucket_target_serialization_includes_ca_cert_pem_content() { + let pem = "-----BEGIN CERTIFICATE-----\nMIIB\n-----END CERTIFICATE-----\n"; + let target = BucketTarget { + source_bucket: "my-bucket".to_string(), + endpoint: "remote:9000".to_string(), + target_bucket: "dest-bucket".to_string(), + secure: true, + skip_tls_verify: Some(false), + ca_cert_pem: Some(pem.to_string()), + target_type: "replication".to_string(), + ..Default::default() + }; + + let json = serde_json::to_string(&target).unwrap(); + assert!(json.contains("\"skipTlsVerify\":false")); + assert!(json.contains("caCertPem")); + assert!(!json.contains("ca.pem")); + } }