From 3796b684f0872a0f295a19b33341195540812f77 Mon Sep 17 00:00:00 2001 From: houseme Date: Tue, 21 Apr 2026 08:28:19 +0800 Subject: [PATCH] feat(targets): add NATS and Pulsar target support (#2618) --- Cargo.lock | 272 +++++++++++- Cargo.toml | 4 +- crates/audit/src/factory.rs | 224 +++------- crates/audit/src/registry.rs | 150 +------ crates/config/src/audit/mod.rs | 13 +- crates/config/src/audit/nats.rs | 60 +++ crates/config/src/audit/pulsar.rs | 54 +++ crates/config/src/constants/targets.rs | 24 ++ crates/config/src/notify/mod.rs | 12 +- crates/config/src/notify/nats.rs | 60 +++ crates/config/src/notify/pulsar.rs | 54 +++ crates/ecstore/src/config/audit.rs | 151 ++++++- crates/ecstore/src/config/com.rs | 252 +++++++----- crates/ecstore/src/config/mod.rs | 8 +- crates/ecstore/src/config/notify.rs | 149 ++++++- crates/notify/src/factory.rs | 224 +++------- crates/notify/src/integration.rs | 19 +- crates/notify/src/registry.rs | 150 +------ crates/targets/Cargo.toml | 3 + crates/targets/src/check.rs | 36 ++ crates/targets/src/config/common.rs | 182 ++++++++ crates/targets/src/config/loader.rs | 239 +++++++++++ crates/targets/src/config/mod.rs | 26 ++ crates/targets/src/config/target_args.rs | 267 ++++++++++++ crates/targets/src/lib.rs | 5 +- crates/targets/src/target/mod.rs | 8 + crates/targets/src/target/nats.rs | 367 +++++++++++++++++ crates/targets/src/target/pulsar.rs | 387 ++++++++++++++++++ rustfs/src/admin/handlers/audit.rs | 253 +++--------- rustfs/src/admin/handlers/event.rs | 249 +++-------- rustfs/src/admin/handlers/mod.rs | 1 + .../src/admin/handlers/target_descriptor.rs | 299 ++++++++++++++ rustfs/src/server/audit.rs | 4 +- 33 files changed, 3046 insertions(+), 1160 deletions(-) create mode 100644 crates/config/src/audit/nats.rs create mode 100644 crates/config/src/audit/pulsar.rs create mode 100644 crates/config/src/notify/nats.rs create mode 100644 crates/config/src/notify/pulsar.rs create mode 100644 crates/targets/src/config/common.rs create mode 100644 crates/targets/src/config/loader.rs create mode 100644 crates/targets/src/config/mod.rs create mode 100644 crates/targets/src/config/target_args.rs create mode 100644 crates/targets/src/target/nats.rs create mode 100644 crates/targets/src/target/pulsar.rs create mode 100644 rustfs/src/admin/handlers/target_descriptor.rs diff --git a/Cargo.lock b/Cargo.lock index 27fba8cf4..c2eec7a0a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -578,6 +578,42 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "async-nats" +version = "0.47.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07d6f157065c3461096d51aacde0c326fa49f3f6e0199e204c566842cdaa5299" +dependencies = [ + "base64 0.22.1", + "bytes", + "futures-util", + "memchr", + "nkeys", + "nuid", + "pin-project", + "portable-atomic", + "rand 0.8.6", + "regex", + "ring", + "rustls-native-certs", + "rustls-pki-types", + "rustls-webpki", + "serde", + "serde_json", + "serde_nanos", + "serde_repr", + "thiserror 1.0.69", + "time", + "tokio", + "tokio-rustls", + "tokio-stream", + "tokio-util", + "tokio-websockets", + "tracing", + "tryhard", + "url", +] + [[package]] name = "async-recursion" version = "1.1.1" @@ -2760,7 +2796,7 @@ dependencies = [ "itertools 0.14.0", "parking_lot 0.12.5", "paste", - "petgraph", + "petgraph 0.8.3", "recursive", "tokio", ] @@ -3302,6 +3338,7 @@ dependencies = [ "ed25519", "serde", "sha2 0.10.9", + "signature 2.2.0", "subtle", "zeroize", ] @@ -3906,8 +3943,8 @@ dependencies = [ "opentelemetry_sdk", "percent-encoding", "pin-project", - "prost", - "prost-types", + "prost 0.14.3", + "prost-types 0.14.3", "reqwest 0.13.2", "rustc_version", "serde", @@ -4013,8 +4050,8 @@ dependencies = [ "md5", "percent-encoding", "pin-project", - "prost", - "prost-types", + "prost 0.14.3", + "prost-types 0.14.3", "serde", "serde_json", "serde_with", @@ -4410,7 +4447,7 @@ dependencies = [ "tokio", "tokio-rustls", "tower-service", - "webpki-roots", + "webpki-roots 1.0.7", ] [[package]] @@ -5605,6 +5642,12 @@ version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d87ecb2933e8aeadb3e3a02b828fed80a7528047e68b4f424523a0981a3a084" +[[package]] +name = "murmur3" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9252111cf132ba0929b6f8e030cac2a24b507f3a4d6db6fb2896f27b354c714b" + [[package]] name = "names" version = "0.14.0" @@ -5698,6 +5741,21 @@ dependencies = [ "libc", ] +[[package]] +name = "nkeys" +version = "0.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879011babc47a1c7fdf5a935ae3cfe94f34645ca0cac1c7f6424b36fc743d1bf" +dependencies = [ + "data-encoding", + "ed25519", + "ed25519-dalek", + "getrandom 0.2.17", + "log", + "rand 0.8.6", + "signatory", +] + [[package]] name = "no_std_io2" version = "0.9.3" @@ -5744,6 +5802,15 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "nuid" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc895af95856f929163a0aa20c26a78d26bfdc839f51b9d5aa7a5b79e52b7e83" +dependencies = [ + "rand 0.8.6", +] + [[package]] name = "num" version = "0.4.3" @@ -6098,7 +6165,7 @@ dependencies = [ "opentelemetry-http", "opentelemetry-proto", "opentelemetry_sdk", - "prost", + "prost 0.14.3", "reqwest 0.12.28", "thiserror 2.0.18", "tracing", @@ -6112,7 +6179,7 @@ checksum = "a7175df06de5eaee9909d4805a3d07e28bb752c34cab57fa9cff549da596b30f" dependencies = [ "opentelemetry", "opentelemetry_sdk", - "prost", + "prost 0.14.3", "tonic", "tonic-prost", ] @@ -6423,6 +6490,16 @@ version = "2.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" +[[package]] +name = "petgraph" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3672b37090dbd86368a4145bc067582552b29c27377cad4e0a306c97f9bd7772" +dependencies = [ + "fixedbitset", + "indexmap 2.14.0", +] + [[package]] name = "petgraph" version = "0.8.3" @@ -6689,7 +6766,7 @@ dependencies = [ "inferno 0.12.6", "num", "paste", - "prost", + "prost 0.14.3", ] [[package]] @@ -6775,6 +6852,16 @@ dependencies = [ "thiserror 2.0.18", ] +[[package]] +name = "prost" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2796faa41db3ec313a31f7624d9286acf277b52de526150b7e69f3debf891ee5" +dependencies = [ + "bytes", + "prost-derive 0.13.5", +] + [[package]] name = "prost" version = "0.14.3" @@ -6782,7 +6869,27 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d2ea70524a2f82d518bce41317d0fae74151505651af45faf1ffbd6fd33f0568" dependencies = [ "bytes", - "prost-derive", + "prost-derive 0.14.3", +] + +[[package]] +name = "prost-build" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be769465445e8c1474e9c5dac2018218498557af32d9ed057325ec9a41ae81bf" +dependencies = [ + "heck", + "itertools 0.14.0", + "log", + "multimap", + "once_cell", + "petgraph 0.7.1", + "prettyplease", + "prost 0.13.5", + "prost-types 0.13.5", + "regex", + "syn 2.0.117", + "tempfile", ] [[package]] @@ -6795,10 +6902,10 @@ dependencies = [ "itertools 0.14.0", "log", "multimap", - "petgraph", + "petgraph 0.8.3", "prettyplease", - "prost", - "prost-types", + "prost 0.14.3", + "prost-types 0.14.3", "pulldown-cmark", "pulldown-cmark-to-cmark", "regex", @@ -6806,6 +6913,19 @@ dependencies = [ "tempfile", ] +[[package]] +name = "prost-derive" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a56d757972c98b346a9b766e3f02746cde6dd1cd1d1d563472929fdd74bec4d" +dependencies = [ + "anyhow", + "itertools 0.14.0", + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "prost-derive" version = "0.14.3" @@ -6819,13 +6939,22 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "prost-types" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52c2c1bf36ddb1a1c396b3601a3cec27c2462e45f07c386894ec3ccf5332bd16" +dependencies = [ + "prost 0.13.5", +] + [[package]] name = "prost-types" version = "0.14.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8991c4cbdb8bc5b11f0b074ffe286c30e523de90fee5ba8132f1399f23cb3dd7" dependencies = [ - "prost", + "prost 0.14.3", ] [[package]] @@ -6919,6 +7048,36 @@ dependencies = [ "pulldown-cmark", ] +[[package]] +name = "pulsar" +version = "6.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2367cb38f1b65857bc11dd13b2adf13b7a1d991ef1cd43572f1420958c56cc2" +dependencies = [ + "async-channel", + "async-trait", + "bytes", + "chrono", + "crc", + "futures", + "log", + "murmur3", + "nom 7.1.3", + "pem", + "prost 0.13.5", + "prost-build 0.13.5", + "prost-derive 0.13.5", + "rand 0.8.6", + "regex", + "rustls", + "tokio", + "tokio-rustls", + "tokio-util", + "url", + "uuid", + "webpki-roots 1.0.7", +] + [[package]] name = "pyroscope" version = "2.0.0" @@ -6931,7 +7090,7 @@ dependencies = [ "log", "names", "pprof-pyroscope-fork", - "prost", + "prost 0.14.3", "reqwest 0.13.2", "serde_json", "thiserror 2.0.18", @@ -8366,7 +8525,7 @@ name = "rustfs-protos" version = "0.0.5" dependencies = [ "flatbuffers", - "prost", + "prost 0.14.3", "rustfs-common", "tonic", "tonic-prost", @@ -8510,12 +8669,15 @@ dependencies = [ name = "rustfs-targets" version = "0.0.5" dependencies = [ + "async-nats", "async-trait", "criterion", "hyper-rustls", + "pulsar", "reqwest 0.13.2", "rumqttc-next", "rustfs-config", + "rustfs-ecstore", "rustfs-s3-common", "rustfs-utils", "rustls", @@ -9009,6 +9171,15 @@ dependencies = [ "zmij", ] +[[package]] +name = "serde_nanos" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a93142f0367a4cc53ae0fead1bcda39e85beccfad3dcd717656cacab94b12985" +dependencies = [ + "serde", +] + [[package]] name = "serde_path_to_error" version = "0.1.20" @@ -9029,6 +9200,17 @@ dependencies = [ "serde", ] +[[package]] +name = "serde_repr" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "175ee3e80ae9982737ca543e96133087cbd9a485eecc3bc4de9c1a37b47ea59c" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "serde_urlencoded" version = "0.7.1" @@ -9200,6 +9382,18 @@ dependencies = [ "libc", ] +[[package]] +name = "signatory" +version = "0.27.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1e303f8205714074f6068773f0e29527e0453937fe837c9717d066635b65f31" +dependencies = [ + "pkcs8 0.10.2", + "rand_core 0.6.4", + "signature 2.2.0", + "zeroize", +] + [[package]] name = "signature" version = "1.6.4" @@ -10000,6 +10194,27 @@ dependencies = [ "tokio", ] +[[package]] +name = "tokio-websockets" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f591660438b3038dd04d16c938271c79e7e06260ad2ea2885a4861bfb238605d" +dependencies = [ + "base64 0.22.1", + "bytes", + "futures-core", + "futures-sink", + "http 1.4.0", + "httparse", + "rand 0.8.6", + "ring", + "rustls-pki-types", + "tokio", + "tokio-rustls", + "tokio-util", + "webpki-roots 0.26.11", +] + [[package]] name = "tonic" version = "0.14.5" @@ -10051,7 +10266,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a55376a0bbaa4975a3f10d009ad763d8f4108f067c7c2e74f3001fb49778d309" dependencies = [ "bytes", - "prost", + "prost 0.14.3", "tonic", ] @@ -10063,8 +10278,8 @@ checksum = "f3144df636917574672e93d0f56d7edec49f90305749c668df5101751bb8f95a" dependencies = [ "prettyplease", "proc-macro2", - "prost-build", - "prost-types", + "prost-build 0.14.3", + "prost-types 0.14.3", "quote", "syn 2.0.117", "tempfile", @@ -10257,6 +10472,16 @@ version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" +[[package]] +name = "tryhard" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fe58ebd5edd976e0fe0f8a14d2a04b7c81ef153ea9a54eebc42e67c2c23b4e5" +dependencies = [ + "pin-project-lite", + "tokio", +] + [[package]] name = "tungstenite" version = "0.29.0" @@ -10642,6 +10867,15 @@ dependencies = [ "rustls-pki-types", ] +[[package]] +name = "webpki-roots" +version = "0.26.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9" +dependencies = [ + "webpki-roots 1.0.7", +] + [[package]] name = "webpki-roots" version = "1.0.7" diff --git a/Cargo.toml b/Cargo.toml index c0231f53b..524d6b48a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -38,7 +38,7 @@ members = [ "crates/protocols", # Protocol implementations (FTPS, SFTP, etc.) "crates/protos", # Protocol buffer definitions "crates/rio", # Rust I/O utilities and abstractions - "crates/concurrency", # Rust I/O utilities and abstractions + "crates/concurrency", # Concurrency management for RustFS - timeout, locking, backpressure, and I/O scheduling "crates/s3-common", # Common utilities and data structures for S3 compatibility "crates/s3select-api", # S3 Select API interface "crates/s3select-query", # S3 Select query engine @@ -118,11 +118,13 @@ async-channel = "2.5.0" async-compression = { version = "0.4.41" } async-recursion = "1.1.1" async-trait = "0.1.89" +async-nats = "0.47.0" axum = "0.8.9" futures = "0.3.32" futures-core = "0.3.32" futures-util = "0.3.32" pollster = "0.4.0" +pulsar = { version = "6.7.2", default-features = false, features = ["tokio-rustls-runtime"] } hyper = { version = "1.9.0", features = ["http2", "http1", "server"] } hyper-rustls = { version = "0.27.9", default-features = false, features = ["native-tokio", "http1", "tls12", "logging", "http2", "aws-lc-rs", "webpki-roots"] } hyper-util = { version = "0.1.20", features = ["tokio", "server-auto", "server-graceful", "tracing"] } diff --git a/crates/audit/src/factory.rs b/crates/audit/src/factory.rs index efa990038..8fbb3c570 100644 --- a/crates/audit/src/factory.rs +++ b/crates/audit/src/factory.rs @@ -14,28 +14,19 @@ use crate::AuditEntry; use async_trait::async_trait; -use hashbrown::HashSet; -use rumqttc::QoS; -use rustfs_config::audit::{AUDIT_MQTT_KEYS, AUDIT_WEBHOOK_KEYS, ENV_AUDIT_MQTT_KEYS, ENV_AUDIT_WEBHOOK_KEYS}; -use rustfs_config::{ - AUDIT_DEFAULT_DIR, DEFAULT_LIMIT, MQTT_BROKER, MQTT_KEEP_ALIVE_INTERVAL, MQTT_PASSWORD, MQTT_QOS, MQTT_QUEUE_DIR, - MQTT_QUEUE_LIMIT, MQTT_RECONNECT_INTERVAL, MQTT_TLS_CA, MQTT_TLS_CLIENT_CERT, MQTT_TLS_CLIENT_KEY, MQTT_TLS_POLICY, - MQTT_TLS_TRUST_LEAF_AS_CA, MQTT_TOPIC, MQTT_USERNAME, MQTT_WS_PATH_ALLOWLIST, RUSTFS_WEBHOOK_SKIP_TLS_VERIFY_DEFAULT, - WEBHOOK_AUTH_TOKEN, WEBHOOK_CLIENT_CA, WEBHOOK_CLIENT_CERT, WEBHOOK_CLIENT_KEY, WEBHOOK_ENDPOINT, WEBHOOK_QUEUE_DIR, - WEBHOOK_QUEUE_LIMIT, WEBHOOK_SKIP_TLS_VERIFY, -}; +use rustfs_config::AUDIT_DEFAULT_DIR; +use rustfs_config::audit::{AUDIT_MQTT_KEYS, AUDIT_NATS_KEYS, AUDIT_PULSAR_KEYS, AUDIT_WEBHOOK_KEYS}; use rustfs_ecstore::config::KVS; use rustfs_targets::{ Target, - error::TargetError, - target::{ - mqtt::{MQTTArgs, MQTTTlsConfig, validate_mqtt_broker_url}, - webhook::WebhookArgs, + config::{ + build_mqtt_args, build_nats_args, build_pulsar_args, build_webhook_args, validate_mqtt_config, validate_nats_config, + validate_pulsar_config, validate_webhook_config, }, + error::TargetError, + target::TargetType, }; -use std::time::Duration; -use tracing::{debug, warn}; -use url::Url; +use std::collections::HashSet; /// Trait for creating targets from configuration #[async_trait] @@ -49,10 +40,6 @@ pub trait TargetFactory: Send + Sync { /// Returns a set of valid configuration field names for this target type. /// This is used to filter environment variables. fn get_valid_fields(&self) -> HashSet; - - /// Returns a set of valid configuration env field names for this target type. - /// This is used to filter environment variables. - fn get_valid_env_fields(&self) -> HashSet; } /// Factory for creating Webhook targets @@ -61,75 +48,18 @@ pub struct WebhookTargetFactory; #[async_trait] impl TargetFactory for WebhookTargetFactory { async fn create_target(&self, id: String, config: &KVS) -> Result + Send + Sync>, TargetError> { - // All config values are now read directly from the merged `config` KVS. - let endpoint = config - .lookup(WEBHOOK_ENDPOINT) - .ok_or_else(|| TargetError::Configuration("Missing webhook endpoint".to_string()))?; - let parsed_endpoint = endpoint.trim(); - let endpoint_url = Url::parse(parsed_endpoint) - .map_err(|e| TargetError::Configuration(format!("Invalid endpoint URL: {e} (value: '{parsed_endpoint}')")))?; - - let args = WebhookArgs { - enable: true, // If we are here, it's already enabled. - endpoint: endpoint_url, - auth_token: config.lookup(WEBHOOK_AUTH_TOKEN).unwrap_or_default(), - queue_dir: config - .lookup(WEBHOOK_QUEUE_DIR) - .unwrap_or_else(|| AUDIT_DEFAULT_DIR.to_string()), - queue_limit: config - .lookup(WEBHOOK_QUEUE_LIMIT) - .and_then(|v| v.parse::().ok()) - .unwrap_or(DEFAULT_LIMIT), - client_cert: config.lookup(WEBHOOK_CLIENT_CERT).unwrap_or_default(), - client_key: config.lookup(WEBHOOK_CLIENT_KEY).unwrap_or_default(), - client_ca: config.lookup(WEBHOOK_CLIENT_CA).unwrap_or_default(), - skip_tls_verify: config - .lookup(WEBHOOK_SKIP_TLS_VERIFY) - .and_then(|v| v.parse::().ok()) - .unwrap_or(RUSTFS_WEBHOOK_SKIP_TLS_VERIFY_DEFAULT), - target_type: rustfs_targets::target::TargetType::AuditLog, - }; - + let args = build_webhook_args(config, AUDIT_DEFAULT_DIR, TargetType::AuditLog)?; let target = rustfs_targets::target::webhook::WebhookTarget::new(id, args)?; Ok(Box::new(target)) } fn validate_config(&self, _id: &str, config: &KVS) -> Result<(), TargetError> { - // Validation also uses the merged `config` KVS directly. - let endpoint = config - .lookup(WEBHOOK_ENDPOINT) - .ok_or_else(|| TargetError::Configuration("Missing webhook endpoint".to_string()))?; - debug!("endpoint: {}", endpoint); - let parsed_endpoint = endpoint.trim(); - Url::parse(parsed_endpoint) - .map_err(|e| TargetError::Configuration(format!("Invalid endpoint URL: {e} (value: '{parsed_endpoint}')")))?; - - let client_cert = config.lookup(WEBHOOK_CLIENT_CERT).unwrap_or_default(); - let client_key = config.lookup(WEBHOOK_CLIENT_KEY).unwrap_or_default(); - - if client_cert.is_empty() != client_key.is_empty() { - return Err(TargetError::Configuration( - "Both client_cert and client_key must be specified together".to_string(), - )); - } - - let queue_dir = config - .lookup(WEBHOOK_QUEUE_DIR) - .unwrap_or_else(|| AUDIT_DEFAULT_DIR.to_string()); - if !queue_dir.is_empty() && !std::path::Path::new(&queue_dir).is_absolute() { - return Err(TargetError::Configuration("Webhook queue directory must be an absolute path".to_string())); - } - - Ok(()) + validate_webhook_config(config, AUDIT_DEFAULT_DIR) } fn get_valid_fields(&self) -> HashSet { AUDIT_WEBHOOK_KEYS.iter().map(|s| s.to_string()).collect() } - - fn get_valid_env_fields(&self) -> HashSet { - ENV_AUDIT_WEBHOOK_KEYS.iter().map(|s| s.to_string()).collect() - } } /// Factory for creating MQTT targets @@ -138,112 +68,54 @@ pub struct MQTTTargetFactory; #[async_trait] impl TargetFactory for MQTTTargetFactory { async fn create_target(&self, id: String, config: &KVS) -> Result + Send + Sync>, TargetError> { - let broker = config - .lookup(MQTT_BROKER) - .ok_or_else(|| TargetError::Configuration("Missing MQTT broker".to_string()))?; - let broker_url = Url::parse(&broker) - .map_err(|e| TargetError::Configuration(format!("Invalid broker URL: {e} (value: '{broker}')")))?; - - let topic = config - .lookup(MQTT_TOPIC) - .ok_or_else(|| TargetError::Configuration("Missing MQTT topic".to_string()))?; - - let args = MQTTArgs { - enable: true, // Assumed enabled. - broker: broker_url, - topic, - qos: config - .lookup(MQTT_QOS) - .and_then(|v| v.parse::().ok()) - .map(|q| match q { - 0 => QoS::AtMostOnce, - 1 => QoS::AtLeastOnce, - 2 => QoS::ExactlyOnce, - _ => QoS::AtLeastOnce, - }) - .unwrap_or(QoS::AtLeastOnce), - username: config.lookup(MQTT_USERNAME).unwrap_or_default(), - password: config.lookup(MQTT_PASSWORD).unwrap_or_default(), - max_reconnect_interval: config - .lookup(MQTT_RECONNECT_INTERVAL) - .and_then(|v| v.parse::().ok()) - .map(Duration::from_secs) - .unwrap_or_else(|| Duration::from_secs(5)), - keep_alive: config - .lookup(MQTT_KEEP_ALIVE_INTERVAL) - .and_then(|v| v.parse::().ok()) - .map(Duration::from_secs) - .unwrap_or_else(|| Duration::from_secs(30)), - tls: MQTTTlsConfig::from_values( - config.lookup(MQTT_TLS_POLICY).as_deref(), - config.lookup(MQTT_TLS_CA).as_deref(), - config.lookup(MQTT_TLS_CLIENT_CERT).as_deref(), - config.lookup(MQTT_TLS_CLIENT_KEY).as_deref(), - config.lookup(MQTT_TLS_TRUST_LEAF_AS_CA).as_deref(), - config.lookup(MQTT_WS_PATH_ALLOWLIST).as_deref(), - )?, - queue_dir: config.lookup(MQTT_QUEUE_DIR).unwrap_or_else(|| AUDIT_DEFAULT_DIR.to_string()), - queue_limit: config - .lookup(MQTT_QUEUE_LIMIT) - .and_then(|v| v.parse::().ok()) - .unwrap_or(DEFAULT_LIMIT), - target_type: rustfs_targets::target::TargetType::AuditLog, - }; - + let args = build_mqtt_args(config, AUDIT_DEFAULT_DIR, TargetType::AuditLog)?; let target = rustfs_targets::target::mqtt::MQTTTarget::new(id, args)?; Ok(Box::new(target)) } fn validate_config(&self, _id: &str, config: &KVS) -> Result<(), TargetError> { - let broker = config - .lookup(MQTT_BROKER) - .ok_or_else(|| TargetError::Configuration("Missing MQTT broker".to_string()))?; - let url = Url::parse(&broker) - .map_err(|e| TargetError::Configuration(format!("Invalid broker URL: {e} (value: '{broker}')")))?; - - let tls = MQTTTlsConfig::from_values( - config.lookup(MQTT_TLS_POLICY).as_deref(), - config.lookup(MQTT_TLS_CA).as_deref(), - config.lookup(MQTT_TLS_CLIENT_CERT).as_deref(), - config.lookup(MQTT_TLS_CLIENT_KEY).as_deref(), - config.lookup(MQTT_TLS_TRUST_LEAF_AS_CA).as_deref(), - config.lookup(MQTT_WS_PATH_ALLOWLIST).as_deref(), - )?; - validate_mqtt_broker_url(&url, &tls)?; - - if config.lookup(MQTT_TOPIC).is_none() { - return Err(TargetError::Configuration("Missing MQTT topic".to_string())); - } - - if let Some(qos_str) = config.lookup(MQTT_QOS) { - let qos = qos_str - .parse::() - .map_err(|_| TargetError::Configuration("Invalid QoS value".to_string()))?; - if qos > 2 { - return Err(TargetError::Configuration("QoS must be 0, 1, or 2".to_string())); - } - } - - let queue_dir = config.lookup(MQTT_QUEUE_DIR).unwrap_or_default(); - if !queue_dir.is_empty() { - if !std::path::Path::new(&queue_dir).is_absolute() { - return Err(TargetError::Configuration("MQTT queue directory must be an absolute path".to_string())); - } - if let Some(qos_str) = config.lookup(MQTT_QOS) - && qos_str == "0" - { - warn!("Using queue_dir with QoS 0 may result in event loss"); - } - } - - Ok(()) + validate_mqtt_config(config) } fn get_valid_fields(&self) -> HashSet { AUDIT_MQTT_KEYS.iter().map(|s| s.to_string()).collect() } +} - fn get_valid_env_fields(&self) -> HashSet { - ENV_AUDIT_MQTT_KEYS.iter().map(|s| s.to_string()).collect() +pub struct NATSTargetFactory; + +#[async_trait] +impl TargetFactory for NATSTargetFactory { + async fn create_target(&self, id: String, config: &KVS) -> Result + Send + Sync>, TargetError> { + let args = build_nats_args(config, AUDIT_DEFAULT_DIR, TargetType::AuditLog)?; + let target = rustfs_targets::target::nats::NATSTarget::new(id, args)?; + Ok(Box::new(target)) + } + + fn validate_config(&self, _id: &str, config: &KVS) -> Result<(), TargetError> { + validate_nats_config(config, AUDIT_DEFAULT_DIR) + } + + fn get_valid_fields(&self) -> HashSet { + AUDIT_NATS_KEYS.iter().map(|s| s.to_string()).collect() + } +} + +pub struct PulsarTargetFactory; + +#[async_trait] +impl TargetFactory for PulsarTargetFactory { + async fn create_target(&self, id: String, config: &KVS) -> Result + Send + Sync>, TargetError> { + let args = build_pulsar_args(config, AUDIT_DEFAULT_DIR, TargetType::AuditLog)?; + let target = rustfs_targets::target::pulsar::PulsarTarget::new(id, args)?; + Ok(Box::new(target)) + } + + fn validate_config(&self, _id: &str, config: &KVS) -> Result<(), TargetError> { + validate_pulsar_config(config, AUDIT_DEFAULT_DIR) + } + + fn get_valid_fields(&self) -> HashSet { + AUDIT_PULSAR_KEYS.iter().map(|s| s.to_string()).collect() } } diff --git a/crates/audit/src/registry.rs b/crates/audit/src/registry.rs index f15bdba87..a9822c930 100644 --- a/crates/audit/src/registry.rs +++ b/crates/audit/src/registry.rs @@ -14,18 +14,17 @@ use crate::{ AuditEntry, AuditError, AuditResult, - factory::{MQTTTargetFactory, TargetFactory, WebhookTargetFactory}, + factory::{MQTTTargetFactory, NATSTargetFactory, PulsarTargetFactory, TargetFactory, WebhookTargetFactory}, }; use futures::StreamExt; use futures::stream::FuturesUnordered; -use hashbrown::{HashMap, HashSet}; -use rustfs_config::{DEFAULT_DELIMITER, ENABLE_KEY, ENV_PREFIX, EnableState, audit::AUDIT_ROUTE_PREFIX}; +use hashbrown::HashMap; +use rustfs_config::audit::AUDIT_ROUTE_PREFIX; use rustfs_ecstore::config::{Config, KVS}; use rustfs_targets::arn::TargetID; -use rustfs_targets::{Target, TargetError, target::ChannelTargetType}; -use std::str::FromStr; +use rustfs_targets::{Target, TargetError, config::collect_target_configs, target::ChannelTargetType}; use std::sync::Arc; -use tracing::{debug, error, info, warn}; +use tracing::{error, info}; /// Registry for managing audit targets pub struct AuditRegistry { @@ -52,6 +51,8 @@ impl AuditRegistry { // Register built-in factories registry.register(ChannelTargetType::Webhook.as_str(), Box::new(WebhookTargetFactory)); registry.register(ChannelTargetType::Mqtt.as_str(), Box::new(MQTTTargetFactory)); + registry.register(ChannelTargetType::Nats.as_str(), Box::new(NATSTargetFactory)); + registry.register(ChannelTargetType::Pulsar.as_str(), Box::new(PulsarTargetFactory)); registry } @@ -105,143 +106,22 @@ impl AuditRegistry { &self, config: &Config, ) -> AuditResult + Send + Sync>>> { - // Collect only environment variables with the relevant prefix to reduce memory usage - let all_env: Vec<(String, String)> = std::env::vars().filter(|(key, _)| key.starts_with(ENV_PREFIX)).collect(); - // A collection of asynchronous tasks for concurrently executing target creation let mut tasks = FuturesUnordered::new(); - // 1. Traverse all registered plants and process them by target type for (target_type, factory) in &self.factories { tracing::Span::current().record("target_type", target_type.as_str()); info!("Start working on target types..."); - - // 2. Prepare the configuration source - // 2.1. Get the configuration segment in the file, e.g. 'audit_webhook' - let section_name = format!("{AUDIT_ROUTE_PREFIX}{target_type}").to_lowercase(); - let file_configs = config.0.get(§ion_name).cloned().unwrap_or_default(); - // 2.2. Get the default configuration for that type - let default_cfg = file_configs.get(DEFAULT_DELIMITER).cloned().unwrap_or_default(); - debug!(?default_cfg, "Get the default configuration"); - - // *** Optimization point 1: Get all legitimate fields of the current target type *** let valid_fields = factory.get_valid_fields(); - debug!(?valid_fields, "Get the legitimate configuration fields"); - - // 3. Resolve instance IDs and configuration overrides from environment variables - let mut instance_ids_from_env = HashSet::new(); - // 3.1. Instance discovery: Based on the '..._ENABLE_INSTANCEID' format - let enable_prefix = - format!("{ENV_PREFIX}{AUDIT_ROUTE_PREFIX}{target_type}{DEFAULT_DELIMITER}{ENABLE_KEY}{DEFAULT_DELIMITER}") - .to_uppercase(); - for (key, value) in &all_env { - if EnableState::from_str(value).ok().map(|s| s.is_enabled()).unwrap_or(false) - && let Some(id) = key.strip_prefix(&enable_prefix) - && !id.is_empty() - { - instance_ids_from_env.insert(id.to_lowercase()); - } - } - - // 3.2. Parse all relevant environment variable configurations - // 3.2.1. Build environment variable prefixes such as 'RUSTFS_AUDIT_WEBHOOK_' - let env_prefix = format!("{ENV_PREFIX}{AUDIT_ROUTE_PREFIX}{target_type}{DEFAULT_DELIMITER}").to_uppercase(); - // 3.2.2. 'env_overrides' is used to store configurations parsed from environment variables in the format: {instance id -> {field -> value}} - let mut env_overrides: HashMap> = HashMap::new(); - for (key, value) in &all_env { - if let Some(rest) = key.strip_prefix(&env_prefix) { - // Use rsplitn to split from the right side to properly extract the INSTANCE_ID at the end - // Format: _ or - let mut parts = rest.rsplitn(2, DEFAULT_DELIMITER); - - // The first part from the right is INSTANCE_ID - let instance_id_part = parts.next().unwrap_or(DEFAULT_DELIMITER); - // The remaining part is FIELD_NAME - let field_name_part = parts.next(); - - let (field_name, instance_id) = match field_name_part { - // Case 1: The format is _ - // e.g., rest = "ENDPOINT_PRIMARY" -> field_name="ENDPOINT", instance_id="PRIMARY" - Some(field) => (field.to_lowercase(), instance_id_part.to_lowercase()), - // Case 2: The format is (without INSTANCE_ID) - // e.g., rest = "ENABLE" -> field_name="ENABLE", instance_id="" (Universal configuration `_ DEFAULT_DELIMITER`) - None => (instance_id_part.to_lowercase(), DEFAULT_DELIMITER.to_string()), - }; - - // *** Optimization point 2: Verify whether the parsed field_name is legal *** - if !field_name.is_empty() && valid_fields.contains(&field_name) { - debug!( - instance_id = %if instance_id.is_empty() { DEFAULT_DELIMITER } else { &instance_id }, - %field_name, - %value, - "Parsing to environment variables" - ); - env_overrides - .entry(instance_id) - .or_default() - .insert(field_name, value.clone()); - } else { - // Ignore illegal field names - warn!( - field_name = %field_name, - "Ignore environment variable fields, not found in the list of valid fields for target type {}", - target_type - ); - } - } - } - debug!(?env_overrides, "Complete the environment variable analysis"); - - // 4. Determine all instance IDs that need to be processed - let mut all_instance_ids: HashSet = - file_configs.keys().filter(|k| *k != DEFAULT_DELIMITER).cloned().collect(); - all_instance_ids.extend(instance_ids_from_env); - debug!(?all_instance_ids, "Determine all instance IDs"); - - // 5. Merge configurations and create tasks for each instance - for id in all_instance_ids { - // 5.1. Merge configuration, priority: Environment variables > File instance configuration > File default configuration - let mut merged_config = default_cfg.clone(); - // Instance-specific configuration in application files - if let Some(file_instance_cfg) = file_configs.get(&id) { - merged_config.extend(file_instance_cfg.clone()); - } - // Application instance-specific environment variable configuration - if let Some(env_instance_cfg) = env_overrides.get(&id) { - // Convert HashMap to KVS - let mut kvs_from_env = KVS::new(); - for (k, v) in env_instance_cfg { - kvs_from_env.insert(k.clone(), v.clone()); - } - merged_config.extend(kvs_from_env); - } - debug!(instance_id = %id, ?merged_config, "Complete configuration merge"); - - // 5.2. Check if the instance is enabled - let enabled = merged_config - .lookup(ENABLE_KEY) - .map(|v| { - EnableState::from_str(v.as_str()) - .ok() - .map(|s| s.is_enabled()) - .unwrap_or(false) - }) - .unwrap_or(false); - - if enabled { - info!(instance_id = %id, "Target is enabled, ready to create a task"); - // 5.3. Create asynchronous tasks for enabled instances - let tid = id.clone(); - let merged_config_arc = Arc::new(merged_config); - tasks.push(async move { - let result = factory.create_target(tid.clone(), &merged_config_arc).await; - (tid, result) - }); - } else { - info!(instance_id = %id, "Skip disabled target"); - } + for (id, merged_config) in collect_target_configs(config, AUDIT_ROUTE_PREFIX, target_type, &valid_fields) { + info!(instance_id = %id, "Target is enabled, ready to create a task"); + let tid = id.clone(); + let merged_config_arc = Arc::new(merged_config); + tasks.push(async move { + let result = factory.create_target(tid.clone(), &merged_config_arc).await; + (tid, result) + }); } } - // 6. Concurrently execute all creation tasks and collect results let mut successful_targets = Vec::new(); while let Some((id, result)) = tasks.next().await { match result { diff --git a/crates/config/src/audit/mod.rs b/crates/config/src/audit/mod.rs index 793845ff7..a752e6c0c 100644 --- a/crates/config/src/audit/mod.rs +++ b/crates/config/src/audit/mod.rs @@ -17,9 +17,13 @@ //! webhook and MQTT audit-related settings. mod mqtt; +mod nats; +mod pulsar; mod webhook; pub use mqtt::*; +pub use nats::*; +pub use pulsar::*; pub use webhook::*; use crate::DEFAULT_DELIMITER; @@ -30,7 +34,14 @@ pub const AUDIT_ROUTE_PREFIX: &str = const_str::concat!(AUDIT_PREFIX, DEFAULT_DE pub const AUDIT_WEBHOOK_SUB_SYS: &str = "audit_webhook"; pub const AUDIT_MQTT_SUB_SYS: &str = "audit_mqtt"; +pub const AUDIT_NATS_SUB_SYS: &str = "audit_nats"; +pub const AUDIT_PULSAR_SUB_SYS: &str = "audit_pulsar"; pub const AUDIT_STORE_EXTENSION: &str = ".audit"; #[allow(dead_code)] -pub const AUDIT_SUB_SYSTEMS: &[&str] = &[AUDIT_MQTT_SUB_SYS, AUDIT_WEBHOOK_SUB_SYS]; +pub const AUDIT_SUB_SYSTEMS: &[&str] = &[ + AUDIT_MQTT_SUB_SYS, + AUDIT_NATS_SUB_SYS, + AUDIT_PULSAR_SUB_SYS, + AUDIT_WEBHOOK_SUB_SYS, +]; diff --git a/crates/config/src/audit/nats.rs b/crates/config/src/audit/nats.rs new file mode 100644 index 000000000..c9657c62b --- /dev/null +++ b/crates/config/src/audit/nats.rs @@ -0,0 +1,60 @@ +// 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. + +pub const ENV_AUDIT_NATS_ENABLE: &str = "RUSTFS_AUDIT_NATS_ENABLE"; +pub const ENV_AUDIT_NATS_ADDRESS: &str = "RUSTFS_AUDIT_NATS_ADDRESS"; +pub const ENV_AUDIT_NATS_SUBJECT: &str = "RUSTFS_AUDIT_NATS_SUBJECT"; +pub const ENV_AUDIT_NATS_USERNAME: &str = "RUSTFS_AUDIT_NATS_USERNAME"; +pub const ENV_AUDIT_NATS_PASSWORD: &str = "RUSTFS_AUDIT_NATS_PASSWORD"; +pub const ENV_AUDIT_NATS_TOKEN: &str = "RUSTFS_AUDIT_NATS_TOKEN"; +pub const ENV_AUDIT_NATS_CREDENTIALS_FILE: &str = "RUSTFS_AUDIT_NATS_CREDENTIALS_FILE"; +pub const ENV_AUDIT_NATS_TLS_CA: &str = "RUSTFS_AUDIT_NATS_TLS_CA"; +pub const ENV_AUDIT_NATS_TLS_CLIENT_CERT: &str = "RUSTFS_AUDIT_NATS_TLS_CLIENT_CERT"; +pub const ENV_AUDIT_NATS_TLS_CLIENT_KEY: &str = "RUSTFS_AUDIT_NATS_TLS_CLIENT_KEY"; +pub const ENV_AUDIT_NATS_TLS_REQUIRED: &str = "RUSTFS_AUDIT_NATS_TLS_REQUIRED"; +pub const ENV_AUDIT_NATS_QUEUE_DIR: &str = "RUSTFS_AUDIT_NATS_QUEUE_DIR"; +pub const ENV_AUDIT_NATS_QUEUE_LIMIT: &str = "RUSTFS_AUDIT_NATS_QUEUE_LIMIT"; + +pub const ENV_AUDIT_NATS_KEYS: &[&str; 13] = &[ + ENV_AUDIT_NATS_ENABLE, + ENV_AUDIT_NATS_ADDRESS, + ENV_AUDIT_NATS_SUBJECT, + ENV_AUDIT_NATS_USERNAME, + ENV_AUDIT_NATS_PASSWORD, + ENV_AUDIT_NATS_TOKEN, + ENV_AUDIT_NATS_CREDENTIALS_FILE, + ENV_AUDIT_NATS_TLS_CA, + ENV_AUDIT_NATS_TLS_CLIENT_CERT, + ENV_AUDIT_NATS_TLS_CLIENT_KEY, + ENV_AUDIT_NATS_TLS_REQUIRED, + ENV_AUDIT_NATS_QUEUE_DIR, + ENV_AUDIT_NATS_QUEUE_LIMIT, +]; + +pub const AUDIT_NATS_KEYS: &[&str] = &[ + crate::ENABLE_KEY, + crate::NATS_ADDRESS, + crate::NATS_SUBJECT, + crate::NATS_USERNAME, + crate::NATS_PASSWORD, + crate::NATS_TOKEN, + crate::NATS_CREDENTIALS_FILE, + crate::NATS_TLS_CA, + crate::NATS_TLS_CLIENT_CERT, + crate::NATS_TLS_CLIENT_KEY, + crate::NATS_TLS_REQUIRED, + crate::NATS_QUEUE_DIR, + crate::NATS_QUEUE_LIMIT, + crate::COMMENT_KEY, +]; diff --git a/crates/config/src/audit/pulsar.rs b/crates/config/src/audit/pulsar.rs new file mode 100644 index 000000000..27da912b4 --- /dev/null +++ b/crates/config/src/audit/pulsar.rs @@ -0,0 +1,54 @@ +// 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. + +pub const ENV_AUDIT_PULSAR_ENABLE: &str = "RUSTFS_AUDIT_PULSAR_ENABLE"; +pub const ENV_AUDIT_PULSAR_BROKER: &str = "RUSTFS_AUDIT_PULSAR_BROKER"; +pub const ENV_AUDIT_PULSAR_TOPIC: &str = "RUSTFS_AUDIT_PULSAR_TOPIC"; +pub const ENV_AUDIT_PULSAR_AUTH_TOKEN: &str = "RUSTFS_AUDIT_PULSAR_AUTH_TOKEN"; +pub const ENV_AUDIT_PULSAR_USERNAME: &str = "RUSTFS_AUDIT_PULSAR_USERNAME"; +pub const ENV_AUDIT_PULSAR_PASSWORD: &str = "RUSTFS_AUDIT_PULSAR_PASSWORD"; +pub const ENV_AUDIT_PULSAR_TLS_CA: &str = "RUSTFS_AUDIT_PULSAR_TLS_CA"; +pub const ENV_AUDIT_PULSAR_TLS_ALLOW_INSECURE: &str = "RUSTFS_AUDIT_PULSAR_TLS_ALLOW_INSECURE"; +pub const ENV_AUDIT_PULSAR_TLS_HOSTNAME_VERIFICATION: &str = "RUSTFS_AUDIT_PULSAR_TLS_HOSTNAME_VERIFICATION"; +pub const ENV_AUDIT_PULSAR_QUEUE_DIR: &str = "RUSTFS_AUDIT_PULSAR_QUEUE_DIR"; +pub const ENV_AUDIT_PULSAR_QUEUE_LIMIT: &str = "RUSTFS_AUDIT_PULSAR_QUEUE_LIMIT"; + +pub const ENV_AUDIT_PULSAR_KEYS: &[&str; 11] = &[ + ENV_AUDIT_PULSAR_ENABLE, + ENV_AUDIT_PULSAR_BROKER, + ENV_AUDIT_PULSAR_TOPIC, + ENV_AUDIT_PULSAR_AUTH_TOKEN, + ENV_AUDIT_PULSAR_USERNAME, + ENV_AUDIT_PULSAR_PASSWORD, + ENV_AUDIT_PULSAR_TLS_CA, + ENV_AUDIT_PULSAR_TLS_ALLOW_INSECURE, + ENV_AUDIT_PULSAR_TLS_HOSTNAME_VERIFICATION, + ENV_AUDIT_PULSAR_QUEUE_DIR, + ENV_AUDIT_PULSAR_QUEUE_LIMIT, +]; + +pub const AUDIT_PULSAR_KEYS: &[&str] = &[ + crate::ENABLE_KEY, + crate::PULSAR_BROKER, + crate::PULSAR_TOPIC, + crate::PULSAR_AUTH_TOKEN, + crate::PULSAR_USERNAME, + crate::PULSAR_PASSWORD, + crate::PULSAR_TLS_CA, + crate::PULSAR_TLS_ALLOW_INSECURE, + crate::PULSAR_TLS_HOSTNAME_VERIFICATION, + crate::PULSAR_QUEUE_DIR, + crate::PULSAR_QUEUE_LIMIT, + crate::COMMENT_KEY, +]; diff --git a/crates/config/src/constants/targets.rs b/crates/config/src/constants/targets.rs index d6b07faf8..0daedc358 100644 --- a/crates/config/src/constants/targets.rs +++ b/crates/config/src/constants/targets.rs @@ -41,6 +41,30 @@ pub const MQTT_TLS_CLIENT_KEY: &str = "tls_client_key"; pub const MQTT_TLS_TRUST_LEAF_AS_CA: &str = "tls_trust_leaf_as_ca"; pub const MQTT_WS_PATH_ALLOWLIST: &str = "ws_path_allowlist"; +pub const NATS_ADDRESS: &str = "address"; +pub const NATS_SUBJECT: &str = "subject"; +pub const NATS_USERNAME: &str = "username"; +pub const NATS_PASSWORD: &str = "password"; +pub const NATS_TOKEN: &str = "token"; +pub const NATS_CREDENTIALS_FILE: &str = "credentials_file"; +pub const NATS_TLS_CA: &str = "tls_ca"; +pub const NATS_TLS_CLIENT_CERT: &str = "tls_client_cert"; +pub const NATS_TLS_CLIENT_KEY: &str = "tls_client_key"; +pub const NATS_TLS_REQUIRED: &str = "tls_required"; +pub const NATS_QUEUE_DIR: &str = "queue_dir"; +pub const NATS_QUEUE_LIMIT: &str = "queue_limit"; + +pub const PULSAR_BROKER: &str = "broker"; +pub const PULSAR_TOPIC: &str = "topic"; +pub const PULSAR_AUTH_TOKEN: &str = "auth_token"; +pub const PULSAR_USERNAME: &str = "username"; +pub const PULSAR_PASSWORD: &str = "password"; +pub const PULSAR_TLS_CA: &str = "tls_ca"; +pub const PULSAR_TLS_ALLOW_INSECURE: &str = "tls_allow_insecure"; +pub const PULSAR_TLS_HOSTNAME_VERIFICATION: &str = "tls_hostname_verification"; +pub const PULSAR_QUEUE_DIR: &str = "queue_dir"; +pub const PULSAR_QUEUE_LIMIT: &str = "queue_limit"; + /// Environment variable controlling whether target queue files are Snappy-compressed. /// Applies to both notify and audit target queue stores. pub const ENV_TARGET_STORE_COMPRESS: &str = "RUSTFS_TARGET_STORE_COMPRESS"; diff --git a/crates/config/src/notify/mod.rs b/crates/config/src/notify/mod.rs index 59e6493f6..26a3f1778 100644 --- a/crates/config/src/notify/mod.rs +++ b/crates/config/src/notify/mod.rs @@ -14,11 +14,15 @@ mod arn; mod mqtt; +mod nats; +mod pulsar; mod store; mod webhook; pub use arn::*; pub use mqtt::*; +pub use nats::*; +pub use pulsar::*; pub use store::*; pub use webhook::*; @@ -64,7 +68,12 @@ pub const ENV_NOTIFY_SEND_CONCURRENCY: &str = "RUSTFS_NOTIFY_SEND_CONCURRENCY"; pub const DEFAULT_NOTIFY_SEND_CONCURRENCY: usize = 64; #[allow(dead_code)] -pub const NOTIFY_SUB_SYSTEMS: &[&str] = &[NOTIFY_MQTT_SUB_SYS, NOTIFY_WEBHOOK_SUB_SYS]; +pub const NOTIFY_SUB_SYSTEMS: &[&str] = &[ + NOTIFY_MQTT_SUB_SYS, + NOTIFY_NATS_SUB_SYS, + NOTIFY_PULSAR_SUB_SYS, + NOTIFY_WEBHOOK_SUB_SYS, +]; #[allow(dead_code)] pub const NOTIFY_KAFKA_SUB_SYS: &str = "notify_kafka"; @@ -83,4 +92,5 @@ pub const NOTIFY_AMQP_SUB_SYS: &str = "notify_amqp"; pub const NOTIFY_POSTGRES_SUB_SYS: &str = "notify_postgres"; #[allow(dead_code)] pub const NOTIFY_REDIS_SUB_SYS: &str = "notify_redis"; +pub const NOTIFY_PULSAR_SUB_SYS: &str = "notify_pulsar"; pub const NOTIFY_WEBHOOK_SUB_SYS: &str = "notify_webhook"; diff --git a/crates/config/src/notify/nats.rs b/crates/config/src/notify/nats.rs new file mode 100644 index 000000000..d9464128d --- /dev/null +++ b/crates/config/src/notify/nats.rs @@ -0,0 +1,60 @@ +// 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. + +pub const NOTIFY_NATS_KEYS: &[&str] = &[ + crate::ENABLE_KEY, + crate::NATS_ADDRESS, + crate::NATS_SUBJECT, + crate::NATS_USERNAME, + crate::NATS_PASSWORD, + crate::NATS_TOKEN, + crate::NATS_CREDENTIALS_FILE, + crate::NATS_TLS_CA, + crate::NATS_TLS_CLIENT_CERT, + crate::NATS_TLS_CLIENT_KEY, + crate::NATS_TLS_REQUIRED, + crate::NATS_QUEUE_DIR, + crate::NATS_QUEUE_LIMIT, + crate::COMMENT_KEY, +]; + +pub const ENV_NOTIFY_NATS_ENABLE: &str = "RUSTFS_NOTIFY_NATS_ENABLE"; +pub const ENV_NOTIFY_NATS_ADDRESS: &str = "RUSTFS_NOTIFY_NATS_ADDRESS"; +pub const ENV_NOTIFY_NATS_SUBJECT: &str = "RUSTFS_NOTIFY_NATS_SUBJECT"; +pub const ENV_NOTIFY_NATS_USERNAME: &str = "RUSTFS_NOTIFY_NATS_USERNAME"; +pub const ENV_NOTIFY_NATS_PASSWORD: &str = "RUSTFS_NOTIFY_NATS_PASSWORD"; +pub const ENV_NOTIFY_NATS_TOKEN: &str = "RUSTFS_NOTIFY_NATS_TOKEN"; +pub const ENV_NOTIFY_NATS_CREDENTIALS_FILE: &str = "RUSTFS_NOTIFY_NATS_CREDENTIALS_FILE"; +pub const ENV_NOTIFY_NATS_TLS_CA: &str = "RUSTFS_NOTIFY_NATS_TLS_CA"; +pub const ENV_NOTIFY_NATS_TLS_CLIENT_CERT: &str = "RUSTFS_NOTIFY_NATS_TLS_CLIENT_CERT"; +pub const ENV_NOTIFY_NATS_TLS_CLIENT_KEY: &str = "RUSTFS_NOTIFY_NATS_TLS_CLIENT_KEY"; +pub const ENV_NOTIFY_NATS_TLS_REQUIRED: &str = "RUSTFS_NOTIFY_NATS_TLS_REQUIRED"; +pub const ENV_NOTIFY_NATS_QUEUE_DIR: &str = "RUSTFS_NOTIFY_NATS_QUEUE_DIR"; +pub const ENV_NOTIFY_NATS_QUEUE_LIMIT: &str = "RUSTFS_NOTIFY_NATS_QUEUE_LIMIT"; + +pub const ENV_NOTIFY_NATS_KEYS: &[&str; 13] = &[ + ENV_NOTIFY_NATS_ENABLE, + ENV_NOTIFY_NATS_ADDRESS, + ENV_NOTIFY_NATS_SUBJECT, + ENV_NOTIFY_NATS_USERNAME, + ENV_NOTIFY_NATS_PASSWORD, + ENV_NOTIFY_NATS_TOKEN, + ENV_NOTIFY_NATS_CREDENTIALS_FILE, + ENV_NOTIFY_NATS_TLS_CA, + ENV_NOTIFY_NATS_TLS_CLIENT_CERT, + ENV_NOTIFY_NATS_TLS_CLIENT_KEY, + ENV_NOTIFY_NATS_TLS_REQUIRED, + ENV_NOTIFY_NATS_QUEUE_DIR, + ENV_NOTIFY_NATS_QUEUE_LIMIT, +]; diff --git a/crates/config/src/notify/pulsar.rs b/crates/config/src/notify/pulsar.rs new file mode 100644 index 000000000..32ea84198 --- /dev/null +++ b/crates/config/src/notify/pulsar.rs @@ -0,0 +1,54 @@ +// 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. + +pub const NOTIFY_PULSAR_KEYS: &[&str] = &[ + crate::ENABLE_KEY, + crate::PULSAR_BROKER, + crate::PULSAR_TOPIC, + crate::PULSAR_AUTH_TOKEN, + crate::PULSAR_USERNAME, + crate::PULSAR_PASSWORD, + crate::PULSAR_TLS_CA, + crate::PULSAR_TLS_ALLOW_INSECURE, + crate::PULSAR_TLS_HOSTNAME_VERIFICATION, + crate::PULSAR_QUEUE_DIR, + crate::PULSAR_QUEUE_LIMIT, + crate::COMMENT_KEY, +]; + +pub const ENV_NOTIFY_PULSAR_ENABLE: &str = "RUSTFS_NOTIFY_PULSAR_ENABLE"; +pub const ENV_NOTIFY_PULSAR_BROKER: &str = "RUSTFS_NOTIFY_PULSAR_BROKER"; +pub const ENV_NOTIFY_PULSAR_TOPIC: &str = "RUSTFS_NOTIFY_PULSAR_TOPIC"; +pub const ENV_NOTIFY_PULSAR_AUTH_TOKEN: &str = "RUSTFS_NOTIFY_PULSAR_AUTH_TOKEN"; +pub const ENV_NOTIFY_PULSAR_USERNAME: &str = "RUSTFS_NOTIFY_PULSAR_USERNAME"; +pub const ENV_NOTIFY_PULSAR_PASSWORD: &str = "RUSTFS_NOTIFY_PULSAR_PASSWORD"; +pub const ENV_NOTIFY_PULSAR_TLS_CA: &str = "RUSTFS_NOTIFY_PULSAR_TLS_CA"; +pub const ENV_NOTIFY_PULSAR_TLS_ALLOW_INSECURE: &str = "RUSTFS_NOTIFY_PULSAR_TLS_ALLOW_INSECURE"; +pub const ENV_NOTIFY_PULSAR_TLS_HOSTNAME_VERIFICATION: &str = "RUSTFS_NOTIFY_PULSAR_TLS_HOSTNAME_VERIFICATION"; +pub const ENV_NOTIFY_PULSAR_QUEUE_DIR: &str = "RUSTFS_NOTIFY_PULSAR_QUEUE_DIR"; +pub const ENV_NOTIFY_PULSAR_QUEUE_LIMIT: &str = "RUSTFS_NOTIFY_PULSAR_QUEUE_LIMIT"; + +pub const ENV_NOTIFY_PULSAR_KEYS: &[&str; 11] = &[ + ENV_NOTIFY_PULSAR_ENABLE, + ENV_NOTIFY_PULSAR_BROKER, + ENV_NOTIFY_PULSAR_TOPIC, + ENV_NOTIFY_PULSAR_AUTH_TOKEN, + ENV_NOTIFY_PULSAR_USERNAME, + ENV_NOTIFY_PULSAR_PASSWORD, + ENV_NOTIFY_PULSAR_TLS_CA, + ENV_NOTIFY_PULSAR_TLS_ALLOW_INSECURE, + ENV_NOTIFY_PULSAR_TLS_HOSTNAME_VERIFICATION, + ENV_NOTIFY_PULSAR_QUEUE_DIR, + ENV_NOTIFY_PULSAR_QUEUE_LIMIT, +]; diff --git a/crates/ecstore/src/config/audit.rs b/crates/ecstore/src/config/audit.rs index 79c3ec173..de1e86e8d 100644 --- a/crates/ecstore/src/config/audit.rs +++ b/crates/ecstore/src/config/audit.rs @@ -16,9 +16,13 @@ use crate::config::{KV, KVS}; use rustfs_config::{ COMMENT_KEY, DEFAULT_LIMIT, ENABLE_KEY, EVENT_DEFAULT_DIR, EnableState, MQTT_BROKER, MQTT_KEEP_ALIVE_INTERVAL, MQTT_PASSWORD, MQTT_QOS, MQTT_QUEUE_DIR, MQTT_QUEUE_LIMIT, MQTT_RECONNECT_INTERVAL, MQTT_TLS_CA, MQTT_TLS_CLIENT_CERT, MQTT_TLS_CLIENT_KEY, - MQTT_TLS_POLICY, MQTT_TLS_TRUST_LEAF_AS_CA, MQTT_TOPIC, MQTT_USERNAME, MQTT_WS_PATH_ALLOWLIST, WEBHOOK_AUTH_TOKEN, - WEBHOOK_BATCH_SIZE, WEBHOOK_CLIENT_CA, WEBHOOK_CLIENT_CERT, WEBHOOK_CLIENT_KEY, WEBHOOK_ENDPOINT, WEBHOOK_HTTP_TIMEOUT, - WEBHOOK_MAX_RETRY, WEBHOOK_QUEUE_DIR, WEBHOOK_QUEUE_LIMIT, WEBHOOK_RETRY_INTERVAL, WEBHOOK_SKIP_TLS_VERIFY, + MQTT_TLS_POLICY, MQTT_TLS_TRUST_LEAF_AS_CA, MQTT_TOPIC, MQTT_USERNAME, MQTT_WS_PATH_ALLOWLIST, NATS_ADDRESS, + NATS_CREDENTIALS_FILE, NATS_PASSWORD, NATS_QUEUE_DIR, NATS_QUEUE_LIMIT, NATS_SUBJECT, NATS_TLS_CA, NATS_TLS_CLIENT_CERT, + NATS_TLS_CLIENT_KEY, NATS_TLS_REQUIRED, NATS_TOKEN, NATS_USERNAME, PULSAR_AUTH_TOKEN, PULSAR_BROKER, PULSAR_PASSWORD, + PULSAR_QUEUE_DIR, PULSAR_QUEUE_LIMIT, PULSAR_TLS_ALLOW_INSECURE, PULSAR_TLS_CA, PULSAR_TLS_HOSTNAME_VERIFICATION, + PULSAR_TOPIC, PULSAR_USERNAME, WEBHOOK_AUTH_TOKEN, WEBHOOK_BATCH_SIZE, WEBHOOK_CLIENT_CA, WEBHOOK_CLIENT_CERT, + WEBHOOK_CLIENT_KEY, WEBHOOK_ENDPOINT, WEBHOOK_HTTP_TIMEOUT, WEBHOOK_MAX_RETRY, WEBHOOK_QUEUE_DIR, WEBHOOK_QUEUE_LIMIT, + WEBHOOK_RETRY_INTERVAL, WEBHOOK_SKIP_TLS_VERIFY, }; use std::sync::LazyLock; @@ -192,3 +196,144 @@ pub static DEFAULT_AUDIT_MQTT_KVS: LazyLock = LazyLock::new(|| { }, ]) }); + +pub static DEFAULT_AUDIT_NATS_KVS: LazyLock = LazyLock::new(|| { + KVS(vec![ + KV { + key: ENABLE_KEY.to_owned(), + value: EnableState::Off.to_string(), + hidden_if_empty: false, + }, + KV { + key: NATS_ADDRESS.to_owned(), + value: "".to_owned(), + hidden_if_empty: false, + }, + KV { + key: NATS_SUBJECT.to_owned(), + value: "".to_owned(), + hidden_if_empty: false, + }, + KV { + key: NATS_USERNAME.to_owned(), + value: "".to_owned(), + hidden_if_empty: false, + }, + KV { + key: NATS_PASSWORD.to_owned(), + value: "".to_owned(), + hidden_if_empty: true, + }, + KV { + key: NATS_TOKEN.to_owned(), + value: "".to_owned(), + hidden_if_empty: true, + }, + KV { + key: NATS_CREDENTIALS_FILE.to_owned(), + value: "".to_owned(), + hidden_if_empty: true, + }, + KV { + key: NATS_TLS_CA.to_owned(), + value: "".to_owned(), + hidden_if_empty: true, + }, + KV { + key: NATS_TLS_CLIENT_CERT.to_owned(), + value: "".to_owned(), + hidden_if_empty: true, + }, + KV { + key: NATS_TLS_CLIENT_KEY.to_owned(), + value: "".to_owned(), + hidden_if_empty: true, + }, + KV { + key: NATS_TLS_REQUIRED.to_owned(), + value: EnableState::Off.to_string(), + hidden_if_empty: false, + }, + KV { + key: NATS_QUEUE_DIR.to_owned(), + value: EVENT_DEFAULT_DIR.to_owned(), + hidden_if_empty: false, + }, + KV { + key: NATS_QUEUE_LIMIT.to_owned(), + value: DEFAULT_LIMIT.to_string(), + hidden_if_empty: false, + }, + KV { + key: COMMENT_KEY.to_owned(), + value: "".to_owned(), + hidden_if_empty: false, + }, + ]) +}); + +#[allow(dead_code)] +pub static DEFAULT_AUDIT_PULSAR_KVS: LazyLock = LazyLock::new(|| { + KVS(vec![ + KV { + key: ENABLE_KEY.to_owned(), + value: EnableState::Off.to_string(), + hidden_if_empty: false, + }, + KV { + key: PULSAR_BROKER.to_owned(), + value: "".to_owned(), + hidden_if_empty: false, + }, + KV { + key: PULSAR_TOPIC.to_owned(), + value: "".to_owned(), + hidden_if_empty: false, + }, + KV { + key: PULSAR_AUTH_TOKEN.to_owned(), + value: "".to_owned(), + hidden_if_empty: true, + }, + KV { + key: PULSAR_USERNAME.to_owned(), + value: "".to_owned(), + hidden_if_empty: false, + }, + KV { + key: PULSAR_PASSWORD.to_owned(), + value: "".to_owned(), + hidden_if_empty: true, + }, + KV { + key: PULSAR_TLS_CA.to_owned(), + value: "".to_owned(), + hidden_if_empty: true, + }, + KV { + key: PULSAR_TLS_ALLOW_INSECURE.to_owned(), + value: EnableState::Off.to_string(), + hidden_if_empty: false, + }, + KV { + key: PULSAR_TLS_HOSTNAME_VERIFICATION.to_owned(), + value: EnableState::On.to_string(), + hidden_if_empty: false, + }, + KV { + key: PULSAR_QUEUE_DIR.to_owned(), + value: EVENT_DEFAULT_DIR.to_owned(), + hidden_if_empty: false, + }, + KV { + key: PULSAR_QUEUE_LIMIT.to_owned(), + value: DEFAULT_LIMIT.to_string(), + hidden_if_empty: false, + }, + KV { + key: COMMENT_KEY.to_owned(), + value: "".to_owned(), + hidden_if_empty: false, + }, + ]) +}); diff --git a/crates/ecstore/src/config/com.rs b/crates/ecstore/src/config/com.rs index c3e90d781..05ba080f9 100644 --- a/crates/ecstore/src/config/com.rs +++ b/crates/ecstore/src/config/com.rs @@ -18,8 +18,14 @@ use crate::error::{Error, Result}; use crate::global::is_first_cluster_node_local; use crate::store_api::{ObjectInfo, ObjectOptions, PutObjReader, StorageAPI}; use http::HeaderMap; -use rustfs_config::audit::{AUDIT_MQTT_KEYS, AUDIT_MQTT_SUB_SYS, AUDIT_WEBHOOK_KEYS, AUDIT_WEBHOOK_SUB_SYS}; -use rustfs_config::notify::{NOTIFY_MQTT_KEYS, NOTIFY_MQTT_SUB_SYS, NOTIFY_WEBHOOK_KEYS, NOTIFY_WEBHOOK_SUB_SYS}; +use rustfs_config::audit::{ + AUDIT_MQTT_KEYS, AUDIT_MQTT_SUB_SYS, AUDIT_NATS_KEYS, AUDIT_NATS_SUB_SYS, AUDIT_PULSAR_KEYS, AUDIT_PULSAR_SUB_SYS, + AUDIT_WEBHOOK_KEYS, AUDIT_WEBHOOK_SUB_SYS, +}; +use rustfs_config::notify::{ + NOTIFY_MQTT_KEYS, NOTIFY_MQTT_SUB_SYS, NOTIFY_NATS_KEYS, NOTIFY_NATS_SUB_SYS, NOTIFY_PULSAR_KEYS, NOTIFY_PULSAR_SUB_SYS, + NOTIFY_WEBHOOK_KEYS, NOTIFY_WEBHOOK_SUB_SYS, +}; use rustfs_config::oidc::{IDENTITY_OPENID_KEYS, IDENTITY_OPENID_SUB_SYS, OIDC_REDIRECT_URI_DYNAMIC}; use rustfs_config::{COMMENT_KEY, DEFAULT_DELIMITER, ENABLE_KEY, EnableState, RUSTFS_REGION}; use rustfs_utils::path::SLASH_SEPARATOR; @@ -42,6 +48,72 @@ static SUB_SYSTEMS_DYNAMIC: LazyLock> = LazyLock::new(|| { h }); +#[derive(Clone, Copy)] +struct TargetConfigDescriptor { + external_key: &'static str, + subsystem_key: &'static str, + default_kvs: &'static LazyLock, + valid_keys: &'static [&'static str], +} + +fn notify_target_descriptors() -> [TargetConfigDescriptor; 4] { + [ + TargetConfigDescriptor { + external_key: "webhook", + subsystem_key: NOTIFY_WEBHOOK_SUB_SYS, + default_kvs: ¬ify::DEFAULT_NOTIFY_WEBHOOK_KVS, + valid_keys: NOTIFY_WEBHOOK_KEYS, + }, + TargetConfigDescriptor { + external_key: "mqtt", + subsystem_key: NOTIFY_MQTT_SUB_SYS, + default_kvs: ¬ify::DEFAULT_NOTIFY_MQTT_KVS, + valid_keys: NOTIFY_MQTT_KEYS, + }, + TargetConfigDescriptor { + external_key: "nats", + subsystem_key: NOTIFY_NATS_SUB_SYS, + default_kvs: ¬ify::DEFAULT_NOTIFY_NATS_KVS, + valid_keys: NOTIFY_NATS_KEYS, + }, + TargetConfigDescriptor { + external_key: "pulsar", + subsystem_key: NOTIFY_PULSAR_SUB_SYS, + default_kvs: ¬ify::DEFAULT_NOTIFY_PULSAR_KVS, + valid_keys: NOTIFY_PULSAR_KEYS, + }, + ] +} + +fn audit_target_descriptors() -> [TargetConfigDescriptor; 4] { + [ + TargetConfigDescriptor { + external_key: "webhook", + subsystem_key: AUDIT_WEBHOOK_SUB_SYS, + default_kvs: &audit::DEFAULT_AUDIT_WEBHOOK_KVS, + valid_keys: AUDIT_WEBHOOK_KEYS, + }, + TargetConfigDescriptor { + external_key: "mqtt", + subsystem_key: AUDIT_MQTT_SUB_SYS, + default_kvs: &audit::DEFAULT_AUDIT_MQTT_KVS, + valid_keys: AUDIT_MQTT_KEYS, + }, + TargetConfigDescriptor { + external_key: "nats", + subsystem_key: AUDIT_NATS_SUB_SYS, + default_kvs: &audit::DEFAULT_AUDIT_NATS_KVS, + valid_keys: AUDIT_NATS_KEYS, + }, + TargetConfigDescriptor { + external_key: "pulsar", + subsystem_key: AUDIT_PULSAR_SUB_SYS, + default_kvs: &audit::DEFAULT_AUDIT_PULSAR_KVS, + valid_keys: AUDIT_PULSAR_KEYS, + }, + ] +} + #[instrument(skip(api))] pub async fn read_config(api: Arc, file: &str) -> Result> { let (data, _obj) = read_config_with_metadata(api, file, &ObjectOptions::default()).await?; @@ -363,29 +435,31 @@ fn apply_external_notify_section( applied } +fn apply_external_target_descriptors( + cfg: &mut Config, + section_obj: &Map, + descriptors: &[TargetConfigDescriptor], +) -> bool { + let mut applied = false; + for descriptor in descriptors { + applied |= apply_external_notify_section( + cfg, + section_obj, + descriptor.external_key, + descriptor.subsystem_key, + descriptor.default_kvs, + descriptor.valid_keys, + ); + } + applied +} + fn apply_external_notify_map(cfg: &mut Config, root: &Map) -> bool { let Some(Value::Object(notify_obj)) = root.get("notify") else { return false; }; - let mut applied = false; - applied |= apply_external_notify_section( - cfg, - notify_obj, - "webhook", - NOTIFY_WEBHOOK_SUB_SYS, - ¬ify::DEFAULT_NOTIFY_WEBHOOK_KVS, - NOTIFY_WEBHOOK_KEYS, - ); - applied |= apply_external_notify_section( - cfg, - notify_obj, - "mqtt", - NOTIFY_MQTT_SUB_SYS, - ¬ify::DEFAULT_NOTIFY_MQTT_KVS, - NOTIFY_MQTT_KEYS, - ); - applied + apply_external_target_descriptors(cfg, notify_obj, ¬ify_target_descriptors()) } fn apply_external_audit_map(cfg: &mut Config, root: &Map) -> bool { @@ -394,24 +468,7 @@ fn apply_external_audit_map(cfg: &mut Config, root: &Map) -> bool return false; }; - let mut applied = false; - applied |= apply_external_notify_section( - cfg, - audit_obj, - "webhook", - AUDIT_WEBHOOK_SUB_SYS, - &audit::DEFAULT_AUDIT_WEBHOOK_KVS, - AUDIT_WEBHOOK_KEYS, - ); - applied |= apply_external_notify_section( - cfg, - audit_obj, - "mqtt", - AUDIT_MQTT_SUB_SYS, - &audit::DEFAULT_AUDIT_MQTT_KVS, - AUDIT_MQTT_KEYS, - ); - applied + apply_external_target_descriptors(cfg, audit_obj, &audit_target_descriptors()) } fn apply_external_storage_class_map(cfg: &mut Config, root: &Map) -> bool { @@ -605,7 +662,15 @@ fn build_semantic_oidc_object(cfg: &Config) -> Map { } fn is_notify_bool_key(key: &str) -> bool { - key == ENABLE_KEY || key == rustfs_config::WEBHOOK_SKIP_TLS_VERIFY + matches!( + key, + ENABLE_KEY + | rustfs_config::WEBHOOK_SKIP_TLS_VERIFY + | rustfs_config::MQTT_TLS_TRUST_LEAF_AS_CA + | rustfs_config::NATS_TLS_REQUIRED + | rustfs_config::PULSAR_TLS_ALLOW_INSECURE + | rustfs_config::PULSAR_TLS_HOSTNAME_VERIFICATION + ) } fn encode_notify_scalar_value(key: &str, value: &str) -> Value { @@ -703,38 +768,43 @@ fn build_notify_subsystem_object( subsystem_obj } +fn build_target_object(cfg: &Config, descriptors: &[TargetConfigDescriptor]) -> Map { + let mut target_obj = Map::new(); + for descriptor in descriptors { + let subsystem_obj = + build_notify_subsystem_object(cfg, descriptor.subsystem_key, descriptor.default_kvs, descriptor.valid_keys); + if !subsystem_obj.is_empty() { + target_obj.insert(descriptor.external_key.to_string(), Value::Object(subsystem_obj)); + } + } + target_obj +} + fn build_notify_object(cfg: &Config) -> Map { - let mut notify_obj = Map::new(); - - let webhook_obj = - build_notify_subsystem_object(cfg, NOTIFY_WEBHOOK_SUB_SYS, ¬ify::DEFAULT_NOTIFY_WEBHOOK_KVS, NOTIFY_WEBHOOK_KEYS); - if !webhook_obj.is_empty() { - notify_obj.insert("webhook".to_string(), Value::Object(webhook_obj)); - } - - let mqtt_obj = build_notify_subsystem_object(cfg, NOTIFY_MQTT_SUB_SYS, ¬ify::DEFAULT_NOTIFY_MQTT_KVS, NOTIFY_MQTT_KEYS); - if !mqtt_obj.is_empty() { - notify_obj.insert("mqtt".to_string(), Value::Object(mqtt_obj)); - } - - notify_obj + build_target_object(cfg, ¬ify_target_descriptors()) } fn build_audit_object(cfg: &Config) -> Map { - let mut audit_obj = Map::new(); + build_target_object(cfg, &audit_target_descriptors()) +} - let webhook_obj = - build_notify_subsystem_object(cfg, AUDIT_WEBHOOK_SUB_SYS, &audit::DEFAULT_AUDIT_WEBHOOK_KVS, AUDIT_WEBHOOK_KEYS); - if !webhook_obj.is_empty() { - audit_obj.insert("webhook".to_string(), Value::Object(webhook_obj)); +fn sync_rendered_target_object( + target_obj: &mut Map, + rendered_target: &Map, + descriptors: &[TargetConfigDescriptor], +) { + for descriptor in descriptors { + match rendered_target.get(descriptor.external_key) { + Some(Value::Object(v)) => { + target_obj.insert(descriptor.external_key.to_string(), Value::Object(v.clone())); + target_obj.remove(descriptor.subsystem_key); + } + _ => { + target_obj.remove(descriptor.external_key); + target_obj.remove(descriptor.subsystem_key); + } + } } - - let mqtt_obj = build_notify_subsystem_object(cfg, AUDIT_MQTT_SUB_SYS, &audit::DEFAULT_AUDIT_MQTT_KVS, AUDIT_MQTT_KEYS); - if !mqtt_obj.is_empty() { - audit_obj.insert("mqtt".to_string(), Value::Object(mqtt_obj)); - } - - audit_obj } fn encode_server_config_blob(cfg: &Config, seed: Option<&[u8]>) -> Result> { @@ -771,67 +841,31 @@ fn encode_server_config_blob(cfg: &Config, seed: Option<&[u8]>) -> Result Map::new(), }; let rendered_notify = build_notify_object(cfg); - match rendered_notify.get("webhook") { - Some(Value::Object(v)) => { - notify_obj.insert("webhook".to_string(), Value::Object(v.clone())); - notify_obj.remove(NOTIFY_WEBHOOK_SUB_SYS); - } - _ => { - notify_obj.remove("webhook"); - notify_obj.remove(NOTIFY_WEBHOOK_SUB_SYS); - } - } - match rendered_notify.get("mqtt") { - Some(Value::Object(v)) => { - notify_obj.insert("mqtt".to_string(), Value::Object(v.clone())); - notify_obj.remove(NOTIFY_MQTT_SUB_SYS); - } - _ => { - notify_obj.remove("mqtt"); - notify_obj.remove(NOTIFY_MQTT_SUB_SYS); - } - } + sync_rendered_target_object(&mut notify_obj, &rendered_notify, ¬ify_target_descriptors()); if notify_obj.is_empty() { root.remove("notify"); } else { root.insert("notify".to_string(), Value::Object(notify_obj)); } - root.remove(NOTIFY_WEBHOOK_SUB_SYS); - root.remove(NOTIFY_MQTT_SUB_SYS); + for descriptor in notify_target_descriptors() { + root.remove(descriptor.subsystem_key); + } let mut logger_obj = match root.remove("logger") { Some(Value::Object(v)) => v, _ => Map::new(), }; let rendered_audit = build_audit_object(cfg); - match rendered_audit.get("webhook") { - Some(Value::Object(v)) => { - logger_obj.insert("webhook".to_string(), Value::Object(v.clone())); - logger_obj.remove(AUDIT_WEBHOOK_SUB_SYS); - } - _ => { - logger_obj.remove("webhook"); - logger_obj.remove(AUDIT_WEBHOOK_SUB_SYS); - } - } - match rendered_audit.get("mqtt") { - Some(Value::Object(v)) => { - logger_obj.insert("mqtt".to_string(), Value::Object(v.clone())); - logger_obj.remove(AUDIT_MQTT_SUB_SYS); - } - _ => { - logger_obj.remove("mqtt"); - logger_obj.remove(AUDIT_MQTT_SUB_SYS); - } - } + sync_rendered_target_object(&mut logger_obj, &rendered_audit, &audit_target_descriptors()); if logger_obj.is_empty() { root.remove("logger"); } else { root.insert("logger".to_string(), Value::Object(logger_obj)); } root.remove("audit"); - root.remove(AUDIT_WEBHOOK_SUB_SYS); - root.remove(AUDIT_MQTT_SUB_SYS); + for descriptor in audit_target_descriptors() { + root.remove(descriptor.subsystem_key); + } Ok(serde_json::to_vec(&Value::Object(root))?) } diff --git a/crates/ecstore/src/config/mod.rs b/crates/ecstore/src/config/mod.rs index 9c82e5eac..f505a2670 100644 --- a/crates/ecstore/src/config/mod.rs +++ b/crates/ecstore/src/config/mod.rs @@ -25,8 +25,8 @@ use crate::store::ECStore; use com::{STORAGE_CLASS_SUB_SYS, lookup_configs, read_config_without_migrate}; use rustfs_config::COMMENT_KEY; use rustfs_config::DEFAULT_DELIMITER; -use rustfs_config::audit::{AUDIT_MQTT_SUB_SYS, AUDIT_WEBHOOK_SUB_SYS}; -use rustfs_config::notify::{NOTIFY_MQTT_SUB_SYS, NOTIFY_WEBHOOK_SUB_SYS}; +use rustfs_config::audit::{AUDIT_MQTT_SUB_SYS, AUDIT_NATS_SUB_SYS, AUDIT_PULSAR_SUB_SYS, AUDIT_WEBHOOK_SUB_SYS}; +use rustfs_config::notify::{NOTIFY_MQTT_SUB_SYS, NOTIFY_NATS_SUB_SYS, NOTIFY_PULSAR_SUB_SYS, NOTIFY_WEBHOOK_SUB_SYS}; use rustfs_config::oidc::IDENTITY_OPENID_SUB_SYS; use serde::{Deserialize, Serialize}; use std::collections::HashMap; @@ -241,6 +241,10 @@ pub fn init() { kvs.insert(AUDIT_WEBHOOK_SUB_SYS.to_owned(), audit::DEFAULT_AUDIT_WEBHOOK_KVS.clone()); kvs.insert(NOTIFY_MQTT_SUB_SYS.to_owned(), notify::DEFAULT_NOTIFY_MQTT_KVS.clone()); kvs.insert(AUDIT_MQTT_SUB_SYS.to_owned(), audit::DEFAULT_AUDIT_MQTT_KVS.clone()); + kvs.insert(NOTIFY_NATS_SUB_SYS.to_owned(), notify::DEFAULT_NOTIFY_NATS_KVS.clone()); + kvs.insert(AUDIT_NATS_SUB_SYS.to_owned(), audit::DEFAULT_AUDIT_NATS_KVS.clone()); + kvs.insert(NOTIFY_PULSAR_SUB_SYS.to_owned(), notify::DEFAULT_NOTIFY_PULSAR_KVS.clone()); + kvs.insert(AUDIT_PULSAR_SUB_SYS.to_owned(), audit::DEFAULT_AUDIT_PULSAR_KVS.clone()); kvs.insert(IDENTITY_OPENID_SUB_SYS.to_owned(), oidc::DEFAULT_IDENTITY_OPENID_KVS.clone()); // Register all default configurations diff --git a/crates/ecstore/src/config/notify.rs b/crates/ecstore/src/config/notify.rs index 05ce628ec..7904ef077 100644 --- a/crates/ecstore/src/config/notify.rs +++ b/crates/ecstore/src/config/notify.rs @@ -16,9 +16,12 @@ use crate::config::{KV, KVS}; use rustfs_config::{ COMMENT_KEY, DEFAULT_LIMIT, ENABLE_KEY, EVENT_DEFAULT_DIR, EnableState, MQTT_BROKER, MQTT_KEEP_ALIVE_INTERVAL, MQTT_PASSWORD, MQTT_QOS, MQTT_QUEUE_DIR, MQTT_QUEUE_LIMIT, MQTT_RECONNECT_INTERVAL, MQTT_TLS_CA, MQTT_TLS_CLIENT_CERT, MQTT_TLS_CLIENT_KEY, - MQTT_TLS_POLICY, MQTT_TLS_TRUST_LEAF_AS_CA, MQTT_TOPIC, MQTT_USERNAME, MQTT_WS_PATH_ALLOWLIST, WEBHOOK_AUTH_TOKEN, - WEBHOOK_CLIENT_CA, WEBHOOK_CLIENT_CERT, WEBHOOK_CLIENT_KEY, WEBHOOK_ENDPOINT, WEBHOOK_QUEUE_DIR, WEBHOOK_QUEUE_LIMIT, - WEBHOOK_SKIP_TLS_VERIFY, + MQTT_TLS_POLICY, MQTT_TLS_TRUST_LEAF_AS_CA, MQTT_TOPIC, MQTT_USERNAME, MQTT_WS_PATH_ALLOWLIST, NATS_ADDRESS, + NATS_CREDENTIALS_FILE, NATS_PASSWORD, NATS_QUEUE_DIR, NATS_QUEUE_LIMIT, NATS_SUBJECT, NATS_TLS_CA, NATS_TLS_CLIENT_CERT, + NATS_TLS_CLIENT_KEY, NATS_TLS_REQUIRED, NATS_TOKEN, NATS_USERNAME, PULSAR_AUTH_TOKEN, PULSAR_BROKER, PULSAR_PASSWORD, + PULSAR_QUEUE_DIR, PULSAR_QUEUE_LIMIT, PULSAR_TLS_ALLOW_INSECURE, PULSAR_TLS_CA, PULSAR_TLS_HOSTNAME_VERIFICATION, + PULSAR_TOPIC, PULSAR_USERNAME, WEBHOOK_AUTH_TOKEN, WEBHOOK_CLIENT_CA, WEBHOOK_CLIENT_CERT, WEBHOOK_CLIENT_KEY, + WEBHOOK_ENDPOINT, WEBHOOK_QUEUE_DIR, WEBHOOK_QUEUE_LIMIT, WEBHOOK_SKIP_TLS_VERIFY, }; use std::sync::LazyLock; @@ -171,3 +174,143 @@ pub static DEFAULT_NOTIFY_MQTT_KVS: LazyLock = LazyLock::new(|| { }, ]) }); + +pub static DEFAULT_NOTIFY_NATS_KVS: LazyLock = LazyLock::new(|| { + KVS(vec![ + KV { + key: ENABLE_KEY.to_owned(), + value: EnableState::Off.to_string(), + hidden_if_empty: false, + }, + KV { + key: NATS_ADDRESS.to_owned(), + value: "".to_owned(), + hidden_if_empty: false, + }, + KV { + key: NATS_SUBJECT.to_owned(), + value: "".to_owned(), + hidden_if_empty: false, + }, + KV { + key: NATS_USERNAME.to_owned(), + value: "".to_owned(), + hidden_if_empty: false, + }, + KV { + key: NATS_PASSWORD.to_owned(), + value: "".to_owned(), + hidden_if_empty: true, + }, + KV { + key: NATS_TOKEN.to_owned(), + value: "".to_owned(), + hidden_if_empty: true, + }, + KV { + key: NATS_CREDENTIALS_FILE.to_owned(), + value: "".to_owned(), + hidden_if_empty: true, + }, + KV { + key: NATS_TLS_CA.to_owned(), + value: "".to_owned(), + hidden_if_empty: true, + }, + KV { + key: NATS_TLS_CLIENT_CERT.to_owned(), + value: "".to_owned(), + hidden_if_empty: true, + }, + KV { + key: NATS_TLS_CLIENT_KEY.to_owned(), + value: "".to_owned(), + hidden_if_empty: true, + }, + KV { + key: NATS_TLS_REQUIRED.to_owned(), + value: EnableState::Off.to_string(), + hidden_if_empty: false, + }, + KV { + key: NATS_QUEUE_DIR.to_owned(), + value: EVENT_DEFAULT_DIR.to_owned(), + hidden_if_empty: false, + }, + KV { + key: NATS_QUEUE_LIMIT.to_owned(), + value: DEFAULT_LIMIT.to_string(), + hidden_if_empty: false, + }, + KV { + key: COMMENT_KEY.to_owned(), + value: "".to_owned(), + hidden_if_empty: false, + }, + ]) +}); + +pub static DEFAULT_NOTIFY_PULSAR_KVS: LazyLock = LazyLock::new(|| { + KVS(vec![ + KV { + key: ENABLE_KEY.to_owned(), + value: EnableState::Off.to_string(), + hidden_if_empty: false, + }, + KV { + key: PULSAR_BROKER.to_owned(), + value: "".to_owned(), + hidden_if_empty: false, + }, + KV { + key: PULSAR_TOPIC.to_owned(), + value: "".to_owned(), + hidden_if_empty: false, + }, + KV { + key: PULSAR_AUTH_TOKEN.to_owned(), + value: "".to_owned(), + hidden_if_empty: true, + }, + KV { + key: PULSAR_USERNAME.to_owned(), + value: "".to_owned(), + hidden_if_empty: false, + }, + KV { + key: PULSAR_PASSWORD.to_owned(), + value: "".to_owned(), + hidden_if_empty: true, + }, + KV { + key: PULSAR_TLS_CA.to_owned(), + value: "".to_owned(), + hidden_if_empty: true, + }, + KV { + key: PULSAR_TLS_ALLOW_INSECURE.to_owned(), + value: EnableState::Off.to_string(), + hidden_if_empty: false, + }, + KV { + key: PULSAR_TLS_HOSTNAME_VERIFICATION.to_owned(), + value: EnableState::On.to_string(), + hidden_if_empty: false, + }, + KV { + key: PULSAR_QUEUE_DIR.to_owned(), + value: EVENT_DEFAULT_DIR.to_owned(), + hidden_if_empty: false, + }, + KV { + key: PULSAR_QUEUE_LIMIT.to_owned(), + value: DEFAULT_LIMIT.to_string(), + hidden_if_empty: false, + }, + KV { + key: COMMENT_KEY.to_owned(), + value: "".to_owned(), + hidden_if_empty: false, + }, + ]) +}); diff --git a/crates/notify/src/factory.rs b/crates/notify/src/factory.rs index 7159bba40..1688b7834 100644 --- a/crates/notify/src/factory.rs +++ b/crates/notify/src/factory.rs @@ -14,28 +14,19 @@ use crate::Event; use async_trait::async_trait; -use hashbrown::HashSet; -use rumqttc::QoS; -use rustfs_config::notify::{ENV_NOTIFY_MQTT_KEYS, ENV_NOTIFY_WEBHOOK_KEYS, NOTIFY_MQTT_KEYS, NOTIFY_WEBHOOK_KEYS}; -use rustfs_config::{ - DEFAULT_LIMIT, EVENT_DEFAULT_DIR, MQTT_BROKER, MQTT_KEEP_ALIVE_INTERVAL, MQTT_PASSWORD, MQTT_QOS, MQTT_QUEUE_DIR, - MQTT_QUEUE_LIMIT, MQTT_RECONNECT_INTERVAL, MQTT_TLS_CA, MQTT_TLS_CLIENT_CERT, MQTT_TLS_CLIENT_KEY, MQTT_TLS_POLICY, - MQTT_TLS_TRUST_LEAF_AS_CA, MQTT_TOPIC, MQTT_USERNAME, MQTT_WS_PATH_ALLOWLIST, RUSTFS_WEBHOOK_SKIP_TLS_VERIFY_DEFAULT, - WEBHOOK_AUTH_TOKEN, WEBHOOK_CLIENT_CA, WEBHOOK_CLIENT_CERT, WEBHOOK_CLIENT_KEY, WEBHOOK_ENDPOINT, WEBHOOK_QUEUE_DIR, - WEBHOOK_QUEUE_LIMIT, WEBHOOK_SKIP_TLS_VERIFY, -}; +use rustfs_config::EVENT_DEFAULT_DIR; +use rustfs_config::notify::{NOTIFY_MQTT_KEYS, NOTIFY_NATS_KEYS, NOTIFY_PULSAR_KEYS, NOTIFY_WEBHOOK_KEYS}; use rustfs_ecstore::config::KVS; use rustfs_targets::{ Target, - error::TargetError, - target::{ - mqtt::{MQTTArgs, MQTTTlsConfig, validate_mqtt_broker_url}, - webhook::WebhookArgs, + config::{ + build_mqtt_args, build_nats_args, build_pulsar_args, build_webhook_args, validate_mqtt_config, validate_nats_config, + validate_pulsar_config, validate_webhook_config, }, + error::TargetError, + target::TargetType, }; -use std::time::Duration; -use tracing::{debug, warn}; -use url::Url; +use std::collections::HashSet; /// Trait for creating targets from configuration #[async_trait] @@ -49,10 +40,6 @@ pub trait TargetFactory: Send + Sync { /// Returns a set of valid configuration field names for this target type. /// This is used to filter environment variables. fn get_valid_fields(&self) -> HashSet; - - /// Returns a set of valid configuration env field names for this target type. - /// This is used to filter environment variables. - fn get_valid_env_fields(&self) -> HashSet; } /// Factory for creating Webhook targets @@ -61,75 +48,18 @@ pub struct WebhookTargetFactory; #[async_trait] impl TargetFactory for WebhookTargetFactory { async fn create_target(&self, id: String, config: &KVS) -> Result + Send + Sync>, TargetError> { - // All config values are now read directly from the merged `config` KVS. - let endpoint = config - .lookup(WEBHOOK_ENDPOINT) - .ok_or_else(|| TargetError::Configuration("Missing webhook endpoint".to_string()))?; - let parsed_endpoint = endpoint.trim(); - let endpoint_url = Url::parse(parsed_endpoint) - .map_err(|e| TargetError::Configuration(format!("Invalid endpoint URL: {e} (value: '{parsed_endpoint}')")))?; - - let args = WebhookArgs { - enable: true, // If we are here, it's already enabled. - endpoint: endpoint_url, - auth_token: config.lookup(WEBHOOK_AUTH_TOKEN).unwrap_or_default(), - queue_dir: config - .lookup(WEBHOOK_QUEUE_DIR) - .unwrap_or_else(|| EVENT_DEFAULT_DIR.to_string()), - queue_limit: config - .lookup(WEBHOOK_QUEUE_LIMIT) - .and_then(|v| v.parse::().ok()) - .unwrap_or(DEFAULT_LIMIT), - client_cert: config.lookup(WEBHOOK_CLIENT_CERT).unwrap_or_default(), - client_key: config.lookup(WEBHOOK_CLIENT_KEY).unwrap_or_default(), - client_ca: config.lookup(WEBHOOK_CLIENT_CA).unwrap_or_default(), - skip_tls_verify: config - .lookup(WEBHOOK_SKIP_TLS_VERIFY) - .and_then(|v| v.parse::().ok()) - .unwrap_or(RUSTFS_WEBHOOK_SKIP_TLS_VERIFY_DEFAULT), - target_type: rustfs_targets::target::TargetType::NotifyEvent, - }; - + let args = build_webhook_args(config, EVENT_DEFAULT_DIR, TargetType::NotifyEvent)?; let target = rustfs_targets::target::webhook::WebhookTarget::new(id, args)?; Ok(Box::new(target)) } fn validate_config(&self, _id: &str, config: &KVS) -> Result<(), TargetError> { - // Validation also uses the merged `config` KVS directly. - let endpoint = config - .lookup(WEBHOOK_ENDPOINT) - .ok_or_else(|| TargetError::Configuration("Missing webhook endpoint".to_string()))?; - debug!("endpoint: {}", endpoint); - let parsed_endpoint = endpoint.trim(); - Url::parse(parsed_endpoint) - .map_err(|e| TargetError::Configuration(format!("Invalid endpoint URL: {e} (value: '{parsed_endpoint}')")))?; - - let client_cert = config.lookup(WEBHOOK_CLIENT_CERT).unwrap_or_default(); - let client_key = config.lookup(WEBHOOK_CLIENT_KEY).unwrap_or_default(); - - if client_cert.is_empty() != client_key.is_empty() { - return Err(TargetError::Configuration( - "Both client_cert and client_key must be specified together".to_string(), - )); - } - - let queue_dir = config - .lookup(WEBHOOK_QUEUE_DIR) - .unwrap_or_else(|| EVENT_DEFAULT_DIR.to_string()); - if !queue_dir.is_empty() && !std::path::Path::new(&queue_dir).is_absolute() { - return Err(TargetError::Configuration("Webhook queue directory must be an absolute path".to_string())); - } - - Ok(()) + validate_webhook_config(config, EVENT_DEFAULT_DIR) } fn get_valid_fields(&self) -> HashSet { NOTIFY_WEBHOOK_KEYS.iter().map(|s| s.to_string()).collect() } - - fn get_valid_env_fields(&self) -> HashSet { - ENV_NOTIFY_WEBHOOK_KEYS.iter().map(|s| s.to_string()).collect() - } } /// Factory for creating MQTT targets @@ -138,112 +68,54 @@ pub struct MQTTTargetFactory; #[async_trait] impl TargetFactory for MQTTTargetFactory { async fn create_target(&self, id: String, config: &KVS) -> Result + Send + Sync>, TargetError> { - let broker = config - .lookup(MQTT_BROKER) - .ok_or_else(|| TargetError::Configuration("Missing MQTT broker".to_string()))?; - let broker_url = Url::parse(&broker) - .map_err(|e| TargetError::Configuration(format!("Invalid broker URL: {e} (value: '{broker}')")))?; - - let topic = config - .lookup(MQTT_TOPIC) - .ok_or_else(|| TargetError::Configuration("Missing MQTT topic".to_string()))?; - - let args = MQTTArgs { - enable: true, // Assumed enabled. - broker: broker_url, - topic, - qos: config - .lookup(MQTT_QOS) - .and_then(|v| v.parse::().ok()) - .map(|q| match q { - 0 => QoS::AtMostOnce, - 1 => QoS::AtLeastOnce, - 2 => QoS::ExactlyOnce, - _ => QoS::AtLeastOnce, - }) - .unwrap_or(QoS::AtLeastOnce), - username: config.lookup(MQTT_USERNAME).unwrap_or_default(), - password: config.lookup(MQTT_PASSWORD).unwrap_or_default(), - max_reconnect_interval: config - .lookup(MQTT_RECONNECT_INTERVAL) - .and_then(|v| v.parse::().ok()) - .map(Duration::from_secs) - .unwrap_or_else(|| Duration::from_secs(5)), - keep_alive: config - .lookup(MQTT_KEEP_ALIVE_INTERVAL) - .and_then(|v| v.parse::().ok()) - .map(Duration::from_secs) - .unwrap_or_else(|| Duration::from_secs(30)), - tls: MQTTTlsConfig::from_values( - config.lookup(MQTT_TLS_POLICY).as_deref(), - config.lookup(MQTT_TLS_CA).as_deref(), - config.lookup(MQTT_TLS_CLIENT_CERT).as_deref(), - config.lookup(MQTT_TLS_CLIENT_KEY).as_deref(), - config.lookup(MQTT_TLS_TRUST_LEAF_AS_CA).as_deref(), - config.lookup(MQTT_WS_PATH_ALLOWLIST).as_deref(), - )?, - queue_dir: config.lookup(MQTT_QUEUE_DIR).unwrap_or_else(|| EVENT_DEFAULT_DIR.to_string()), - queue_limit: config - .lookup(MQTT_QUEUE_LIMIT) - .and_then(|v| v.parse::().ok()) - .unwrap_or(DEFAULT_LIMIT), - target_type: rustfs_targets::target::TargetType::NotifyEvent, - }; - + let args = build_mqtt_args(config, EVENT_DEFAULT_DIR, TargetType::NotifyEvent)?; let target = rustfs_targets::target::mqtt::MQTTTarget::new(id, args)?; Ok(Box::new(target)) } fn validate_config(&self, _id: &str, config: &KVS) -> Result<(), TargetError> { - let broker = config - .lookup(MQTT_BROKER) - .ok_or_else(|| TargetError::Configuration("Missing MQTT broker".to_string()))?; - let url = Url::parse(&broker) - .map_err(|e| TargetError::Configuration(format!("Invalid broker URL: {e} (value: '{broker}')")))?; - - let tls = MQTTTlsConfig::from_values( - config.lookup(MQTT_TLS_POLICY).as_deref(), - config.lookup(MQTT_TLS_CA).as_deref(), - config.lookup(MQTT_TLS_CLIENT_CERT).as_deref(), - config.lookup(MQTT_TLS_CLIENT_KEY).as_deref(), - config.lookup(MQTT_TLS_TRUST_LEAF_AS_CA).as_deref(), - config.lookup(MQTT_WS_PATH_ALLOWLIST).as_deref(), - )?; - validate_mqtt_broker_url(&url, &tls)?; - - if config.lookup(MQTT_TOPIC).is_none() { - return Err(TargetError::Configuration("Missing MQTT topic".to_string())); - } - - if let Some(qos_str) = config.lookup(MQTT_QOS) { - let qos = qos_str - .parse::() - .map_err(|_| TargetError::Configuration("Invalid QoS value".to_string()))?; - if qos > 2 { - return Err(TargetError::Configuration("QoS must be 0, 1, or 2".to_string())); - } - } - - let queue_dir = config.lookup(MQTT_QUEUE_DIR).unwrap_or_default(); - if !queue_dir.is_empty() { - if !std::path::Path::new(&queue_dir).is_absolute() { - return Err(TargetError::Configuration("MQTT queue directory must be an absolute path".to_string())); - } - if let Some(qos_str) = config.lookup(MQTT_QOS) - && qos_str == "0" - { - warn!("Using queue_dir with QoS 0 may result in event loss"); - } - } - - Ok(()) + validate_mqtt_config(config) } fn get_valid_fields(&self) -> HashSet { NOTIFY_MQTT_KEYS.iter().map(|s| s.to_string()).collect() } +} - fn get_valid_env_fields(&self) -> HashSet { - ENV_NOTIFY_MQTT_KEYS.iter().map(|s| s.to_string()).collect() +pub struct NATSTargetFactory; + +#[async_trait] +impl TargetFactory for NATSTargetFactory { + async fn create_target(&self, id: String, config: &KVS) -> Result + Send + Sync>, TargetError> { + let args = build_nats_args(config, EVENT_DEFAULT_DIR, TargetType::NotifyEvent)?; + let target = rustfs_targets::target::nats::NATSTarget::new(id, args)?; + Ok(Box::new(target)) + } + + fn validate_config(&self, _id: &str, config: &KVS) -> Result<(), TargetError> { + validate_nats_config(config, EVENT_DEFAULT_DIR) + } + + fn get_valid_fields(&self) -> HashSet { + NOTIFY_NATS_KEYS.iter().map(|s| s.to_string()).collect() + } +} + +pub struct PulsarTargetFactory; + +#[async_trait] +impl TargetFactory for PulsarTargetFactory { + async fn create_target(&self, id: String, config: &KVS) -> Result + Send + Sync>, TargetError> { + let args = build_pulsar_args(config, EVENT_DEFAULT_DIR, TargetType::NotifyEvent)?; + let target = rustfs_targets::target::pulsar::PulsarTarget::new(id, args)?; + Ok(Box::new(target)) + } + + fn validate_config(&self, _id: &str, config: &KVS) -> Result<(), TargetError> { + validate_pulsar_config(config, EVENT_DEFAULT_DIR) + } + + fn get_valid_fields(&self) -> HashSet { + NOTIFY_PULSAR_KEYS.iter().map(|s| s.to_string()).collect() } } diff --git a/crates/notify/src/integration.rs b/crates/notify/src/integration.rs index 12fab50f4..1cce9ff67 100644 --- a/crates/notify/src/integration.rs +++ b/crates/notify/src/integration.rs @@ -19,7 +19,8 @@ use crate::{ }; use hashbrown::HashMap; use rustfs_config::notify::{ - DEFAULT_NOTIFY_TARGET_STREAM_CONCURRENCY, ENV_NOTIFY_TARGET_STREAM_CONCURRENCY, NOTIFY_MQTT_SUB_SYS, NOTIFY_WEBHOOK_SUB_SYS, + DEFAULT_NOTIFY_TARGET_STREAM_CONCURRENCY, ENV_NOTIFY_TARGET_STREAM_CONCURRENCY, NOTIFY_MQTT_SUB_SYS, NOTIFY_NATS_SUB_SYS, + NOTIFY_PULSAR_SUB_SYS, NOTIFY_WEBHOOK_SUB_SYS, }; use rustfs_ecstore::config::{Config, KVS}; use rustfs_s3_common::EventName; @@ -40,6 +41,8 @@ fn subsystem_target_type(target_type: &str) -> &str { match target_type { NOTIFY_WEBHOOK_SUB_SYS => "webhook", NOTIFY_MQTT_SUB_SYS => "mqtt", + NOTIFY_NATS_SUB_SYS => "nats", + NOTIFY_PULSAR_SUB_SYS => "pulsar", _ => target_type, } } @@ -746,4 +749,18 @@ mod tests { assert_eq!(target_id.id, "analytics"); assert_eq!(target_id.name, "mqtt"); } + + #[test] + fn runtime_target_id_for_subsystem_maps_notify_nats_to_runtime_type() { + let target_id = runtime_target_id_for_subsystem(NOTIFY_NATS_SUB_SYS, "Bus"); + assert_eq!(target_id.id, "bus"); + assert_eq!(target_id.name, "nats"); + } + + #[test] + fn runtime_target_id_for_subsystem_maps_notify_pulsar_to_runtime_type() { + let target_id = runtime_target_id_for_subsystem(NOTIFY_PULSAR_SUB_SYS, "Ledger"); + assert_eq!(target_id.id, "ledger"); + assert_eq!(target_id.name, "pulsar"); + } } diff --git a/crates/notify/src/registry.rs b/crates/notify/src/registry.rs index 6b5e3e515..ec60b1861 100644 --- a/crates/notify/src/registry.rs +++ b/crates/notify/src/registry.rs @@ -13,15 +13,14 @@ // limitations under the License. use crate::Event; -use crate::factory::{MQTTTargetFactory, TargetFactory, WebhookTargetFactory}; +use crate::factory::{MQTTTargetFactory, NATSTargetFactory, PulsarTargetFactory, TargetFactory, WebhookTargetFactory}; use futures::stream::{FuturesUnordered, StreamExt}; -use hashbrown::{HashMap, HashSet}; -use rustfs_config::{DEFAULT_DELIMITER, ENABLE_KEY, ENV_PREFIX, EnableState, notify::NOTIFY_ROUTE_PREFIX}; +use hashbrown::HashMap; +use rustfs_config::notify::NOTIFY_ROUTE_PREFIX; use rustfs_ecstore::config::{Config, KVS}; -use rustfs_targets::{Target, TargetError, target::ChannelTargetType}; -use std::str::FromStr; +use rustfs_targets::{Target, TargetError, config::collect_target_configs, target::ChannelTargetType}; use std::sync::Arc; -use tracing::{debug, error, info, warn}; +use tracing::{error, info}; /// Registry for managing target factories pub struct TargetRegistry { @@ -44,6 +43,8 @@ impl TargetRegistry { // Register built-in factories registry.register(ChannelTargetType::Webhook.as_str(), Box::new(WebhookTargetFactory)); registry.register(ChannelTargetType::Mqtt.as_str(), Box::new(MQTTTargetFactory)); + registry.register(ChannelTargetType::Nats.as_str(), Box::new(NATSTargetFactory)); + registry.register(ChannelTargetType::Pulsar.as_str(), Box::new(PulsarTargetFactory)); registry } @@ -85,143 +86,22 @@ impl TargetRegistry { &self, config: &Config, ) -> Result + Send + Sync>>, TargetError> { - // Collect only environment variables with the relevant prefix to reduce memory usage - let all_env: Vec<(String, String)> = std::env::vars().filter(|(key, _)| key.starts_with(ENV_PREFIX)).collect(); - // A collection of asynchronous tasks for concurrently executing target creation let mut tasks = FuturesUnordered::new(); - // 1. Traverse all registered plants and process them by target type for (target_type, factory) in &self.factories { tracing::Span::current().record("target_type", target_type.as_str()); info!("Start working on target types..."); - - // 2. Prepare the configuration source - // 2.1. Get the configuration segment in the file, e.g. 'notify_webhook' - let section_name = format!("{NOTIFY_ROUTE_PREFIX}{target_type}").to_lowercase(); - let file_configs = config.0.get(§ion_name).cloned().unwrap_or_default(); - // 2.2. Get the default configuration for that type - let default_cfg = file_configs.get(DEFAULT_DELIMITER).cloned().unwrap_or_default(); - debug!(?default_cfg, "Get the default configuration"); - - // *** Optimization point 1: Get all legitimate fields of the current target type *** let valid_fields = factory.get_valid_fields(); - debug!(?valid_fields, "Get the legitimate configuration fields"); - - // 3. Resolve instance IDs and configuration overrides from environment variables - let mut instance_ids_from_env = HashSet::new(); - // 3.1. Instance discovery: Based on the '..._ENABLE_INSTANCEID' format - let enable_prefix = - format!("{ENV_PREFIX}{NOTIFY_ROUTE_PREFIX}{target_type}{DEFAULT_DELIMITER}{ENABLE_KEY}{DEFAULT_DELIMITER}") - .to_uppercase(); - for (key, value) in &all_env { - if EnableState::from_str(value).ok().map(|s| s.is_enabled()).unwrap_or(false) - && let Some(id) = key.strip_prefix(&enable_prefix) - && !id.is_empty() - { - instance_ids_from_env.insert(id.to_lowercase()); - } - } - - // 3.2. Parse all relevant environment variable configurations - // 3.2.1. Build environment variable prefixes such as 'RUSTFS_NOTIFY_WEBHOOK_' - let env_prefix = format!("{ENV_PREFIX}{NOTIFY_ROUTE_PREFIX}{target_type}{DEFAULT_DELIMITER}").to_uppercase(); - // 3.2.2. 'env_overrides' is used to store configurations parsed from environment variables in the format: {instance id -> {field -> value}} - let mut env_overrides: HashMap> = HashMap::new(); - for (key, value) in &all_env { - if let Some(rest) = key.strip_prefix(&env_prefix) { - // Use rsplitn to split from the right side to properly extract the INSTANCE_ID at the end - // Format: _ or - let mut parts = rest.rsplitn(2, DEFAULT_DELIMITER); - - // The first part from the right is INSTANCE_ID - let instance_id_part = parts.next().unwrap_or(DEFAULT_DELIMITER); - // The remaining part is FIELD_NAME - let field_name_part = parts.next(); - - let (field_name, instance_id) = match field_name_part { - // Case 1: The format is _ - // e.g., rest = "ENDPOINT_PRIMARY" -> field_name="ENDPOINT", instance_id="PRIMARY" - Some(field) => (field.to_lowercase(), instance_id_part.to_lowercase()), - // Case 2: The format is (without INSTANCE_ID) - // e.g., rest = "ENABLE" -> field_name="ENABLE", instance_id="" (Universal configuration `_ DEFAULT_DELIMITER`) - None => (instance_id_part.to_lowercase(), DEFAULT_DELIMITER.to_string()), - }; - - // *** Optimization point 2: Verify whether the parsed field_name is legal *** - if !field_name.is_empty() && valid_fields.contains(&field_name) { - debug!( - instance_id = %if instance_id.is_empty() { DEFAULT_DELIMITER } else { &instance_id }, - %field_name, - %value, - "Parsing to environment variables" - ); - env_overrides - .entry(instance_id) - .or_default() - .insert(field_name, value.clone()); - } else { - // Ignore illegal field names - warn!( - field_name = %field_name, - "Ignore environment variable fields, not found in the list of valid fields for target type {}", - target_type - ); - } - } - } - debug!(?env_overrides, "Complete the environment variable analysis"); - - // 4. Determine all instance IDs that need to be processed - let mut all_instance_ids: HashSet = - file_configs.keys().filter(|k| *k != DEFAULT_DELIMITER).cloned().collect(); - all_instance_ids.extend(instance_ids_from_env); - debug!(?all_instance_ids, "Determine all instance IDs"); - - // 5. Merge configurations and create tasks for each instance - for id in all_instance_ids { - // 5.1. Merge configuration, priority: Environment variables > File instance configuration > File default configuration - let mut merged_config = default_cfg.clone(); - // Instance-specific configuration in application files - if let Some(file_instance_cfg) = file_configs.get(&id) { - merged_config.extend(file_instance_cfg.clone()); - } - // Application instance-specific environment variable configuration - if let Some(env_instance_cfg) = env_overrides.get(&id) { - // Convert HashMap to KVS - let mut kvs_from_env = KVS::new(); - for (k, v) in env_instance_cfg { - kvs_from_env.insert(k.clone(), v.clone()); - } - merged_config.extend(kvs_from_env); - } - debug!(instance_id = %id, ?merged_config, "Complete configuration merge"); - - // 5.2. Check if the instance is enabled - let enabled = merged_config - .lookup(ENABLE_KEY) - .map(|v| { - EnableState::from_str(v.as_str()) - .ok() - .map(|s| s.is_enabled()) - .unwrap_or(false) - }) - .unwrap_or(false); - - if enabled { - info!(instance_id = %id, "Target is enabled, ready to create a task"); - // 5.3. Create asynchronous tasks for enabled instances - let tid = id.clone(); - let merged_config_arc = Arc::new(merged_config); - tasks.push(async move { - let result = factory.create_target(tid.clone(), &merged_config_arc).await; - (tid, result) - }); - } else { - info!(instance_id = %id, "Skip disabled target"); - } + for (id, merged_config) in collect_target_configs(config, NOTIFY_ROUTE_PREFIX, target_type, &valid_fields) { + info!(instance_id = %id, "Target is enabled, ready to create a task"); + let tid = id.clone(); + let merged_config_arc = Arc::new(merged_config); + tasks.push(async move { + let result = factory.create_target(tid.clone(), &merged_config_arc).await; + (tid, result) + }); } } - // 6. Concurrently execute all creation tasks and collect results let mut successful_targets = Vec::new(); while let Some((id, result)) = tasks.next().await { match result { diff --git a/crates/targets/Cargo.toml b/crates/targets/Cargo.toml index 7dcf53d0b..40c87cc50 100644 --- a/crates/targets/Cargo.toml +++ b/crates/targets/Cargo.toml @@ -13,10 +13,13 @@ documentation = "https://docs.rs/rustfs-target/latest/rustfs_target/" [dependencies] rustfs-config = { workspace = true, features = ["notify", "constants", "audit"] } +rustfs-ecstore = { workspace = true } rustfs-utils = { workspace = true, features = ["notify", "tls"] } rustfs-s3-common = { workspace = true } async-trait = { workspace = true } +async-nats = { workspace = true } hyper-rustls = { workspace = true } +pulsar = { workspace = true } reqwest = { workspace = true } rumqttc = { workspace = true } rustls = { workspace = true } diff --git a/crates/targets/src/check.rs b/crates/targets/src/check.rs index 09d9c8bf8..19dd10210 100644 --- a/crates/targets/src/check.rs +++ b/crates/targets/src/check.rs @@ -92,3 +92,39 @@ pub async fn check_mqtt_broker_available_with_tls( Err(_) => Err(crate::TargetError::Timeout("MQTT connection timed out".to_string())), } } + +pub async fn check_nats_server_available(args: &crate::target::nats::NATSArgs) -> Result<(), crate::TargetError> { + match tokio::time::timeout(std::time::Duration::from_secs(5), async { + let client = crate::target::nats::connect_nats(args).await?; + client + .flush() + .await + .map_err(|e| crate::TargetError::Network(format!("NATS connection check failed: {e}")))?; + client + .drain() + .await + .map_err(|e| crate::TargetError::Network(format!("Failed to close NATS check connection: {e}")))?; + Ok(()) + }) + .await + { + Ok(result) => result, + Err(_) => Err(crate::TargetError::Timeout("NATS connection timed out".to_string())), + } +} + +pub async fn check_pulsar_broker_available(args: &crate::target::pulsar::PulsarArgs) -> Result<(), crate::TargetError> { + match tokio::time::timeout(std::time::Duration::from_secs(5), async { + let client = crate::target::pulsar::connect_pulsar(args).await?; + client + .lookup_partitioned_topic(args.topic.clone()) + .await + .map_err(|e| crate::TargetError::Network(format!("Pulsar topic lookup failed: {e}")))?; + Ok(()) + }) + .await + { + Ok(result) => result, + Err(_) => Err(crate::TargetError::Timeout("Pulsar connection timed out".to_string())), + } +} diff --git a/crates/targets/src/config/common.rs b/crates/targets/src/config/common.rs new file mode 100644 index 000000000..54536bf4c --- /dev/null +++ b/crates/targets/src/config/common.rs @@ -0,0 +1,182 @@ +// 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::TargetError; +use crate::target::pulsar::validate_pulsar_broker; +use async_nats::ServerAddr; +use rustfs_config::{ + DEFAULT_DELIMITER, ENABLE_KEY, EnableState, NATS_CREDENTIALS_FILE, NATS_PASSWORD, NATS_QUEUE_DIR, NATS_SUBJECT, NATS_TLS_CA, + NATS_TLS_CLIENT_CERT, NATS_TLS_CLIENT_KEY, NATS_TOKEN, NATS_USERNAME, PULSAR_AUTH_TOKEN, PULSAR_PASSWORD, PULSAR_QUEUE_DIR, + PULSAR_TLS_ALLOW_INSECURE, PULSAR_TLS_CA, PULSAR_TLS_HOSTNAME_VERIFICATION, PULSAR_TOPIC, PULSAR_USERNAME, +}; +use rustfs_ecstore::config::KVS; +use std::collections::HashSet; +use std::path::Path; +use std::str::FromStr; +use url::Url; + +pub(super) fn split_env_field_and_instance(rest: &str, valid_fields: &HashSet) -> Option<(String, String)> { + let normalized = rest.to_lowercase(); + if valid_fields.contains(&normalized) { + return Some((normalized, DEFAULT_DELIMITER.to_string())); + } + + valid_fields + .iter() + .filter_map(|field| { + normalized + .strip_prefix(field) + .and_then(|suffix| suffix.strip_prefix(DEFAULT_DELIMITER)) + .filter(|instance_id| !instance_id.is_empty()) + .map(|instance_id| (field.clone(), instance_id.to_string())) + }) + .max_by_key(|(field, _)| field.len()) +} + +pub(super) fn is_target_enabled(config: &KVS) -> bool { + config + .lookup(ENABLE_KEY) + .map(|v| { + EnableState::from_str(v.as_str()) + .ok() + .map(|s| s.is_enabled()) + .unwrap_or(false) + }) + .unwrap_or(false) +} + +pub(super) fn parse_target_bool(value: Option<&str>) -> Option { + let value = value?.trim(); + if value.is_empty() { + return None; + } + value + .parse::() + .map(EnableState::is_enabled) + .or_else(|_| value.parse::()) + .ok() +} + +pub(super) fn validate_nats_server_config(server: &ServerAddr, config: &KVS, default_queue_dir: &str) -> Result<(), TargetError> { + if config.lookup(NATS_SUBJECT).unwrap_or_default().trim().is_empty() { + return Err(TargetError::Configuration("Missing NATS subject".to_string())); + } + + if server.has_user_pass() { + return Err(TargetError::Configuration("NATS address must not embed username or password".to_string())); + } + + let username = config.lookup(NATS_USERNAME).unwrap_or_default(); + let password = config.lookup(NATS_PASSWORD).unwrap_or_default(); + let token = config.lookup(NATS_TOKEN).unwrap_or_default(); + let credentials_file = config.lookup(NATS_CREDENTIALS_FILE).unwrap_or_default(); + + let mut auth_methods = 0usize; + if !token.is_empty() { + auth_methods += 1; + } + if !credentials_file.is_empty() { + auth_methods += 1; + if !Path::new(&credentials_file).is_absolute() { + return Err(TargetError::Configuration(format!("{NATS_CREDENTIALS_FILE} must be an absolute path"))); + } + } + if !username.is_empty() || !password.is_empty() { + if username.is_empty() != password.is_empty() { + return Err(TargetError::Configuration( + "NATS username and password must be specified together".to_string(), + )); + } + auth_methods += 1; + } + if auth_methods > 1 { + return Err(TargetError::Configuration( + "NATS supports only one auth method at a time: token, username/password, or credentials_file".to_string(), + )); + } + + let tls_ca = config.lookup(NATS_TLS_CA).unwrap_or_default(); + let tls_client_cert = config.lookup(NATS_TLS_CLIENT_CERT).unwrap_or_default(); + let tls_client_key = config.lookup(NATS_TLS_CLIENT_KEY).unwrap_or_default(); + if !tls_ca.is_empty() && !Path::new(&tls_ca).is_absolute() { + return Err(TargetError::Configuration(format!("{NATS_TLS_CA} must be an absolute path"))); + } + if !tls_client_cert.is_empty() && !Path::new(&tls_client_cert).is_absolute() { + return Err(TargetError::Configuration(format!("{NATS_TLS_CLIENT_CERT} must be an absolute path"))); + } + if !tls_client_key.is_empty() && !Path::new(&tls_client_key).is_absolute() { + return Err(TargetError::Configuration(format!("{NATS_TLS_CLIENT_KEY} must be an absolute path"))); + } + if tls_client_cert.is_empty() != tls_client_key.is_empty() { + return Err(TargetError::Configuration( + "NATS tls_client_cert and tls_client_key must be specified together".to_string(), + )); + } + + let queue_dir = config.lookup(NATS_QUEUE_DIR).unwrap_or_else(|| default_queue_dir.to_string()); + if !queue_dir.is_empty() && !Path::new(&queue_dir).is_absolute() { + return Err(TargetError::Configuration("NATS queue directory must be an absolute path".to_string())); + } + + let _ = server; + Ok(()) +} + +pub(super) fn validate_pulsar_broker_config(broker: &str, config: &KVS, default_queue_dir: &str) -> Result<(), TargetError> { + let url = validate_pulsar_broker(broker)?; + + if config.lookup(PULSAR_TOPIC).unwrap_or_default().trim().is_empty() { + return Err(TargetError::Configuration("Missing Pulsar topic".to_string())); + } + + let auth_token = config.lookup(PULSAR_AUTH_TOKEN).unwrap_or_default(); + let username = config.lookup(PULSAR_USERNAME).unwrap_or_default(); + let password = config.lookup(PULSAR_PASSWORD).unwrap_or_default(); + if !auth_token.is_empty() && (!username.is_empty() || !password.is_empty()) { + return Err(TargetError::Configuration( + "Pulsar supports either auth_token or username/password auth, not both".to_string(), + )); + } + if username.is_empty() != password.is_empty() { + return Err(TargetError::Configuration( + "Pulsar username and password must be specified together".to_string(), + )); + } + + let tls_ca = config.lookup(PULSAR_TLS_CA).unwrap_or_default(); + let tls_allow_insecure = parse_target_bool(config.lookup(PULSAR_TLS_ALLOW_INSECURE).as_deref()).unwrap_or(false); + let tls_hostname_verification = parse_target_bool(config.lookup(PULSAR_TLS_HOSTNAME_VERIFICATION).as_deref()).unwrap_or(true); + + if !tls_ca.is_empty() && !Path::new(&tls_ca).is_absolute() { + return Err(TargetError::Configuration("Pulsar tls_ca must be an absolute path".to_string())); + } + if url.scheme() != "pulsar+ssl" && (!tls_ca.is_empty() || tls_allow_insecure || !tls_hostname_verification) { + return Err(TargetError::Configuration( + "Pulsar TLS settings are only allowed with pulsar+ssl brokers".to_string(), + )); + } + + let queue_dir = config + .lookup(PULSAR_QUEUE_DIR) + .unwrap_or_else(|| default_queue_dir.to_string()); + if !queue_dir.is_empty() && !Path::new(&queue_dir).is_absolute() { + return Err(TargetError::Configuration("Pulsar queue directory must be an absolute path".to_string())); + } + + Ok(()) +} + +pub(super) fn parse_url(value: &str, field_label: &str) -> Result { + Url::parse(value).map_err(|e| TargetError::Configuration(format!("Invalid {field_label}: {e} (value: '{value}')"))) +} diff --git a/crates/targets/src/config/loader.rs b/crates/targets/src/config/loader.rs new file mode 100644 index 000000000..cc74f2e65 --- /dev/null +++ b/crates/targets/src/config/loader.rs @@ -0,0 +1,239 @@ +// 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 super::common::{is_target_enabled, split_env_field_and_instance}; +use rustfs_config::{DEFAULT_DELIMITER, ENABLE_KEY, ENV_PREFIX, EnableState}; +use rustfs_ecstore::config::{Config, KVS}; +use std::collections::{HashMap, HashSet}; +use std::str::FromStr; +use tracing::{debug, warn}; + +pub fn collect_target_configs( + config: &Config, + route_prefix: &str, + target_type: &str, + valid_fields: &HashSet, +) -> Vec<(String, KVS)> { + collect_target_configs_from_env(config, route_prefix, target_type, valid_fields, std::env::vars()) +} + +pub fn collect_env_target_instance_ids(route_prefix: &str, target_type: &str, valid_fields: &HashSet) -> HashSet { + collect_env_target_instance_ids_from_env(route_prefix, target_type, valid_fields, std::env::vars()) +} + +pub fn collect_env_target_instance_ids_from_env( + route_prefix: &str, + target_type: &str, + valid_fields: &HashSet, + env_vars: I, +) -> HashSet +where + I: IntoIterator, +{ + let env_prefix = format!("{ENV_PREFIX}{route_prefix}{target_type}{DEFAULT_DELIMITER}").to_uppercase(); + let mut instance_ids = HashSet::new(); + + for (key, _value) in env_vars.into_iter().filter(|(key, _)| key.starts_with(ENV_PREFIX)) { + let Some(rest) = key.strip_prefix(&env_prefix) else { + continue; + }; + let Some((_field_name, instance_id)) = split_env_field_and_instance(rest, valid_fields) else { + continue; + }; + if instance_id != DEFAULT_DELIMITER && !instance_id.is_empty() { + instance_ids.insert(instance_id); + } + } + + instance_ids +} + +pub fn collect_target_configs_from_env( + config: &Config, + route_prefix: &str, + target_type: &str, + valid_fields: &HashSet, + env_vars: I, +) -> Vec<(String, KVS)> +where + I: IntoIterator, +{ + let all_env: Vec<(String, String)> = env_vars.into_iter().filter(|(key, _)| key.starts_with(ENV_PREFIX)).collect(); + let section_name = format!("{route_prefix}{target_type}").to_lowercase(); + let file_configs = config.0.get(§ion_name).cloned().unwrap_or_default(); + let default_cfg = file_configs.get(DEFAULT_DELIMITER).cloned().unwrap_or_default(); + + let enable_prefix = + format!("{ENV_PREFIX}{route_prefix}{target_type}{DEFAULT_DELIMITER}{ENABLE_KEY}{DEFAULT_DELIMITER}").to_uppercase(); + let env_prefix = format!("{ENV_PREFIX}{route_prefix}{target_type}{DEFAULT_DELIMITER}").to_uppercase(); + + let mut instance_ids_from_env = HashSet::new(); + let mut env_overrides: HashMap = HashMap::new(); + for (key, value) in &all_env { + if EnableState::from_str(value).ok().map(|s| s.is_enabled()).unwrap_or(false) + && let Some(id) = key.strip_prefix(&enable_prefix) + && !id.is_empty() + { + instance_ids_from_env.insert(id.to_lowercase()); + } + + let Some(rest) = key.strip_prefix(&env_prefix) else { + continue; + }; + + let Some((field_name, instance_id)) = split_env_field_and_instance(rest, valid_fields) else { + warn!( + field_name = %rest.to_lowercase(), + "Ignore environment variable field not found in the valid field list for target type {}", + target_type + ); + continue; + }; + + debug!( + instance_id = %if instance_id == DEFAULT_DELIMITER { DEFAULT_DELIMITER } else { &instance_id }, + %field_name, + %value, + "Parsed target environment override" + ); + env_overrides + .entry(instance_id) + .or_default() + .insert(field_name, value.clone()); + } + + let mut effective_default = default_cfg; + if let Some(default_env_cfg) = env_overrides.remove(DEFAULT_DELIMITER) { + effective_default.extend(default_env_cfg); + } + + let mut all_instance_ids: Vec = file_configs + .keys() + .filter(|key| key.as_str() != DEFAULT_DELIMITER) + .cloned() + .collect(); + all_instance_ids.extend(instance_ids_from_env); + all_instance_ids.sort(); + all_instance_ids.dedup(); + + let mut merged_configs = Vec::new(); + for id in all_instance_ids { + let mut merged_config = effective_default.clone(); + if let Some(file_instance_cfg) = file_configs.get(&id) { + merged_config.extend(file_instance_cfg.clone()); + } + if let Some(env_instance_cfg) = env_overrides.get(&id) { + merged_config.extend(env_instance_cfg.clone()); + } + + debug!(instance_id = %id, ?merged_config, "Merged target configuration"); + if is_target_enabled(&merged_config) { + merged_configs.push((id, merged_config)); + } + } + + merged_configs +} + +#[cfg(test)] +mod tests { + use super::{collect_env_target_instance_ids_from_env, collect_target_configs_from_env}; + use rustfs_config::notify::NOTIFY_ROUTE_PREFIX; + use rustfs_config::{ENABLE_KEY, WEBHOOK_ENDPOINT, WEBHOOK_QUEUE_LIMIT}; + use rustfs_ecstore::config::{Config, KVS}; + use std::collections::{HashMap, HashSet}; + + #[test] + fn collect_target_configs_applies_default_env_overrides_to_file_targets() { + let mut cfg = Config(HashMap::new()); + let mut subsystem = HashMap::new(); + + let mut default_kvs = KVS::new(); + default_kvs.insert(ENABLE_KEY.to_string(), "off".to_string()); + subsystem.insert("_".to_string(), default_kvs); + + let mut primary = KVS::new(); + primary.insert(WEBHOOK_ENDPOINT.to_string(), "https://example.com/primary".to_string()); + subsystem.insert("primary".to_string(), primary); + + let mut secondary = KVS::new(); + secondary.insert(WEBHOOK_ENDPOINT.to_string(), "https://example.com/secondary".to_string()); + subsystem.insert("secondary".to_string(), secondary); + + cfg.0.insert("notify_webhook".to_string(), subsystem); + + let configs = collect_target_configs_from_env( + &cfg, + NOTIFY_ROUTE_PREFIX, + "webhook", + &HashSet::from([ + ENABLE_KEY.to_string(), + WEBHOOK_ENDPOINT.to_string(), + WEBHOOK_QUEUE_LIMIT.to_string(), + ]), + vec![ + ("RUSTFS_NOTIFY_WEBHOOK_ENABLE".to_string(), "on".to_string()), + ("RUSTFS_NOTIFY_WEBHOOK_QUEUE_LIMIT".to_string(), "42".to_string()), + ], + ); + + let configs: HashMap = configs.into_iter().collect(); + assert_eq!(configs.len(), 2); + assert_eq!(configs["primary"].lookup(ENABLE_KEY).as_deref(), Some("on")); + assert_eq!(configs["secondary"].lookup(ENABLE_KEY).as_deref(), Some("on")); + assert_eq!(configs["primary"].lookup(WEBHOOK_QUEUE_LIMIT).as_deref(), Some("42")); + assert_eq!(configs["secondary"].lookup(WEBHOOK_QUEUE_LIMIT).as_deref(), Some("42")); + } + + #[test] + fn collect_target_configs_discovers_enabled_instance_from_env() { + let cfg = Config(HashMap::new()); + let configs = collect_target_configs_from_env( + &cfg, + NOTIFY_ROUTE_PREFIX, + "webhook", + &HashSet::from([ENABLE_KEY.to_string(), WEBHOOK_ENDPOINT.to_string()]), + vec![ + ("RUSTFS_NOTIFY_WEBHOOK_ENABLE_PRIMARY".to_string(), "on".to_string()), + ( + "RUSTFS_NOTIFY_WEBHOOK_ENDPOINT_PRIMARY".to_string(), + "https://example.com/from-env".to_string(), + ), + ], + ); + + assert_eq!(configs.len(), 1); + assert_eq!(configs[0].0, "primary"); + assert_eq!(configs[0].1.lookup(WEBHOOK_ENDPOINT).as_deref(), Some("https://example.com/from-env")); + } + + #[test] + fn collect_env_target_instance_ids_handles_keys_with_internal_underscores() { + let ids = collect_env_target_instance_ids_from_env( + NOTIFY_ROUTE_PREFIX, + "webhook", + &HashSet::from([ + ENABLE_KEY.to_string(), + WEBHOOK_ENDPOINT.to_string(), + WEBHOOK_QUEUE_LIMIT.to_string(), + ]), + vec![ + ("RUSTFS_NOTIFY_WEBHOOK_ENABLE_PRIMARY".to_string(), "on".to_string()), + ("RUSTFS_NOTIFY_WEBHOOK_QUEUE_LIMIT_PRIMARY".to_string(), "42".to_string()), + ], + ); + + assert_eq!(ids, HashSet::from(["primary".to_string()])); + } +} diff --git a/crates/targets/src/config/mod.rs b/crates/targets/src/config/mod.rs new file mode 100644 index 000000000..81204027e --- /dev/null +++ b/crates/targets/src/config/mod.rs @@ -0,0 +1,26 @@ +// 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. + +mod common; +mod loader; +mod target_args; + +pub use loader::{ + collect_env_target_instance_ids, collect_env_target_instance_ids_from_env, collect_target_configs, + collect_target_configs_from_env, +}; +pub use target_args::{ + build_mqtt_args, build_nats_args, build_pulsar_args, build_webhook_args, validate_mqtt_config, validate_nats_config, + validate_pulsar_config, validate_webhook_config, +}; diff --git a/crates/targets/src/config/target_args.rs b/crates/targets/src/config/target_args.rs new file mode 100644 index 000000000..f077911aa --- /dev/null +++ b/crates/targets/src/config/target_args.rs @@ -0,0 +1,267 @@ +// 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 super::common::{parse_target_bool, parse_url, validate_nats_server_config, validate_pulsar_broker_config}; +use crate::error::TargetError; +use crate::target::{ + TargetType, + mqtt::{MQTTArgs, MQTTTlsConfig, validate_mqtt_broker_url}, + nats::{NATSArgs, validate_nats_address}, + pulsar::{PulsarArgs, validate_pulsar_broker}, + webhook::WebhookArgs, +}; +use rumqttc::QoS; +use rustfs_config::{ + DEFAULT_LIMIT, MQTT_BROKER, MQTT_KEEP_ALIVE_INTERVAL, MQTT_PASSWORD, MQTT_QOS, MQTT_QUEUE_DIR, MQTT_QUEUE_LIMIT, + MQTT_RECONNECT_INTERVAL, MQTT_TLS_CA, MQTT_TLS_CLIENT_CERT, MQTT_TLS_CLIENT_KEY, MQTT_TLS_POLICY, MQTT_TLS_TRUST_LEAF_AS_CA, + MQTT_TOPIC, MQTT_USERNAME, MQTT_WS_PATH_ALLOWLIST, NATS_ADDRESS, NATS_CREDENTIALS_FILE, NATS_PASSWORD, NATS_QUEUE_DIR, + NATS_QUEUE_LIMIT, NATS_SUBJECT, NATS_TLS_CA, NATS_TLS_CLIENT_CERT, NATS_TLS_CLIENT_KEY, NATS_TLS_REQUIRED, NATS_TOKEN, + NATS_USERNAME, PULSAR_AUTH_TOKEN, PULSAR_BROKER, PULSAR_PASSWORD, PULSAR_QUEUE_DIR, PULSAR_QUEUE_LIMIT, + PULSAR_TLS_ALLOW_INSECURE, PULSAR_TLS_CA, PULSAR_TLS_HOSTNAME_VERIFICATION, PULSAR_TOPIC, PULSAR_USERNAME, + RUSTFS_WEBHOOK_SKIP_TLS_VERIFY_DEFAULT, WEBHOOK_AUTH_TOKEN, WEBHOOK_CLIENT_CA, WEBHOOK_CLIENT_CERT, WEBHOOK_CLIENT_KEY, + WEBHOOK_ENDPOINT, WEBHOOK_QUEUE_DIR, WEBHOOK_QUEUE_LIMIT, WEBHOOK_SKIP_TLS_VERIFY, +}; +use rustfs_ecstore::config::KVS; +use std::path::Path; +use std::time::Duration; + +pub fn build_webhook_args(config: &KVS, default_queue_dir: &str, target_type: TargetType) -> Result { + let endpoint = config + .lookup(WEBHOOK_ENDPOINT) + .ok_or_else(|| TargetError::Configuration("Missing webhook endpoint".to_string()))?; + let parsed_endpoint = endpoint.trim(); + let endpoint_url = parse_url(parsed_endpoint, "endpoint URL")?; + + Ok(WebhookArgs { + enable: true, + endpoint: endpoint_url, + auth_token: config.lookup(WEBHOOK_AUTH_TOKEN).unwrap_or_default(), + queue_dir: config + .lookup(WEBHOOK_QUEUE_DIR) + .unwrap_or_else(|| default_queue_dir.to_string()), + queue_limit: config + .lookup(WEBHOOK_QUEUE_LIMIT) + .and_then(|v| v.parse::().ok()) + .unwrap_or(DEFAULT_LIMIT), + client_cert: config.lookup(WEBHOOK_CLIENT_CERT).unwrap_or_default(), + client_key: config.lookup(WEBHOOK_CLIENT_KEY).unwrap_or_default(), + client_ca: config.lookup(WEBHOOK_CLIENT_CA).unwrap_or_default(), + skip_tls_verify: config + .lookup(WEBHOOK_SKIP_TLS_VERIFY) + .and_then(|v| v.parse::().ok()) + .unwrap_or(RUSTFS_WEBHOOK_SKIP_TLS_VERIFY_DEFAULT), + target_type, + }) +} + +pub fn validate_webhook_config(config: &KVS, default_queue_dir: &str) -> Result<(), TargetError> { + let endpoint = config + .lookup(WEBHOOK_ENDPOINT) + .ok_or_else(|| TargetError::Configuration("Missing webhook endpoint".to_string()))?; + let parsed_endpoint = endpoint.trim(); + let _ = parse_url(parsed_endpoint, "endpoint URL")?; + + let client_cert = config.lookup(WEBHOOK_CLIENT_CERT).unwrap_or_default(); + let client_key = config.lookup(WEBHOOK_CLIENT_KEY).unwrap_or_default(); + if client_cert.is_empty() != client_key.is_empty() { + return Err(TargetError::Configuration( + "Both client_cert and client_key must be specified together".to_string(), + )); + } + + let queue_dir = config + .lookup(WEBHOOK_QUEUE_DIR) + .unwrap_or_else(|| default_queue_dir.to_string()); + if !queue_dir.is_empty() && !Path::new(&queue_dir).is_absolute() { + return Err(TargetError::Configuration("Webhook queue directory must be an absolute path".to_string())); + } + + Ok(()) +} + +pub fn build_mqtt_args(config: &KVS, default_queue_dir: &str, target_type: TargetType) -> Result { + let broker = config + .lookup(MQTT_BROKER) + .ok_or_else(|| TargetError::Configuration("Missing MQTT broker".to_string()))?; + let broker_url = parse_url(&broker, "broker URL")?; + + let topic = config + .lookup(MQTT_TOPIC) + .ok_or_else(|| TargetError::Configuration("Missing MQTT topic".to_string()))?; + + Ok(MQTTArgs { + enable: true, + broker: broker_url, + topic, + qos: config + .lookup(MQTT_QOS) + .and_then(|v| v.parse::().ok()) + .map(|q| match q { + 0 => QoS::AtMostOnce, + 1 => QoS::AtLeastOnce, + 2 => QoS::ExactlyOnce, + _ => QoS::AtLeastOnce, + }) + .unwrap_or(QoS::AtLeastOnce), + username: config.lookup(MQTT_USERNAME).unwrap_or_default(), + password: config.lookup(MQTT_PASSWORD).unwrap_or_default(), + max_reconnect_interval: config + .lookup(MQTT_RECONNECT_INTERVAL) + .and_then(|v| v.parse::().ok()) + .map(Duration::from_secs) + .unwrap_or_else(|| Duration::from_secs(5)), + keep_alive: config + .lookup(MQTT_KEEP_ALIVE_INTERVAL) + .and_then(|v| v.parse::().ok()) + .map(Duration::from_secs) + .unwrap_or_else(|| Duration::from_secs(30)), + tls: MQTTTlsConfig::from_values( + config.lookup(MQTT_TLS_POLICY).as_deref(), + config.lookup(MQTT_TLS_CA).as_deref(), + config.lookup(MQTT_TLS_CLIENT_CERT).as_deref(), + config.lookup(MQTT_TLS_CLIENT_KEY).as_deref(), + config.lookup(MQTT_TLS_TRUST_LEAF_AS_CA).as_deref(), + config.lookup(MQTT_WS_PATH_ALLOWLIST).as_deref(), + )?, + queue_dir: config.lookup(MQTT_QUEUE_DIR).unwrap_or_else(|| default_queue_dir.to_string()), + queue_limit: config + .lookup(MQTT_QUEUE_LIMIT) + .and_then(|v| v.parse::().ok()) + .unwrap_or(DEFAULT_LIMIT), + target_type, + }) +} + +pub fn validate_mqtt_config(config: &KVS) -> Result<(), TargetError> { + let broker = config + .lookup(MQTT_BROKER) + .ok_or_else(|| TargetError::Configuration("Missing MQTT broker".to_string()))?; + let url = parse_url(&broker, "broker URL")?; + + let tls = MQTTTlsConfig::from_values( + config.lookup(MQTT_TLS_POLICY).as_deref(), + config.lookup(MQTT_TLS_CA).as_deref(), + config.lookup(MQTT_TLS_CLIENT_CERT).as_deref(), + config.lookup(MQTT_TLS_CLIENT_KEY).as_deref(), + config.lookup(MQTT_TLS_TRUST_LEAF_AS_CA).as_deref(), + config.lookup(MQTT_WS_PATH_ALLOWLIST).as_deref(), + )?; + validate_mqtt_broker_url(&url, &tls)?; + + if config.lookup(MQTT_TOPIC).is_none() { + return Err(TargetError::Configuration("Missing MQTT topic".to_string())); + } + + if let Some(qos_str) = config.lookup(MQTT_QOS) { + let qos = qos_str + .parse::() + .map_err(|_| TargetError::Configuration("Invalid QoS value".to_string()))?; + if qos > 2 { + return Err(TargetError::Configuration("QoS must be 0, 1, or 2".to_string())); + } + } + + let queue_dir = config.lookup(MQTT_QUEUE_DIR).unwrap_or_default(); + if !queue_dir.is_empty() { + if !Path::new(&queue_dir).is_absolute() { + return Err(TargetError::Configuration("MQTT queue directory must be an absolute path".to_string())); + } + if let Some(qos_str) = config.lookup(MQTT_QOS) + && qos_str == "0" + { + return Err(TargetError::Configuration( + "QoS should be AtLeastOnce (1) or ExactlyOnce (2) if queue_dir is set".to_string(), + )); + } + } + + Ok(()) +} + +pub fn build_nats_args(config: &KVS, default_queue_dir: &str, target_type: TargetType) -> Result { + let address = config + .lookup(NATS_ADDRESS) + .ok_or_else(|| TargetError::Configuration("Missing NATS address".to_string()))?; + validate_nats_address(&address)?; + + let subject = config + .lookup(NATS_SUBJECT) + .ok_or_else(|| TargetError::Configuration("Missing NATS subject".to_string()))?; + + Ok(NATSArgs { + enable: true, + address, + subject, + username: config.lookup(NATS_USERNAME).unwrap_or_default(), + password: config.lookup(NATS_PASSWORD).unwrap_or_default(), + token: config.lookup(NATS_TOKEN).unwrap_or_default(), + credentials_file: config.lookup(NATS_CREDENTIALS_FILE).unwrap_or_default(), + tls_ca: config.lookup(NATS_TLS_CA).unwrap_or_default(), + tls_client_cert: config.lookup(NATS_TLS_CLIENT_CERT).unwrap_or_default(), + tls_client_key: config.lookup(NATS_TLS_CLIENT_KEY).unwrap_or_default(), + tls_required: parse_target_bool(config.lookup(NATS_TLS_REQUIRED).as_deref()).unwrap_or(false), + queue_dir: config.lookup(NATS_QUEUE_DIR).unwrap_or_else(|| default_queue_dir.to_string()), + queue_limit: config + .lookup(NATS_QUEUE_LIMIT) + .and_then(|v| v.parse::().ok()) + .unwrap_or(DEFAULT_LIMIT), + target_type, + }) +} + +pub fn validate_nats_config(config: &KVS, default_queue_dir: &str) -> Result<(), TargetError> { + let address = config + .lookup(NATS_ADDRESS) + .ok_or_else(|| TargetError::Configuration("Missing NATS address".to_string()))?; + let server = validate_nats_address(&address)?; + validate_nats_server_config(&server, config, default_queue_dir) +} + +pub fn build_pulsar_args(config: &KVS, default_queue_dir: &str, target_type: TargetType) -> Result { + let broker = config + .lookup(PULSAR_BROKER) + .ok_or_else(|| TargetError::Configuration("Missing Pulsar broker".to_string()))?; + validate_pulsar_broker(&broker)?; + + let topic = config + .lookup(PULSAR_TOPIC) + .ok_or_else(|| TargetError::Configuration("Missing Pulsar topic".to_string()))?; + + Ok(PulsarArgs { + enable: true, + broker, + topic, + auth_token: config.lookup(PULSAR_AUTH_TOKEN).unwrap_or_default(), + username: config.lookup(PULSAR_USERNAME).unwrap_or_default(), + password: config.lookup(PULSAR_PASSWORD).unwrap_or_default(), + tls_ca: config.lookup(PULSAR_TLS_CA).unwrap_or_default(), + tls_allow_insecure: parse_target_bool(config.lookup(PULSAR_TLS_ALLOW_INSECURE).as_deref()).unwrap_or(false), + tls_hostname_verification: parse_target_bool(config.lookup(PULSAR_TLS_HOSTNAME_VERIFICATION).as_deref()).unwrap_or(true), + queue_dir: config + .lookup(PULSAR_QUEUE_DIR) + .unwrap_or_else(|| default_queue_dir.to_string()), + queue_limit: config + .lookup(PULSAR_QUEUE_LIMIT) + .and_then(|v| v.parse::().ok()) + .unwrap_or(DEFAULT_LIMIT), + target_type, + }) +} + +pub fn validate_pulsar_config(config: &KVS, default_queue_dir: &str) -> Result<(), TargetError> { + let broker = config + .lookup(PULSAR_BROKER) + .ok_or_else(|| TargetError::Configuration("Missing Pulsar broker".to_string()))?; + validate_pulsar_broker_config(&broker, config, default_queue_dir) +} diff --git a/crates/targets/src/lib.rs b/crates/targets/src/lib.rs index ef684e23d..8562f4e02 100644 --- a/crates/targets/src/lib.rs +++ b/crates/targets/src/lib.rs @@ -14,12 +14,15 @@ pub mod arn; mod check; +pub mod config; pub mod error; pub mod store; pub mod sys; pub mod target; -pub use check::{check_mqtt_broker_available, check_mqtt_broker_available_with_tls}; +pub use check::{ + check_mqtt_broker_available, check_mqtt_broker_available_with_tls, check_nats_server_available, check_pulsar_broker_available, +}; pub use error::{StoreError, TargetError}; pub use rustfs_s3_common::EventName; use serde::{Deserialize, Serialize}; diff --git a/crates/targets/src/target/mod.rs b/crates/targets/src/target/mod.rs index 8412b82a2..8a26c2e64 100644 --- a/crates/targets/src/target/mod.rs +++ b/crates/targets/src/target/mod.rs @@ -26,6 +26,8 @@ use std::time::{SystemTime, UNIX_EPOCH}; use tracing::warn; pub mod mqtt; +pub mod nats; +pub mod pulsar; pub mod webhook; /// A read-only snapshot of delivery counters for a target. @@ -286,6 +288,8 @@ pub enum ChannelTargetType { Webhook, Kafka, Mqtt, + Nats, + Pulsar, } impl ChannelTargetType { @@ -294,6 +298,8 @@ impl ChannelTargetType { ChannelTargetType::Webhook => "webhook", ChannelTargetType::Kafka => "kafka", ChannelTargetType::Mqtt => "mqtt", + ChannelTargetType::Nats => "nats", + ChannelTargetType::Pulsar => "pulsar", } } } @@ -304,6 +310,8 @@ impl std::fmt::Display for ChannelTargetType { ChannelTargetType::Webhook => write!(f, "webhook"), ChannelTargetType::Kafka => write!(f, "kafka"), ChannelTargetType::Mqtt => write!(f, "mqtt"), + ChannelTargetType::Nats => write!(f, "nats"), + ChannelTargetType::Pulsar => write!(f, "pulsar"), } } } diff --git a/crates/targets/src/target/nats.rs b/crates/targets/src/target/nats.rs new file mode 100644 index 000000000..5076ad01c --- /dev/null +++ b/crates/targets/src/target/nats.rs @@ -0,0 +1,367 @@ +// 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::{ + StoreError, Target, TargetLog, + arn::TargetID, + error::TargetError, + store::{Key, QueueStore, Store}, + target::{ + ChannelTargetType, EntityTarget, QueuedPayload, QueuedPayloadMeta, TargetDeliveryCounters, TargetDeliverySnapshot, + TargetType, + }, +}; +use async_trait::async_trait; +use rustfs_config::{NATS_CREDENTIALS_FILE, NATS_TLS_CA, NATS_TLS_CLIENT_CERT, NATS_TLS_CLIENT_KEY}; +use serde::Serialize; +use serde::de::DeserializeOwned; +use std::path::{Path, PathBuf}; +use std::str::FromStr; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{Arc, Mutex}; +use tracing::{error, info, instrument}; + +#[derive(Debug, Clone)] +pub struct NATSArgs { + pub enable: bool, + pub address: String, + pub subject: String, + pub username: String, + pub password: String, + pub token: String, + pub credentials_file: String, + pub tls_ca: String, + pub tls_client_cert: String, + pub tls_client_key: String, + pub tls_required: bool, + pub queue_dir: String, + pub queue_limit: u64, + pub target_type: TargetType, +} + +impl NATSArgs { + pub fn validate(&self) -> Result<(), TargetError> { + if !self.enable { + return Ok(()); + } + + validate_nats_address(&self.address)?; + validate_nats_auth(self)?; + + if self.subject.trim().is_empty() || self.subject.chars().any(char::is_whitespace) { + return Err(TargetError::Configuration( + "NATS subject cannot be empty or contain whitespace".to_string(), + )); + } + + if !self.credentials_file.is_empty() && !Path::new(&self.credentials_file).is_absolute() { + return Err(TargetError::Configuration(format!("{NATS_CREDENTIALS_FILE} must be an absolute path"))); + } + if !self.tls_ca.is_empty() && !Path::new(&self.tls_ca).is_absolute() { + return Err(TargetError::Configuration(format!("{NATS_TLS_CA} must be an absolute path"))); + } + if !self.tls_client_cert.is_empty() && !Path::new(&self.tls_client_cert).is_absolute() { + return Err(TargetError::Configuration(format!("{NATS_TLS_CLIENT_CERT} must be an absolute path"))); + } + if !self.tls_client_key.is_empty() && !Path::new(&self.tls_client_key).is_absolute() { + return Err(TargetError::Configuration(format!("{NATS_TLS_CLIENT_KEY} must be an absolute path"))); + } + if self.tls_client_cert.is_empty() != self.tls_client_key.is_empty() { + return Err(TargetError::Configuration( + "NATS tls_client_cert and tls_client_key must be specified together".to_string(), + )); + } + + if !self.queue_dir.is_empty() && !Path::new(&self.queue_dir).is_absolute() { + return Err(TargetError::Configuration("NATS queue directory must be an absolute path".to_string())); + } + + Ok(()) + } +} + +pub fn validate_nats_address(address: &str) -> Result { + let server = async_nats::ServerAddr::from_str(address) + .map_err(|e| TargetError::Configuration(format!("Invalid NATS address: {e}")))?; + + if server.has_user_pass() { + return Err(TargetError::Configuration("NATS address must not embed username or password".to_string())); + } + + Ok(server) +} + +fn validate_nats_auth(args: &NATSArgs) -> Result<(), TargetError> { + let mut auth_methods = 0usize; + + if !args.token.is_empty() { + auth_methods += 1; + } + + if !args.credentials_file.is_empty() { + auth_methods += 1; + } + + let has_user = !args.username.is_empty(); + let has_password = !args.password.is_empty(); + if has_user || has_password { + if has_user != has_password { + return Err(TargetError::Configuration( + "NATS username and password must be specified together".to_string(), + )); + } + auth_methods += 1; + } + + if auth_methods > 1 { + return Err(TargetError::Configuration( + "NATS supports only one auth method at a time: token, username/password, or credentials_file".to_string(), + )); + } + + Ok(()) +} + +pub async fn connect_nats(args: &NATSArgs) -> Result { + args.validate()?; + + let mut options = async_nats::ConnectOptions::new().require_tls(args.tls_required); + + if !args.token.is_empty() { + options = options.token(args.token.clone()); + } else if !args.username.is_empty() { + options = options.user_and_password(args.username.clone(), args.password.clone()); + } else if !args.credentials_file.is_empty() { + options = options + .credentials_file(&args.credentials_file) + .await + .map_err(|e| TargetError::Configuration(format!("Failed to load NATS credentials file: {e}")))?; + } + + if !args.tls_ca.is_empty() { + options = options.add_root_certificates(PathBuf::from(&args.tls_ca)); + } + if !args.tls_client_cert.is_empty() { + options = options.add_client_certificate(PathBuf::from(&args.tls_client_cert), PathBuf::from(&args.tls_client_key)); + } + + options + .connect(args.address.clone()) + .await + .map_err(|e| TargetError::Network(format!("Failed to connect to NATS server: {e}"))) +} + +pub struct NATSTarget +where + E: Send + Sync + 'static + Clone + Serialize + DeserializeOwned, +{ + id: TargetID, + args: NATSArgs, + client: Mutex>, + store: Option + Send + Sync>>, + connected: AtomicBool, + delivery_counters: Arc, + _phantom: std::marker::PhantomData, +} + +impl NATSTarget +where + E: Send + Sync + 'static + Clone + Serialize + DeserializeOwned, +{ + pub fn clone_box(&self) -> Box + Send + Sync> { + Box::new(NATSTarget:: { + id: self.id.clone(), + args: self.args.clone(), + client: Mutex::new(self.client.lock().unwrap().clone()), + store: self.store.as_ref().map(|s| s.boxed_clone()), + connected: AtomicBool::new(self.connected.load(Ordering::SeqCst)), + delivery_counters: Arc::clone(&self.delivery_counters), + _phantom: std::marker::PhantomData, + }) + } + + #[instrument(skip(args), fields(target_id_as_string = %id))] + pub fn new(id: String, args: NATSArgs) -> Result { + args.validate()?; + let target_id = TargetID::new(id, ChannelTargetType::Nats.as_str().to_string()); + let queue_store = if !args.queue_dir.is_empty() { + let base_path = PathBuf::from(&args.queue_dir); + let specific_queue_path = base_path.join(format!("rustfs-{}-{}", ChannelTargetType::Nats.as_str(), target_id.id)); + let extension = match args.target_type { + TargetType::AuditLog => rustfs_config::audit::AUDIT_STORE_EXTENSION, + TargetType::NotifyEvent => rustfs_config::notify::NOTIFY_STORE_EXTENSION, + }; + let store = QueueStore::::new(specific_queue_path, args.queue_limit, extension); + if let Err(e) = store.open() { + error!(target_id = %target_id, error = %e, "Failed to open store for NATS target"); + return Err(TargetError::Storage(format!("{e}"))); + } + Some(Box::new(store) as Box + Send + Sync>) + } else { + None + }; + + Ok(Self { + id: target_id, + args, + client: Mutex::new(None), + store: queue_store, + connected: AtomicBool::new(false), + delivery_counters: Arc::new(TargetDeliveryCounters::default()), + _phantom: std::marker::PhantomData, + }) + } + + async fn get_or_connect(&self) -> Result { + if let Some(client) = self.client.lock().unwrap().clone() { + return Ok(client); + } + + let client = connect_nats(&self.args).await?; + client + .flush() + .await + .map_err(|e| TargetError::Network(format!("Failed to flush NATS connection: {e}")))?; + self.connected.store(true, Ordering::SeqCst); + + let mut guard = self.client.lock().unwrap(); + let shared = guard.get_or_insert_with(|| client.clone()).clone(); + Ok(shared) + } + + fn build_queued_payload(&self, event: &EntityTarget) -> Result { + let object_name = crate::target::decode_object_name(&event.object_name)?; + let key = format!("{}/{}", event.bucket_name, object_name); + let log = TargetLog { + event_name: event.event_name, + key, + records: vec![event.clone()], + }; + let body = serde_json::to_vec(&log).map_err(|e| TargetError::Serialization(format!("Failed to serialize event: {e}")))?; + let meta = QueuedPayloadMeta::new( + event.event_name, + event.bucket_name.clone(), + event.object_name.clone(), + "application/json", + body.len(), + ); + Ok(QueuedPayload::new(meta, body)) + } + + async fn send_body(&self, body: Vec) -> Result<(), TargetError> { + let client = self.get_or_connect().await?; + client + .publish(self.args.subject.clone(), body.into()) + .await + .map_err(|e| TargetError::Request(format!("Failed to publish NATS message: {e}")))?; + self.delivery_counters.record_success(); + Ok(()) + } +} + +#[async_trait] +impl Target for NATSTarget +where + E: Send + Sync + 'static + Clone + Serialize + DeserializeOwned, +{ + fn id(&self) -> TargetID { + self.id.clone() + } + + async fn is_active(&self) -> Result { + let client = self.get_or_connect().await?; + client + .flush() + .await + .map_err(|e| TargetError::Network(format!("NATS health check failed: {e}")))?; + Ok(true) + } + + async fn save(&self, event: Arc>) -> Result<(), TargetError> { + let queued = match self.build_queued_payload(&event) { + Ok(queued) => queued, + Err(err) => { + self.delivery_counters.record_final_failure(); + return Err(err); + } + }; + + if let Some(store) = &self.store { + let encoded = match queued.encode() { + Ok(encoded) => encoded, + Err(err) => { + self.delivery_counters.record_final_failure(); + return Err(TargetError::Storage(format!("Failed to encode queued payload: {err}"))); + } + }; + if let Err(e) = store.put_raw(&encoded) { + self.delivery_counters.record_final_failure(); + return Err(TargetError::Storage(format!("Failed to save event to store: {e}"))); + } + Ok(()) + } else { + if let Err(err) = self.send_body(queued.body).await { + self.delivery_counters.record_final_failure(); + return Err(err); + } + Ok(()) + } + } + + async fn send_raw_from_store(&self, _key: Key, body: Vec, _meta: QueuedPayloadMeta) -> Result<(), TargetError> { + self.send_body(body).await + } + + async fn close(&self) -> Result<(), TargetError> { + let client = self.client.lock().unwrap().take(); + self.connected.store(false, Ordering::SeqCst); + if let Some(client) = client { + client + .drain() + .await + .map_err(|e| TargetError::Network(format!("Failed to drain NATS client: {e}")))?; + } + info!(target_id = %self.id, "NATS target closed"); + Ok(()) + } + + fn store(&self) -> Option<&(dyn Store + Send + Sync)> { + self.store.as_deref() + } + + fn clone_dyn(&self) -> Box + Send + Sync> { + self.clone_box() + } + + async fn init(&self) -> Result<(), TargetError> { + if !self.is_enabled() { + return Ok(()); + } + let _ = self.get_or_connect().await?; + Ok(()) + } + + fn is_enabled(&self) -> bool { + self.args.enable + } + + fn delivery_snapshot(&self) -> TargetDeliverySnapshot { + self.delivery_counters + .snapshot(self.store.as_deref().map_or(0, |store| store.len() as u64)) + } + + fn record_final_failure(&self) { + self.delivery_counters.record_final_failure(); + } +} diff --git a/crates/targets/src/target/pulsar.rs b/crates/targets/src/target/pulsar.rs new file mode 100644 index 000000000..1c6090f87 --- /dev/null +++ b/crates/targets/src/target/pulsar.rs @@ -0,0 +1,387 @@ +// 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::{ + StoreError, Target, TargetLog, + arn::TargetID, + error::TargetError, + store::{Key, QueueStore, Store}, + target::{ + ChannelTargetType, EntityTarget, QueuedPayload, QueuedPayloadMeta, TargetDeliveryCounters, TargetDeliverySnapshot, + TargetType, + }, +}; +use async_trait::async_trait; +use pulsar::{Authentication, Producer, Pulsar, TokioExecutor}; +use serde::Serialize; +use serde::de::DeserializeOwned; +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{Arc, Mutex}; +use tokio::sync::Mutex as AsyncMutex; +use tracing::{error, info, instrument}; +use url::Url; + +#[derive(Debug, Clone)] +pub struct PulsarArgs { + pub enable: bool, + pub broker: String, + pub topic: String, + pub auth_token: String, + pub username: String, + pub password: String, + pub tls_ca: String, + pub tls_allow_insecure: bool, + pub tls_hostname_verification: bool, + pub queue_dir: String, + pub queue_limit: u64, + pub target_type: TargetType, +} + +impl PulsarArgs { + pub fn validate(&self) -> Result<(), TargetError> { + if !self.enable { + return Ok(()); + } + + validate_pulsar_broker(&self.broker)?; + + if self.topic.trim().is_empty() { + return Err(TargetError::Configuration("Pulsar topic cannot be empty".to_string())); + } + + if !self.auth_token.is_empty() && (!self.username.is_empty() || !self.password.is_empty()) { + return Err(TargetError::Configuration( + "Pulsar supports either auth_token or username/password auth, not both".to_string(), + )); + } + + if self.username.is_empty() != self.password.is_empty() { + return Err(TargetError::Configuration( + "Pulsar username and password must be specified together".to_string(), + )); + } + + if !self.tls_ca.is_empty() && !Path::new(&self.tls_ca).is_absolute() { + return Err(TargetError::Configuration("Pulsar tls_ca must be an absolute path".to_string())); + } + + if !self.queue_dir.is_empty() && !Path::new(&self.queue_dir).is_absolute() { + return Err(TargetError::Configuration("Pulsar queue directory must be an absolute path".to_string())); + } + + let parsed = Url::parse(&self.broker) + .map_err(|e| TargetError::Configuration(format!("Invalid Pulsar broker URL: {e} (value: '{}')", self.broker)))?; + let tls_enabled = parsed.scheme() == "pulsar+ssl"; + if !tls_enabled && (!self.tls_ca.is_empty() || self.tls_allow_insecure || !self.tls_hostname_verification) { + return Err(TargetError::Configuration( + "Pulsar TLS settings are only allowed with pulsar+ssl brokers".to_string(), + )); + } + + Ok(()) + } +} + +pub fn validate_pulsar_broker(broker: &str) -> Result { + let url = Url::parse(broker) + .map_err(|e| TargetError::Configuration(format!("Invalid Pulsar broker URL: {e} (value: '{broker}')")))?; + + match url.scheme() { + "pulsar" | "pulsar+ssl" => {} + _ => { + return Err(TargetError::Configuration( + "Pulsar broker must use pulsar:// or pulsar+ssl://".to_string(), + )); + } + } + + if !url.username().is_empty() || url.password().is_some() { + return Err(TargetError::Configuration( + "Pulsar broker URL must not embed username or password".to_string(), + )); + } + + if url.host_str().is_none() { + return Err(TargetError::Configuration("Pulsar broker is missing host".to_string())); + } + + Ok(url) +} + +pub async fn connect_pulsar(args: &PulsarArgs) -> Result, TargetError> { + args.validate()?; + + let mut builder = Pulsar::builder(args.broker.clone(), TokioExecutor); + + if !args.auth_token.is_empty() { + builder = builder.with_auth(Authentication { + name: "token".to_string(), + data: args.auth_token.clone().into_bytes(), + }); + } else if !args.username.is_empty() { + builder = + builder.with_auth_provider(pulsar::authentication::basic::BasicAuthentication::new(&args.username, &args.password)); + } + + if !args.tls_ca.is_empty() { + builder = builder + .with_certificate_chain_file(&args.tls_ca) + .map_err(|e| TargetError::Configuration(format!("Failed to load Pulsar tls_ca: {e}")))?; + } + + builder = builder + .with_allow_insecure_connection(args.tls_allow_insecure) + .with_tls_hostname_verification_enabled(args.tls_hostname_verification); + + builder + .build() + .await + .map_err(|e| TargetError::Network(format!("Failed to connect to Pulsar broker: {e}"))) +} + +pub struct PulsarTarget +where + E: Send + Sync + 'static + Clone + Serialize + DeserializeOwned, +{ + id: TargetID, + args: PulsarArgs, + client: Mutex>>, + producer: AsyncMutex>>, + store: Option + Send + Sync>>, + connected: AtomicBool, + delivery_counters: Arc, + _phantom: std::marker::PhantomData, +} + +impl PulsarTarget +where + E: Send + Sync + 'static + Clone + Serialize + DeserializeOwned, +{ + pub fn clone_box(&self) -> Box + Send + Sync> { + Box::new(PulsarTarget:: { + id: self.id.clone(), + args: self.args.clone(), + client: Mutex::new(self.client.lock().unwrap().clone()), + producer: AsyncMutex::new(None), + store: self.store.as_ref().map(|s| s.boxed_clone()), + connected: AtomicBool::new(self.connected.load(Ordering::SeqCst)), + delivery_counters: Arc::clone(&self.delivery_counters), + _phantom: std::marker::PhantomData, + }) + } + + #[instrument(skip(args), fields(target_id_as_string = %id))] + pub fn new(id: String, args: PulsarArgs) -> Result { + args.validate()?; + let target_id = TargetID::new(id, ChannelTargetType::Pulsar.as_str().to_string()); + let queue_store = if !args.queue_dir.is_empty() { + let base_path = PathBuf::from(&args.queue_dir); + let specific_queue_path = base_path.join(format!("rustfs-{}-{}", ChannelTargetType::Pulsar.as_str(), target_id.id)); + let extension = match args.target_type { + TargetType::AuditLog => rustfs_config::audit::AUDIT_STORE_EXTENSION, + TargetType::NotifyEvent => rustfs_config::notify::NOTIFY_STORE_EXTENSION, + }; + let store = QueueStore::::new(specific_queue_path, args.queue_limit, extension); + if let Err(e) = store.open() { + error!(target_id = %target_id, error = %e, "Failed to open store for Pulsar target"); + return Err(TargetError::Storage(format!("{e}"))); + } + Some(Box::new(store) as Box + Send + Sync>) + } else { + None + }; + + Ok(Self { + id: target_id, + args, + client: Mutex::new(None), + producer: AsyncMutex::new(None), + store: queue_store, + connected: AtomicBool::new(false), + delivery_counters: Arc::new(TargetDeliveryCounters::default()), + _phantom: std::marker::PhantomData, + }) + } + + async fn get_or_connect_client(&self) -> Result, TargetError> { + if let Some(client) = self.client.lock().unwrap().clone() { + return Ok(client); + } + + let client = connect_pulsar(&self.args).await?; + self.connected.store(true, Ordering::SeqCst); + let mut guard = self.client.lock().unwrap(); + let shared = guard.get_or_insert_with(|| client.clone()).clone(); + Ok(shared) + } + + async fn init_producer(&self) -> Result<(), TargetError> { + if self.producer.lock().await.is_some() { + return Ok(()); + } + + let client = self.get_or_connect_client().await?; + let producer = client + .producer() + .with_topic(self.args.topic.clone()) + .with_name(self.id.id.clone()) + .build() + .await + .map_err(|e| TargetError::Network(format!("Failed to create Pulsar producer: {e}")))?; + + let mut guard = self.producer.lock().await; + if guard.is_none() { + *guard = Some(producer); + } + Ok(()) + } + + fn build_queued_payload(&self, event: &EntityTarget) -> Result { + let object_name = crate::target::decode_object_name(&event.object_name)?; + let key = format!("{}/{}", event.bucket_name, object_name); + let log = TargetLog { + event_name: event.event_name, + key, + records: vec![event.clone()], + }; + let body = serde_json::to_vec(&log).map_err(|e| TargetError::Serialization(format!("Failed to serialize event: {e}")))?; + let meta = QueuedPayloadMeta::new( + event.event_name, + event.bucket_name.clone(), + event.object_name.clone(), + "application/json", + body.len(), + ); + Ok(QueuedPayload::new(meta, body)) + } + + async fn send_body(&self, body: Vec) -> Result<(), TargetError> { + self.init_producer().await?; + let mut guard = self.producer.lock().await; + let producer = guard + .as_mut() + .ok_or_else(|| TargetError::Configuration("Pulsar producer not initialized".to_string()))?; + let receipt = producer + .send_non_blocking(body) + .await + .map_err(|e| TargetError::Request(format!("Failed to send Pulsar message: {e}")))?; + receipt + .await + .map_err(|e| TargetError::Request(format!("Failed to receive Pulsar receipt: {e}")))?; + self.delivery_counters.record_success(); + Ok(()) + } +} + +#[async_trait] +impl Target for PulsarTarget +where + E: Send + Sync + 'static + Clone + Serialize + DeserializeOwned, +{ + fn id(&self) -> TargetID { + self.id.clone() + } + + async fn is_active(&self) -> Result { + self.init_producer().await?; + let guard = self.producer.lock().await; + let producer = guard + .as_ref() + .ok_or_else(|| TargetError::Configuration("Pulsar producer not initialized".to_string()))?; + producer + .check_connection() + .await + .map_err(|e| TargetError::Network(format!("Pulsar health check failed: {e}")))?; + Ok(true) + } + + async fn save(&self, event: Arc>) -> Result<(), TargetError> { + let queued = match self.build_queued_payload(&event) { + Ok(queued) => queued, + Err(err) => { + self.delivery_counters.record_final_failure(); + return Err(err); + } + }; + + if let Some(store) = &self.store { + let encoded = match queued.encode() { + Ok(encoded) => encoded, + Err(err) => { + self.delivery_counters.record_final_failure(); + return Err(TargetError::Storage(format!("Failed to encode queued payload: {err}"))); + } + }; + if let Err(e) = store.put_raw(&encoded) { + self.delivery_counters.record_final_failure(); + return Err(TargetError::Storage(format!("Failed to save event to store: {e}"))); + } + Ok(()) + } else { + if let Err(err) = self.send_body(queued.body).await { + self.delivery_counters.record_final_failure(); + return Err(err); + } + Ok(()) + } + } + + async fn send_raw_from_store(&self, _key: Key, body: Vec, _meta: QueuedPayloadMeta) -> Result<(), TargetError> { + self.send_body(body).await + } + + async fn close(&self) -> Result<(), TargetError> { + let mut producer = self.producer.lock().await; + if let Some(producer) = producer.as_mut() { + producer + .close() + .await + .map_err(|e| TargetError::Network(format!("Failed to close Pulsar producer: {e}")))?; + } + *producer = None; + self.client.lock().unwrap().take(); + self.connected.store(false, Ordering::SeqCst); + info!(target_id = %self.id, "Pulsar target closed"); + Ok(()) + } + + fn store(&self) -> Option<&(dyn Store + Send + Sync)> { + self.store.as_deref() + } + + fn clone_dyn(&self) -> Box + Send + Sync> { + self.clone_box() + } + + async fn init(&self) -> Result<(), TargetError> { + if !self.is_enabled() { + return Ok(()); + } + self.init_producer().await + } + + fn is_enabled(&self) -> bool { + self.args.enable + } + + fn delivery_snapshot(&self) -> TargetDeliverySnapshot { + self.delivery_counters + .snapshot(self.store.as_deref().map_or(0, |store| store.len() as u64)) + } + + fn record_final_failure(&self) { + self.delivery_counters.record_final_failure(); + } +} diff --git a/rustfs/src/admin/handlers/audit.rs b/rustfs/src/admin/handlers/audit.rs index 124511274..cb2f06381 100644 --- a/rustfs/src/admin/handlers/audit.rs +++ b/rustfs/src/admin/handlers/audit.rs @@ -14,6 +14,13 @@ use crate::admin::{ auth::validate_admin_request, + handlers::target_descriptor::{ + AdminTargetSpec, AdminTargetValidator, EndpointKey, TargetDomain, allowed_target_keys, + collect_config_entry_keys as shared_collect_config_entry_keys, + collect_configured_endpoint_keys as shared_collect_configured_endpoint_keys, + collect_env_endpoint_keys as shared_collect_env_endpoint_keys, normalized_endpoint_key, target_service_name, target_spec, + validate_target_request, + }, router::{AdminOperation, Operation, S3Router}, }; use crate::auth::{check_key_valid, get_session_token}; @@ -24,22 +31,20 @@ use http::{HeaderMap, StatusCode}; use hyper::Method; use matchit::Params; use rustfs_audit::{audit_system, start_audit_system as start_global_audit_system, system::AuditSystemState}; -use rustfs_config::audit::{AUDIT_MQTT_KEYS, AUDIT_MQTT_SUB_SYS, AUDIT_ROUTE_PREFIX, AUDIT_WEBHOOK_KEYS, AUDIT_WEBHOOK_SUB_SYS}; -use rustfs_config::{DEFAULT_DELIMITER, ENABLE_KEY, ENV_PREFIX, EnableState, MAX_ADMIN_REQUEST_BODY_SIZE}; +use rustfs_config::audit::{ + AUDIT_MQTT_KEYS, AUDIT_MQTT_SUB_SYS, AUDIT_NATS_KEYS, AUDIT_NATS_SUB_SYS, AUDIT_PULSAR_KEYS, AUDIT_PULSAR_SUB_SYS, + AUDIT_ROUTE_PREFIX, AUDIT_WEBHOOK_KEYS, AUDIT_WEBHOOK_SUB_SYS, +}; +use rustfs_config::{AUDIT_DEFAULT_DIR, DEFAULT_DELIMITER, ENABLE_KEY, EnableState, MAX_ADMIN_REQUEST_BODY_SIZE}; use rustfs_ecstore::config::Config; use rustfs_policy::policy::action::{Action, AdminAction}; -use rustfs_targets::{TargetError, check_mqtt_broker_available_with_tls, target::mqtt::MQTTTlsConfig}; use s3s::{Body, S3Request, S3Response, S3Result, header::CONTENT_TYPE, s3_error}; use serde::{Deserialize, Serialize}; use std::collections::{HashMap, HashSet}; -use std::future::Future; -use std::io::{Error, ErrorKind}; -use std::path::Path; use std::sync::Arc; use tokio::sync::Semaphore; -use tokio::time::{Duration, sleep, timeout}; +use tokio::time::{Duration, timeout}; use tracing::{Span, warn}; -use url::Url; pub fn register_audit_target_route(r: &mut S3Router) -> std::io::Result<()> { r.insert( @@ -87,8 +92,6 @@ struct AuditEndpointsResponse { audit_endpoints: Vec, } -type EndpointKey = (String, String); - #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)] #[serde(rename_all = "lowercase")] enum AuditEndpointSource { @@ -98,8 +101,33 @@ enum AuditEndpointSource { Runtime, } -fn normalized_endpoint_key(account_id: &str, service: &str) -> EndpointKey { - (account_id.to_lowercase(), service.to_string()) +fn audit_target_specs() -> [AdminTargetSpec; 4] { + [ + AdminTargetSpec { + subsystem: AUDIT_WEBHOOK_SUB_SYS, + service: "webhook", + valid_keys: AUDIT_WEBHOOK_KEYS, + validator: AdminTargetValidator::Webhook, + }, + AdminTargetSpec { + subsystem: AUDIT_MQTT_SUB_SYS, + service: "mqtt", + valid_keys: AUDIT_MQTT_KEYS, + validator: AdminTargetValidator::Mqtt, + }, + AdminTargetSpec { + subsystem: AUDIT_NATS_SUB_SYS, + service: "nats", + valid_keys: AUDIT_NATS_KEYS, + validator: AdminTargetValidator::Nats(TargetDomain::Audit), + }, + AdminTargetSpec { + subsystem: AUDIT_PULSAR_SUB_SYS, + service: "pulsar", + valid_keys: AUDIT_PULSAR_KEYS, + validator: AdminTargetValidator::Pulsar(TargetDomain::Audit), + }, + ] } async fn authorize_audit_admin_request(req: &S3Request, action: AdminAction) -> S3Result<()> { @@ -121,58 +149,9 @@ fn build_response(status: StatusCode, body: Body, request_id: Option<&http::Head S3Response::with_headers((status, body), header) } -async fn retry_with_backoff(mut operation: F, max_attempts: usize, base_delay: Duration) -> Result -where - F: FnMut() -> Fut, - Fut: Future>, -{ - let mut attempts = 0; - let mut delay = base_delay; - let mut last_err = None; - - while attempts < max_attempts { - match operation().await { - Ok(result) => return Ok(result), - Err(e) => { - last_err = Some(e); - attempts += 1; - if attempts < max_attempts { - sleep(delay).await; - delay = delay.saturating_mul(2); - } - } - } - } - Err(last_err.unwrap_or_else(|| Error::other("retry_with_backoff: unknown error"))) -} - -async fn validate_queue_dir(queue_dir: &str) -> S3Result<()> { - if !queue_dir.is_empty() { - if !Path::new(queue_dir).is_absolute() { - return Err(s3_error!(InvalidArgument, "queue_dir must be absolute path")); - } - retry_with_backoff( - || async { tokio::fs::metadata(queue_dir).await.map(|_| ()) }, - 3, - Duration::from_millis(100), - ) - .await - .map_err(|e| match e.kind() { - ErrorKind::NotFound => s3_error!(InvalidArgument, "queue_dir does not exist"), - ErrorKind::PermissionDenied => s3_error!(InvalidArgument, "queue_dir exists but permission denied"), - _ => s3_error!(InvalidArgument, "failed to access queue_dir: {}", e), - })?; - } - Ok(()) -} - -fn config_enable_is_on(value: &str) -> bool { - matches!(value.trim().to_ascii_lowercase().as_str(), "on" | "true" | "yes" | "1") -} - fn has_any_audit_targets(config: &Config) -> bool { - for subsystem in [AUDIT_WEBHOOK_SUB_SYS, AUDIT_MQTT_SUB_SYS] { - let Some(targets) = config.0.get(subsystem) else { + for spec in audit_target_specs() { + let Some(targets) = config.0.get(spec.subsystem) else { continue; }; if targets.keys().any(|key| key != DEFAULT_DELIMITER) { @@ -183,73 +162,15 @@ fn has_any_audit_targets(config: &Config) -> bool { } fn collect_configured_audit_endpoint_keys(config: &Config) -> Vec { - let mut endpoints = Vec::new(); - for (subsystem, service) in [(AUDIT_WEBHOOK_SUB_SYS, "webhook"), (AUDIT_MQTT_SUB_SYS, "mqtt")] { - let Some(targets) = config.0.get(subsystem) else { - continue; - }; - - for (target_name, kvs) in targets { - if target_name == DEFAULT_DELIMITER { - continue; - } - let enabled = kvs.lookup(ENABLE_KEY).as_deref().map(config_enable_is_on).unwrap_or(false); - if enabled { - endpoints.push((target_name.clone(), service.to_string())); - } - } - } - endpoints + shared_collect_configured_endpoint_keys(&audit_target_specs(), config) } fn collect_config_entry_keys(config: &Config) -> HbHashSet { - let mut endpoints = HbHashSet::new(); - for (subsystem, service) in [(AUDIT_WEBHOOK_SUB_SYS, "webhook"), (AUDIT_MQTT_SUB_SYS, "mqtt")] { - let Some(targets) = config.0.get(subsystem) else { - continue; - }; - - for target_name in targets.keys() { - if target_name == DEFAULT_DELIMITER { - continue; - } - endpoints.insert(normalized_endpoint_key(target_name, service)); - } - } - endpoints + shared_collect_config_entry_keys(&audit_target_specs(), config) } fn collect_env_endpoint_keys() -> HbHashSet { - let mut endpoints = HbHashSet::new(); - - for (service, valid_keys) in [("webhook", AUDIT_WEBHOOK_KEYS), ("mqtt", AUDIT_MQTT_KEYS)] { - let env_prefix = format!("{ENV_PREFIX}{AUDIT_ROUTE_PREFIX}{service}{DEFAULT_DELIMITER}").to_uppercase(); - - for (key, _value) in std::env::vars() { - let Some(rest) = key.strip_prefix(&env_prefix) else { - continue; - }; - - let mut parts = rest.rsplitn(2, DEFAULT_DELIMITER); - let instance_id_part = parts.next().unwrap_or(DEFAULT_DELIMITER); - let field_name_part = parts.next(); - - let (field_name, instance_id) = match field_name_part { - Some(field) => (field.to_lowercase(), instance_id_part.to_lowercase()), - None => (instance_id_part.to_lowercase(), DEFAULT_DELIMITER.to_string()), - }; - - if instance_id == DEFAULT_DELIMITER || instance_id.is_empty() { - continue; - } - - if valid_keys.contains(&field_name.as_str()) { - endpoints.insert(normalized_endpoint_key(&instance_id, service)); - } - } - } - - endpoints + shared_collect_env_endpoint_keys(&audit_target_specs(), AUDIT_ROUTE_PREFIX) } fn classify_audit_endpoint_source( @@ -268,11 +189,7 @@ fn classify_audit_endpoint_source( fn audit_endpoint_source(config: &Config, target_type: &str, target_name: &str) -> AuditEndpointSource { let config_targets = collect_config_entry_keys(config); let env_targets = collect_env_endpoint_keys(); - let service = match target_type { - AUDIT_WEBHOOK_SUB_SYS => "webhook", - AUDIT_MQTT_SUB_SYS => "mqtt", - _ => "", - }; + let service = target_service_name(&audit_target_specs(), target_type).unwrap_or_default(); let key = normalized_endpoint_key(target_name, service); classify_audit_endpoint_source(&config_targets, &env_targets, &key) @@ -384,7 +301,7 @@ fn extract_target_params<'a>(params: &'a Params<'_, '_>) -> S3Result<(&'a str, & let target_type = params .get("target_type") .ok_or_else(|| s3_error!(InvalidArgument, "missing required parameter: 'target_type'"))?; - if target_type != AUDIT_WEBHOOK_SUB_SYS && target_type != AUDIT_MQTT_SUB_SYS { + if target_service_name(&audit_target_specs(), target_type).is_none() { return Err(s3_error!(InvalidArgument, "unsupported audit target type: '{}'", target_type)); } let target_name = params @@ -486,77 +403,16 @@ impl Operation for AuditTargetConfig { let audit_body: AuditTargetBody = serde_json::from_slice(&body_bytes) .map_err(|e| s3_error!(InvalidArgument, "invalid json body for audit target config: {}", e))?; - let allowed_keys: HashSet<&str> = match target_type { - AUDIT_WEBHOOK_SUB_SYS => AUDIT_WEBHOOK_KEYS.iter().cloned().collect(), - AUDIT_MQTT_SUB_SYS => AUDIT_MQTT_KEYS.iter().cloned().collect(), - _ => unreachable!(), - }; + let specs = audit_target_specs(); + let allowed_keys: HashSet<&str> = allowed_target_keys(&specs, target_type); let kv_map = collect_validated_key_values(&audit_body.key_values, &allowed_keys, target_type)?; - if target_type == AUDIT_WEBHOOK_SUB_SYS { - let endpoint = kv_map - .get("endpoint") - .map(String::as_str) - .ok_or_else(|| s3_error!(InvalidArgument, "endpoint is required"))?; - let parsed_endpoint = Url::parse(endpoint).map_err(|e| s3_error!(InvalidArgument, "invalid endpoint url: {}", e))?; - match parsed_endpoint.scheme() { - "http" | "https" => {} - other => { - return Err(s3_error!( - InvalidArgument, - "unsupported endpoint scheme: {} (only http and https are allowed)", - other - )); - } - } - if let Some(queue_dir) = kv_map.get("queue_dir") { - validate_queue_dir(queue_dir.as_str()).await?; - } - if kv_map.contains_key("client_cert") != kv_map.contains_key("client_key") { - return Err(s3_error!(InvalidArgument, "client_cert and client_key must be specified as a pair")); - } - } else if target_type == AUDIT_MQTT_SUB_SYS { - let endpoint = kv_map - .get(rustfs_config::MQTT_BROKER) - .map(String::as_str) - .ok_or_else(|| s3_error!(InvalidArgument, "broker endpoint is required"))?; - let topic = kv_map - .get(rustfs_config::MQTT_TOPIC) - .map(String::as_str) - .ok_or_else(|| s3_error!(InvalidArgument, "topic is required"))?; - let username = kv_map.get(rustfs_config::MQTT_USERNAME).map(String::as_str); - let password = kv_map.get(rustfs_config::MQTT_PASSWORD).map(String::as_str); - let tls = MQTTTlsConfig::from_values( - kv_map.get(rustfs_config::MQTT_TLS_POLICY).map(String::as_str), - kv_map.get(rustfs_config::MQTT_TLS_CA).map(String::as_str), - kv_map.get(rustfs_config::MQTT_TLS_CLIENT_CERT).map(String::as_str), - kv_map.get(rustfs_config::MQTT_TLS_CLIENT_KEY).map(String::as_str), - kv_map.get(rustfs_config::MQTT_TLS_TRUST_LEAF_AS_CA).map(String::as_str), - kv_map.get(rustfs_config::MQTT_WS_PATH_ALLOWLIST).map(String::as_str), - ) - .map_err(|e| s3_error!(InvalidArgument, "invalid MQTT TLS settings: {}", e))?; - let parsed_broker = Url::parse(endpoint).map_err(|e| s3_error!(InvalidArgument, "invalid broker URL: {}", e))?; - rustfs_targets::target::mqtt::validate_mqtt_broker_url(&parsed_broker, &tls) - .map_err(|e| s3_error!(InvalidArgument, "{}", e))?; - check_mqtt_broker_available_with_tls(parsed_broker.as_str(), topic, username, password, &tls) - .await - .map_err(|e| match e { - TargetError::Configuration(_) => s3_error!(InvalidArgument, "{}", e), - _ => s3_error!(InvalidArgument, "MQTT broker check failed: {}", e), - })?; - - if let Some(queue_dir) = kv_map.get("queue_dir") { - validate_queue_dir(queue_dir.as_str()).await?; - if let Some(qos) = kv_map.get("qos") { - match qos.parse::() { - Ok(1) | Ok(2) => {} - Ok(0) => return Err(s3_error!(InvalidArgument, "qos should be 1 or 2 if queue_dir is set")), - _ => return Err(s3_error!(InvalidArgument, "qos must be an integer 0, 1, or 2")), - } - } - } - } + let spec = target_spec(&specs, target_type) + .ok_or_else(|| s3_error!(InvalidArgument, "unsupported audit target type: '{}'", target_type))?; + timeout(Duration::from_secs(10), validate_target_request(spec, &kv_map, AUDIT_DEFAULT_DIR)) + .await + .map_err(|_| s3_error!(InvalidArgument, "audit target validation timed out"))??; let mut kvs = rustfs_ecstore::config::KVS::new(); for (key, value) in kv_map { @@ -656,6 +512,7 @@ impl Operation for RemoveAuditTarget { mod tests { use super::*; use matchit::Router; + use rustfs_config::ENV_PREFIX; use rustfs_ecstore::config::{KV, KVS}; use std::collections::{HashMap, HashSet}; use temp_env::{with_var, with_vars, with_vars_unset}; diff --git a/rustfs/src/admin/handlers/event.rs b/rustfs/src/admin/handlers/event.rs index 2247c733e..9f93ac167 100644 --- a/rustfs/src/admin/handlers/event.rs +++ b/rustfs/src/admin/handlers/event.rs @@ -14,6 +14,13 @@ use crate::admin::{ auth::validate_admin_request, + handlers::target_descriptor::{ + AdminTargetSpec, AdminTargetValidator, EndpointKey, TargetDomain, allowed_target_keys, + collect_config_entry_keys as shared_collect_config_entry_keys, + collect_configured_endpoint_keys as shared_collect_configured_endpoint_keys, + collect_env_endpoint_keys as shared_collect_env_endpoint_keys, normalized_endpoint_key, target_service_name, target_spec, + validate_target_request, + }, router::{AdminOperation, Operation, S3Router}, }; use crate::auth::{check_key_valid, get_session_token}; @@ -24,23 +31,19 @@ use http::{HeaderMap, StatusCode}; use hyper::Method; use matchit::Params; use rustfs_config::notify::{ - NOTIFY_MQTT_KEYS, NOTIFY_MQTT_SUB_SYS, NOTIFY_ROUTE_PREFIX, NOTIFY_WEBHOOK_KEYS, NOTIFY_WEBHOOK_SUB_SYS, + NOTIFY_MQTT_KEYS, NOTIFY_MQTT_SUB_SYS, NOTIFY_NATS_KEYS, NOTIFY_NATS_SUB_SYS, NOTIFY_PULSAR_KEYS, NOTIFY_PULSAR_SUB_SYS, + NOTIFY_ROUTE_PREFIX, NOTIFY_WEBHOOK_KEYS, NOTIFY_WEBHOOK_SUB_SYS, }; -use rustfs_config::{DEFAULT_DELIMITER, ENABLE_KEY, ENV_PREFIX, EnableState, MAX_ADMIN_REQUEST_BODY_SIZE}; +use rustfs_config::{ENABLE_KEY, EVENT_DEFAULT_DIR, EnableState, MAX_ADMIN_REQUEST_BODY_SIZE}; use rustfs_ecstore::config::Config; use rustfs_policy::policy::action::{Action, AdminAction}; -use rustfs_targets::{TargetError, check_mqtt_broker_available_with_tls, target::mqtt::MQTTTlsConfig}; use s3s::{Body, S3Request, S3Response, S3Result, header::CONTENT_TYPE, s3_error}; use serde::{Deserialize, Serialize}; use std::collections::{HashMap, HashSet}; -use std::future::Future; -use std::io::{Error, ErrorKind}; -use std::path::Path; use std::sync::Arc; use tokio::sync::Semaphore; -use tokio::time::{Duration, sleep, timeout}; +use tokio::time::{Duration, timeout}; use tracing::{Span, info, warn}; -use url::Url; pub fn register_notification_target_route(r: &mut S3Router) -> std::io::Result<()> { r.insert( @@ -94,8 +97,6 @@ struct NotificationEndpointsResponse { notification_endpoints: Vec, } -type EndpointKey = (String, String); - #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)] #[serde(rename_all = "lowercase")] enum NotificationEndpointSource { @@ -105,8 +106,33 @@ enum NotificationEndpointSource { Runtime, } -fn normalized_endpoint_key(account_id: &str, service: &str) -> EndpointKey { - (account_id.to_lowercase(), service.to_string()) +fn notification_target_specs() -> [AdminTargetSpec; 4] { + [ + AdminTargetSpec { + subsystem: NOTIFY_WEBHOOK_SUB_SYS, + service: "webhook", + valid_keys: NOTIFY_WEBHOOK_KEYS, + validator: AdminTargetValidator::Webhook, + }, + AdminTargetSpec { + subsystem: NOTIFY_MQTT_SUB_SYS, + service: "mqtt", + valid_keys: NOTIFY_MQTT_KEYS, + validator: AdminTargetValidator::Mqtt, + }, + AdminTargetSpec { + subsystem: NOTIFY_NATS_SUB_SYS, + service: "nats", + valid_keys: NOTIFY_NATS_KEYS, + validator: AdminTargetValidator::Nats(TargetDomain::Notify), + }, + AdminTargetSpec { + subsystem: NOTIFY_PULSAR_SUB_SYS, + service: "pulsar", + valid_keys: NOTIFY_PULSAR_KEYS, + validator: AdminTargetValidator::Pulsar(TargetDomain::Notify), + }, + ] } // --- Helper Functions --- @@ -134,123 +160,16 @@ fn build_response(status: StatusCode, body: Body, request_id: Option<&http::Head S3Response::with_headers((status, body), header) } -async fn retry_with_backoff(mut operation: F, max_attempts: usize, base_delay: Duration) -> Result -where - F: FnMut() -> Fut, - Fut: Future>, -{ - let mut attempts = 0; - let mut delay = base_delay; - let mut last_err = None; - - while attempts < max_attempts { - match operation().await { - Ok(result) => return Ok(result), - Err(e) => { - last_err = Some(e); - attempts += 1; - if attempts < max_attempts { - sleep(delay).await; - delay = delay.saturating_mul(2); - } - } - } - } - Err(last_err.unwrap_or_else(|| Error::other("retry_with_backoff: unknown error"))) -} - -async fn validate_queue_dir(queue_dir: &str) -> S3Result<()> { - if !queue_dir.is_empty() { - if !Path::new(queue_dir).is_absolute() { - return Err(s3_error!(InvalidArgument, "queue_dir must be absolute path")); - } - retry_with_backoff( - || async { tokio::fs::metadata(queue_dir).await.map(|_| ()) }, - 3, - Duration::from_millis(100), - ) - .await - .map_err(|e| match e.kind() { - ErrorKind::NotFound => s3_error!(InvalidArgument, "queue_dir does not exist"), - ErrorKind::PermissionDenied => s3_error!(InvalidArgument, "queue_dir exists but permission denied"), - _ => s3_error!(InvalidArgument, "failed to access queue_dir: {}", e), - })?; - } - Ok(()) -} - -fn config_enable_is_on(value: &str) -> bool { - matches!(value.trim().to_ascii_lowercase().as_str(), "on" | "true" | "yes" | "1") -} - fn collect_configured_endpoint_keys(config: &Config) -> Vec { - let mut endpoints = Vec::new(); - for (subsystem, service) in [(NOTIFY_WEBHOOK_SUB_SYS, "webhook"), (NOTIFY_MQTT_SUB_SYS, "mqtt")] { - let Some(targets) = config.0.get(subsystem) else { - continue; - }; - - for (target_name, kvs) in targets { - if target_name == DEFAULT_DELIMITER { - continue; - } - let enabled = kvs.lookup(ENABLE_KEY).as_deref().map(config_enable_is_on).unwrap_or(false); - if enabled { - endpoints.push((target_name.clone(), service.to_string())); - } - } - } - endpoints + shared_collect_configured_endpoint_keys(¬ification_target_specs(), config) } fn collect_config_entry_keys(config: &Config) -> HbHashSet { - let mut endpoints = HbHashSet::new(); - for (subsystem, service) in [(NOTIFY_WEBHOOK_SUB_SYS, "webhook"), (NOTIFY_MQTT_SUB_SYS, "mqtt")] { - let Some(targets) = config.0.get(subsystem) else { - continue; - }; - - for target_name in targets.keys() { - if target_name == DEFAULT_DELIMITER { - continue; - } - endpoints.insert(normalized_endpoint_key(target_name, service)); - } - } - endpoints + shared_collect_config_entry_keys(¬ification_target_specs(), config) } fn collect_env_endpoint_keys() -> HbHashSet { - let mut endpoints = HbHashSet::new(); - - for (service, valid_keys) in [("webhook", NOTIFY_WEBHOOK_KEYS), ("mqtt", NOTIFY_MQTT_KEYS)] { - let env_prefix = format!("{ENV_PREFIX}{NOTIFY_ROUTE_PREFIX}{service}{DEFAULT_DELIMITER}").to_uppercase(); - - for (key, _value) in std::env::vars() { - let Some(rest) = key.strip_prefix(&env_prefix) else { - continue; - }; - - let mut parts = rest.rsplitn(2, DEFAULT_DELIMITER); - let instance_id_part = parts.next().unwrap_or(DEFAULT_DELIMITER); - let field_name_part = parts.next(); - - let (field_name, instance_id) = match field_name_part { - Some(field) => (field.to_lowercase(), instance_id_part.to_lowercase()), - None => (instance_id_part.to_lowercase(), DEFAULT_DELIMITER.to_string()), - }; - - if instance_id == DEFAULT_DELIMITER || instance_id.is_empty() { - continue; - } - - if valid_keys.contains(&field_name.as_str()) { - endpoints.insert(normalized_endpoint_key(&instance_id, service)); - } - } - } - - endpoints + shared_collect_env_endpoint_keys(¬ification_target_specs(), NOTIFY_ROUTE_PREFIX) } fn classify_notification_endpoint_source( @@ -269,11 +188,7 @@ fn classify_notification_endpoint_source( fn notification_endpoint_source(config: &Config, target_type: &str, target_name: &str) -> NotificationEndpointSource { let config_targets = collect_config_entry_keys(config); let env_targets = collect_env_endpoint_keys(); - let service = match target_type { - NOTIFY_WEBHOOK_SUB_SYS => "webhook", - NOTIFY_MQTT_SUB_SYS => "mqtt", - _ => "", - }; + let service = target_service_name(¬ification_target_specs(), target_type).unwrap_or_default(); let key = normalized_endpoint_key(target_name, service); classify_notification_endpoint_source(&config_targets, &env_targets, &key) @@ -414,78 +329,15 @@ impl Operation for NotificationTarget { let notification_body: NotificationTargetBody = serde_json::from_slice(&body_bytes) .map_err(|e| s3_error!(InvalidArgument, "invalid json body for target config: {}", e))?; - let allowed_keys: HashSet<&str> = match target_type { - NOTIFY_WEBHOOK_SUB_SYS => rustfs_config::notify::NOTIFY_WEBHOOK_KEYS.iter().cloned().collect(), - NOTIFY_MQTT_SUB_SYS => rustfs_config::notify::NOTIFY_MQTT_KEYS.iter().cloned().collect(), - _ => unreachable!(), - }; + let specs = notification_target_specs(); + let allowed_keys: HashSet<&str> = allowed_target_keys(&specs, target_type); let kv_map = collect_validated_key_values(¬ification_body.key_values, &allowed_keys, target_type)?; - - // Type-specific validation - if target_type == NOTIFY_WEBHOOK_SUB_SYS { - let endpoint = kv_map - .get("endpoint") - .map(String::as_str) - .ok_or_else(|| s3_error!(InvalidArgument, "endpoint is required"))?; - let parsed_endpoint = Url::parse(endpoint).map_err(|e| s3_error!(InvalidArgument, "invalid endpoint url: {}", e))?; - match parsed_endpoint.scheme() { - "http" | "https" => {} - other => { - return Err(s3_error!( - InvalidArgument, - "unsupported endpoint scheme: {} (only http and https are allowed)", - other - )); - } - } - if let Some(queue_dir) = kv_map.get("queue_dir") { - validate_queue_dir(queue_dir.as_str()).await?; - } - if kv_map.contains_key("client_cert") != kv_map.contains_key("client_key") { - return Err(s3_error!(InvalidArgument, "client_cert and client_key must be specified as a pair")); - } - } else if target_type == NOTIFY_MQTT_SUB_SYS { - let endpoint = kv_map - .get(rustfs_config::MQTT_BROKER) - .map(String::as_str) - .ok_or_else(|| s3_error!(InvalidArgument, "broker endpoint is required"))?; - let topic = kv_map - .get(rustfs_config::MQTT_TOPIC) - .map(String::as_str) - .ok_or_else(|| s3_error!(InvalidArgument, "topic is required"))?; - let username = kv_map.get(rustfs_config::MQTT_USERNAME).map(String::as_str); - let password = kv_map.get(rustfs_config::MQTT_PASSWORD).map(String::as_str); - let tls = MQTTTlsConfig::from_values( - kv_map.get(rustfs_config::MQTT_TLS_POLICY).map(String::as_str), - kv_map.get(rustfs_config::MQTT_TLS_CA).map(String::as_str), - kv_map.get(rustfs_config::MQTT_TLS_CLIENT_CERT).map(String::as_str), - kv_map.get(rustfs_config::MQTT_TLS_CLIENT_KEY).map(String::as_str), - kv_map.get(rustfs_config::MQTT_TLS_TRUST_LEAF_AS_CA).map(String::as_str), - kv_map.get(rustfs_config::MQTT_WS_PATH_ALLOWLIST).map(String::as_str), - ) - .map_err(|e| s3_error!(InvalidArgument, "invalid MQTT TLS settings: {}", e))?; - let parsed_broker = Url::parse(endpoint).map_err(|e| s3_error!(InvalidArgument, "invalid broker URL: {}", e))?; - rustfs_targets::target::mqtt::validate_mqtt_broker_url(&parsed_broker, &tls) - .map_err(|e| s3_error!(InvalidArgument, "{}", e))?; - check_mqtt_broker_available_with_tls(parsed_broker.as_str(), topic, username, password, &tls) - .await - .map_err(|e| match e { - TargetError::Configuration(_) => s3_error!(InvalidArgument, "{}", e), - _ => s3_error!(InvalidArgument, "MQTT broker check failed: {}", e), - })?; - - if let Some(queue_dir) = kv_map.get("queue_dir") { - validate_queue_dir(queue_dir.as_str()).await?; - if let Some(qos) = kv_map.get("qos") { - match qos.parse::() { - Ok(1) | Ok(2) => {} - Ok(0) => return Err(s3_error!(InvalidArgument, "qos should be 1 or 2 if queue_dir is set")), - _ => return Err(s3_error!(InvalidArgument, "qos must be an integer 0, 1, or 2")), - } - } - } - } + let spec = target_spec(&specs, target_type) + .ok_or_else(|| s3_error!(InvalidArgument, "unsupported target type: '{}'", target_type))?; + timeout(Duration::from_secs(10), validate_target_request(spec, &kv_map, EVENT_DEFAULT_DIR)) + .await + .map_err(|_| s3_error!(InvalidArgument, "target validation timed out"))??; let mut kvs = rustfs_ecstore::config::KVS::new(); for (key, value) in kv_map { @@ -616,7 +468,7 @@ fn extract_param<'a>(params: &'a Params<'_, '_>, key: &str) -> S3Result<&'a str> fn extract_target_params<'a>(params: &'a Params<'_, '_>) -> S3Result<(&'a str, &'a str)> { let target_type = extract_param(params, "target_type")?; - if target_type != NOTIFY_WEBHOOK_SUB_SYS && target_type != NOTIFY_MQTT_SUB_SYS { + if target_service_name(¬ification_target_specs(), target_type).is_none() { return Err(s3_error!(InvalidArgument, "unsupported target type: '{}'", target_type)); } let target_name = extract_param(params, "target_name")?; @@ -626,6 +478,7 @@ fn extract_target_params<'a>(params: &'a Params<'_, '_>) -> S3Result<(&'a str, & #[cfg(test)] mod tests { use super::*; + use rustfs_config::DEFAULT_DELIMITER; use rustfs_ecstore::config::{KV, KVS}; use rustfs_targets::arn::TargetID; use std::collections::{HashMap, HashSet}; diff --git a/rustfs/src/admin/handlers/mod.rs b/rustfs/src/admin/handlers/mod.rs index f26f3ea89..852b44d83 100644 --- a/rustfs/src/admin/handlers/mod.rs +++ b/rustfs/src/admin/handlers/mod.rs @@ -37,6 +37,7 @@ pub mod service_account; pub mod site_replication; pub mod sts; pub mod system; +mod target_descriptor; pub mod tier; pub mod trace; pub mod user; diff --git a/rustfs/src/admin/handlers/target_descriptor.rs b/rustfs/src/admin/handlers/target_descriptor.rs new file mode 100644 index 000000000..de2c78db6 --- /dev/null +++ b/rustfs/src/admin/handlers/target_descriptor.rs @@ -0,0 +1,299 @@ +// 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 hashbrown::HashSet as HbHashSet; +use rustfs_config::{ + ENABLE_KEY, MQTT_BROKER, MQTT_PASSWORD, MQTT_QOS, MQTT_TLS_CA, MQTT_TLS_CLIENT_CERT, MQTT_TLS_CLIENT_KEY, MQTT_TLS_POLICY, + MQTT_TLS_TRUST_LEAF_AS_CA, MQTT_TOPIC, MQTT_USERNAME, MQTT_WS_PATH_ALLOWLIST, +}; +use rustfs_ecstore::config::Config; +use rustfs_targets::{ + TargetError, check_mqtt_broker_available_with_tls, check_nats_server_available, check_pulsar_broker_available, + config::{build_nats_args, build_pulsar_args, collect_env_target_instance_ids}, + target::{TargetType, mqtt::MQTTTlsConfig}, +}; +use s3s::{S3Result, s3_error}; +use std::collections::{HashMap, HashSet}; +use std::io::{Error, ErrorKind}; +use std::path::Path; +use tokio::time::{Duration, sleep}; +use url::Url; + +pub(crate) type EndpointKey = (String, String); + +#[derive(Clone, Copy)] +pub(crate) enum TargetDomain { + Notify, + Audit, +} + +impl TargetDomain { + fn runtime_target_type(self) -> TargetType { + match self { + TargetDomain::Notify => TargetType::NotifyEvent, + TargetDomain::Audit => TargetType::AuditLog, + } + } +} + +#[derive(Clone, Copy)] +pub(crate) enum AdminTargetValidator { + Webhook, + Mqtt, + Nats(TargetDomain), + Pulsar(TargetDomain), +} + +#[derive(Clone, Copy)] +pub(crate) struct AdminTargetSpec { + pub subsystem: &'static str, + pub service: &'static str, + pub valid_keys: &'static [&'static str], + pub validator: AdminTargetValidator, +} + +pub(crate) fn normalized_endpoint_key(account_id: &str, service: &str) -> EndpointKey { + (account_id.to_lowercase(), service.to_string()) +} + +pub(crate) fn target_spec<'a>(specs: &'a [AdminTargetSpec], target_type: &str) -> Option<&'a AdminTargetSpec> { + specs.iter().find(|spec| spec.subsystem == target_type) +} + +pub(crate) fn target_service_name(specs: &[AdminTargetSpec], target_type: &str) -> Option<&'static str> { + target_spec(specs, target_type).map(|spec| spec.service) +} + +pub(crate) fn collect_configured_endpoint_keys(specs: &[AdminTargetSpec], config: &Config) -> Vec { + let mut endpoints = Vec::new(); + for spec in specs { + let Some(targets) = config.0.get(spec.subsystem) else { + continue; + }; + + for (target_name, kvs) in targets { + if target_name == rustfs_config::DEFAULT_DELIMITER { + continue; + } + let enabled = kvs.lookup(ENABLE_KEY).as_deref().map(config_enable_is_on).unwrap_or(false); + if enabled { + endpoints.push((target_name.clone(), spec.service.to_string())); + } + } + } + endpoints +} + +pub(crate) fn collect_config_entry_keys(specs: &[AdminTargetSpec], config: &Config) -> HbHashSet { + let mut endpoints = HbHashSet::new(); + for spec in specs { + let Some(targets) = config.0.get(spec.subsystem) else { + continue; + }; + + for target_name in targets.keys() { + if target_name == rustfs_config::DEFAULT_DELIMITER { + continue; + } + endpoints.insert(normalized_endpoint_key(target_name, spec.service)); + } + } + endpoints +} + +pub(crate) fn collect_env_endpoint_keys(specs: &[AdminTargetSpec], route_prefix: &str) -> HbHashSet { + let mut endpoints = HbHashSet::new(); + for spec in specs { + let valid_keys = spec.valid_keys.iter().map(|key| (*key).to_string()).collect::>(); + for instance_id in collect_env_target_instance_ids(route_prefix, spec.service, &valid_keys) { + if instance_id != rustfs_config::DEFAULT_DELIMITER && !instance_id.is_empty() { + endpoints.insert(normalized_endpoint_key(&instance_id, spec.service)); + } + } + } + endpoints +} + +pub(crate) fn allowed_target_keys(specs: &[AdminTargetSpec], target_type: &str) -> HashSet<&'static str> { + target_spec(specs, target_type) + .map(|spec| spec.valid_keys.iter().copied().collect()) + .unwrap_or_default() +} + +pub(crate) async fn validate_queue_dir(queue_dir: &str) -> S3Result<()> { + if !queue_dir.is_empty() { + if !Path::new(queue_dir).is_absolute() { + return Err(s3_error!(InvalidArgument, "queue_dir must be absolute path")); + } + retry_with_backoff( + || async { tokio::fs::metadata(queue_dir).await.map(|_| ()) }, + 3, + Duration::from_millis(100), + ) + .await + .map_err(|e| match e.kind() { + ErrorKind::NotFound => s3_error!(InvalidArgument, "queue_dir does not exist"), + ErrorKind::PermissionDenied => s3_error!(InvalidArgument, "queue_dir exists but permission denied"), + _ => s3_error!(InvalidArgument, "failed to access queue_dir: {}", e), + })?; + } + Ok(()) +} + +pub(crate) async fn validate_target_request( + spec: &AdminTargetSpec, + kv_map: &HashMap, + default_queue_dir: &str, +) -> S3Result<()> { + match spec.validator { + AdminTargetValidator::Webhook => validate_webhook_request(kv_map).await, + AdminTargetValidator::Mqtt => validate_mqtt_request(kv_map).await, + AdminTargetValidator::Nats(domain) => validate_nats_request(kv_map, default_queue_dir, domain).await, + AdminTargetValidator::Pulsar(domain) => validate_pulsar_request(kv_map, default_queue_dir, domain).await, + } +} + +fn config_enable_is_on(value: &str) -> bool { + matches!(value.trim().to_ascii_lowercase().as_str(), "on" | "true" | "yes" | "1") +} + +async fn retry_with_backoff(mut operation: F, max_attempts: usize, base_delay: Duration) -> Result +where + F: FnMut() -> Fut, + Fut: std::future::Future>, +{ + let mut attempts = 0; + let mut delay = base_delay; + let mut last_err = None; + + while attempts < max_attempts { + match operation().await { + Ok(result) => return Ok(result), + Err(e) => { + last_err = Some(e); + attempts += 1; + if attempts < max_attempts { + sleep(delay).await; + delay = delay.saturating_mul(2); + } + } + } + } + Err(last_err.unwrap_or_else(|| Error::other("retry_with_backoff: unknown error"))) +} + +async fn validate_webhook_request(kv_map: &HashMap) -> S3Result<()> { + let endpoint = kv_map + .get("endpoint") + .map(String::as_str) + .ok_or_else(|| s3_error!(InvalidArgument, "endpoint is required"))?; + let parsed_endpoint = Url::parse(endpoint).map_err(|e| s3_error!(InvalidArgument, "invalid endpoint url: {}", e))?; + match parsed_endpoint.scheme() { + "http" | "https" => {} + other => { + return Err(s3_error!( + InvalidArgument, + "unsupported endpoint scheme: {} (only http and https are allowed)", + other + )); + } + } + if let Some(queue_dir) = kv_map.get("queue_dir") { + validate_queue_dir(queue_dir.as_str()).await?; + } + if kv_map.contains_key("client_cert") != kv_map.contains_key("client_key") { + return Err(s3_error!(InvalidArgument, "client_cert and client_key must be specified as a pair")); + } + Ok(()) +} + +async fn validate_mqtt_request(kv_map: &HashMap) -> S3Result<()> { + let endpoint = kv_map + .get(MQTT_BROKER) + .map(String::as_str) + .ok_or_else(|| s3_error!(InvalidArgument, "broker endpoint is required"))?; + let topic = kv_map + .get(MQTT_TOPIC) + .map(String::as_str) + .ok_or_else(|| s3_error!(InvalidArgument, "topic is required"))?; + let username = kv_map.get(MQTT_USERNAME).map(String::as_str); + let password = kv_map.get(MQTT_PASSWORD).map(String::as_str); + let tls = MQTTTlsConfig::from_values( + kv_map.get(MQTT_TLS_POLICY).map(String::as_str), + kv_map.get(MQTT_TLS_CA).map(String::as_str), + kv_map.get(MQTT_TLS_CLIENT_CERT).map(String::as_str), + kv_map.get(MQTT_TLS_CLIENT_KEY).map(String::as_str), + kv_map.get(MQTT_TLS_TRUST_LEAF_AS_CA).map(String::as_str), + kv_map.get(MQTT_WS_PATH_ALLOWLIST).map(String::as_str), + ) + .map_err(|e| s3_error!(InvalidArgument, "invalid MQTT TLS settings: {}", e))?; + let parsed_broker = Url::parse(endpoint).map_err(|e| s3_error!(InvalidArgument, "invalid broker URL: {}", e))?; + rustfs_targets::target::mqtt::validate_mqtt_broker_url(&parsed_broker, &tls) + .map_err(|e| s3_error!(InvalidArgument, "{}", e))?; + check_mqtt_broker_available_with_tls(parsed_broker.as_str(), topic, username, password, &tls) + .await + .map_err(|e| match e { + TargetError::Configuration(_) => s3_error!(InvalidArgument, "{}", e), + _ => s3_error!(InvalidArgument, "MQTT broker check failed: {}", e), + })?; + + if let Some(queue_dir) = kv_map.get("queue_dir") { + validate_queue_dir(queue_dir.as_str()).await?; + if let Some(qos) = kv_map.get(MQTT_QOS) { + match qos.parse::() { + Ok(1) | Ok(2) => {} + Ok(0) => return Err(s3_error!(InvalidArgument, "qos should be 1 or 2 if queue_dir is set")), + _ => return Err(s3_error!(InvalidArgument, "qos must be an integer 0, 1, or 2")), + } + } + } + + Ok(()) +} + +async fn validate_nats_request(kv_map: &HashMap, default_queue_dir: &str, domain: TargetDomain) -> S3Result<()> { + if let Some(queue_dir) = kv_map.get("queue_dir") { + validate_queue_dir(queue_dir.as_str()).await?; + } + let args = build_nats_args(&to_kvs(kv_map), default_queue_dir, domain.runtime_target_type()) + .map_err(|e| s3_error!(InvalidArgument, "{}", e))?; + check_nats_server_available(&args).await.map_err(|e| match e { + TargetError::Configuration(_) => s3_error!(InvalidArgument, "{}", e), + _ => s3_error!(InvalidArgument, "NATS server check failed: {}", e), + }) +} + +async fn validate_pulsar_request( + kv_map: &HashMap, + default_queue_dir: &str, + domain: TargetDomain, +) -> S3Result<()> { + if let Some(queue_dir) = kv_map.get("queue_dir") { + validate_queue_dir(queue_dir.as_str()).await?; + } + let args = build_pulsar_args(&to_kvs(kv_map), default_queue_dir, domain.runtime_target_type()) + .map_err(|e| s3_error!(InvalidArgument, "{}", e))?; + check_pulsar_broker_available(&args).await.map_err(|e| match e { + TargetError::Configuration(_) => s3_error!(InvalidArgument, "{}", e), + _ => s3_error!(InvalidArgument, "Pulsar broker check failed: {}", e), + }) +} + +fn to_kvs(kv_map: &HashMap) -> rustfs_ecstore::config::KVS { + let mut kvs = rustfs_ecstore::config::KVS::new(); + for (key, value) in kv_map { + kvs.insert(key.clone(), value.clone()); + } + kvs +} diff --git a/rustfs/src/server/audit.rs b/rustfs/src/server/audit.rs index 660683cef..590d0e5ab 100644 --- a/rustfs/src/server/audit.rs +++ b/rustfs/src/server/audit.rs @@ -23,6 +23,8 @@ fn server_config_from_context() -> Option { fn has_any_audit_targets(config: &rustfs_ecstore::config::Config) -> bool { for subsystem in [ rustfs_config::audit::AUDIT_MQTT_SUB_SYS, + rustfs_config::audit::AUDIT_NATS_SUB_SYS, + rustfs_config::audit::AUDIT_PULSAR_SUB_SYS, rustfs_config::audit::AUDIT_WEBHOOK_SUB_SYS, ] { let Some(targets) = config.0.get(subsystem) else { @@ -73,7 +75,7 @@ pub async fn start_audit_system() -> AuditResult<()> { if !has_targets { info!( target: "rustfs::main::start_audit_system", - "Audit subsystem (MQTT/Webhook) is not configured, and audit system initialization is skipped." + "Audit subsystem (Webhook/MQTT/NATS/Pulsar) is not configured, and audit system initialization is skipped." ); return Ok(()); }