mirror of
https://github.com/rustfs/rustfs.git
synced 2026-08-10 07:06:53 +00:00
refactor(tls): centralize runtime foundation (#3065)
* refactor(targets): move notify net helpers from utils * refactor(tls): centralize runtime foundation * refactor(targets): move notify net helpers from utils * refactor(tls): centralize runtime foundation * feat(tls-runtime): add TLS debug state and admin handler * refactor(tls-runtime): unify TLS debug consumer status view * fix(tls): address PR3065 review feedback * refactor(tls): align debug status payload types * refactor(targets): harden TLS hot reload paths * fix(targets): resolve review-4348251652 findings * fix(targets): finalize tls runtime review follow-ups * fix(targets): harden tls reload and review follow-ups * fix(targets): align tls reload handling across targets * fix(targets): finalize tls reload state and metrics updates * chore(deps): trim unused TLS deps * style(targets): normalize TLS reload formatting * refactor(targets): introduce tls runtime adapter path * chore: update workspace manifests for tls refactor * fix(tls): stabilize material reload and audit workflow * fix(targets): refresh tls fingerprint flow across sinks * fix(tls): align runtime coordinator and http reader updates * fix(sftp): simplify protocol error mapping * fix(tls): harmonize material loading behavior * fix(server): finalize tls material wiring in startup flow * fix(protos): tighten tls generation cache and deps
This commit is contained in:
@@ -15,6 +15,7 @@
|
||||
pub mod adapter;
|
||||
pub mod sidecar;
|
||||
pub mod sidecar_protocol;
|
||||
pub mod tls;
|
||||
|
||||
use crate::Target;
|
||||
use crate::arn::TargetID;
|
||||
|
||||
@@ -0,0 +1,217 @@
|
||||
// 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.
|
||||
|
||||
//! `TlsReloadAdapter<M>` — the single entry-point that connects a target to
|
||||
//! the TLS reload coordinator. Each target holds an `Option<TlsReloadAdapter<M>>`
|
||||
//! and calls `current_material()` on the hot path. When `None`, the target
|
||||
//! falls back to its legacy inline fingerprint logic.
|
||||
|
||||
use super::config::TlsReloadOptions;
|
||||
use super::coordinator::TargetTlsReloadCoordinator;
|
||||
use super::state::{TargetTlsRuntimeState, TargetTlsStatusSnapshot};
|
||||
use super::r#trait::ReloadableTargetTls;
|
||||
use std::sync::Arc;
|
||||
use tracing::warn;
|
||||
|
||||
/// Bridges a `ReloadableTargetTls` implementor and the reload coordinator.
|
||||
///
|
||||
/// Created via [`TlsReloadAdapter::try_register`]. Holds the coordinator-
|
||||
/// managed runtime state and exposes a zero-cost `current_material()` accessor
|
||||
/// for the send hot-path.
|
||||
pub struct TlsReloadAdapter<M> {
|
||||
runtime_state: Arc<TargetTlsRuntimeState<M>>,
|
||||
options: TlsReloadOptions,
|
||||
}
|
||||
|
||||
impl<M> std::fmt::Debug for TlsReloadAdapter<M> {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_struct("TlsReloadAdapter")
|
||||
.field("target_label", &self.runtime_state.inputs.target_label)
|
||||
.finish_non_exhaustive()
|
||||
}
|
||||
}
|
||||
|
||||
impl<M> Clone for TlsReloadAdapter<M> {
|
||||
fn clone(&self) -> Self {
|
||||
Self {
|
||||
runtime_state: Arc::clone(&self.runtime_state),
|
||||
options: self.options.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<M: Send + Sync + 'static> TlsReloadAdapter<M> {
|
||||
/// Registers `target` with the coordinator and returns an adapter.
|
||||
///
|
||||
/// On success the coordinator has:
|
||||
/// - built initial TLS material
|
||||
/// - spawned a background poll loop
|
||||
///
|
||||
/// On failure returns `None` (the caller should keep its inline fallback
|
||||
/// path intact — the target continues to work, just without coordinator
|
||||
/// support).
|
||||
pub async fn try_register<T: ReloadableTargetTls<Material = M>>(
|
||||
target: Arc<T>,
|
||||
options: TlsReloadOptions,
|
||||
coordinator: &TargetTlsReloadCoordinator,
|
||||
) -> Option<Self> {
|
||||
let label = target.tls_input_set().target_label.clone();
|
||||
match coordinator.register(target, options.clone()).await {
|
||||
Ok(runtime_state) => {
|
||||
tracing::info!(target = %label, "TLS reload adapter registered");
|
||||
Some(Self { runtime_state, options })
|
||||
}
|
||||
Err(err) => {
|
||||
warn!(target = %label, error = %err, "TLS reload adapter registration failed; target will use inline fallback");
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Hot-path accessor: returns the current TLS material managed by the
|
||||
/// coordinator. The returned `Arc<M>` is cheap to clone.
|
||||
#[inline]
|
||||
pub fn current_material(&self) -> Arc<M> {
|
||||
Arc::clone(&self.runtime_state.current.load().material)
|
||||
}
|
||||
|
||||
/// Returns the active generation counter.
|
||||
#[inline]
|
||||
pub fn generation(&self) -> u64 {
|
||||
self.runtime_state.current.load().generation.0
|
||||
}
|
||||
|
||||
/// Returns a read-only status snapshot for admin/observability.
|
||||
pub fn status_snapshot(&self) -> TargetTlsStatusSnapshot {
|
||||
TargetTlsReloadCoordinator::build_status_snapshot(&self.runtime_state, &self.options)
|
||||
}
|
||||
|
||||
/// Returns the underlying runtime state (for `close()` cleanup etc.).
|
||||
pub fn runtime_state(&self) -> &Arc<TargetTlsRuntimeState<M>> {
|
||||
&self.runtime_state
|
||||
}
|
||||
|
||||
/// Unregisters from the coordinator (stops the poll loop).
|
||||
pub async fn unregister(&self, coordinator: &TargetTlsReloadCoordinator) {
|
||||
let label = &self.runtime_state.inputs.target_label;
|
||||
if let Err(err) = coordinator.unregister(label).await {
|
||||
warn!(target = %label, error = %err, "Failed to unregister TLS reload adapter");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::error::TargetError;
|
||||
use async_trait::async_trait;
|
||||
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
|
||||
|
||||
struct FakeTarget {
|
||||
label: String,
|
||||
build_calls: AtomicUsize,
|
||||
should_fail: AtomicBool,
|
||||
}
|
||||
|
||||
impl FakeTarget {
|
||||
fn new(label: &str) -> Self {
|
||||
Self {
|
||||
label: label.to_string(),
|
||||
build_calls: AtomicUsize::new(0),
|
||||
should_fail: AtomicBool::new(false),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl ReloadableTargetTls for FakeTarget {
|
||||
type Material = String;
|
||||
|
||||
fn tls_input_set(&self) -> super::super::state::TargetTlsInputSet {
|
||||
super::super::state::TargetTlsInputSet {
|
||||
ca_path: String::new(),
|
||||
client_cert_path: String::new(),
|
||||
client_key_path: String::new(),
|
||||
target_label: self.label.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
async fn build_tls_material(&self) -> Result<Self::Material, TargetError> {
|
||||
self.build_calls.fetch_add(1, Ordering::SeqCst);
|
||||
if self.should_fail.load(Ordering::SeqCst) {
|
||||
return Err(TargetError::Configuration("fail".to_string()));
|
||||
}
|
||||
Ok("material".to_string())
|
||||
}
|
||||
|
||||
async fn apply_tls_material(
|
||||
&self,
|
||||
_generation: super::super::fingerprint::TargetTlsGeneration,
|
||||
_material: Arc<Self::Material>,
|
||||
_mode: super::super::config::ReloadApplyMode,
|
||||
) -> Result<(), TargetError> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn try_register_returns_adapter_on_success() {
|
||||
let coordinator = TargetTlsReloadCoordinator::new();
|
||||
let target = Arc::new(FakeTarget::new("test:fake"));
|
||||
let options = TlsReloadOptions::default();
|
||||
|
||||
let adapter = TlsReloadAdapter::try_register(target.clone(), options, &coordinator).await;
|
||||
assert!(adapter.is_some());
|
||||
assert_eq!(target.build_calls.load(Ordering::SeqCst), 1);
|
||||
|
||||
let a = adapter.unwrap();
|
||||
assert_eq!(*a.current_material(), "material");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn try_register_returns_none_on_failure() {
|
||||
let coordinator = TargetTlsReloadCoordinator::new();
|
||||
let target = Arc::new(FakeTarget::new("test:fail"));
|
||||
target.should_fail.store(true, Ordering::SeqCst);
|
||||
let options = TlsReloadOptions::default();
|
||||
|
||||
let adapter = TlsReloadAdapter::try_register(target, options, &coordinator).await;
|
||||
assert!(adapter.is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn adapter_is_clone_and_shares_state() {
|
||||
let coordinator = TargetTlsReloadCoordinator::new();
|
||||
let target = Arc::new(FakeTarget::new("test:clone"));
|
||||
let options = TlsReloadOptions::default();
|
||||
|
||||
let a = TlsReloadAdapter::try_register(target, options, &coordinator).await.unwrap();
|
||||
let b = a.clone();
|
||||
|
||||
assert_eq!(*a.current_material(), *b.current_material());
|
||||
assert_eq!(a.generation(), b.generation());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn status_snapshot_contains_label() {
|
||||
let coordinator = TargetTlsReloadCoordinator::new();
|
||||
let target = Arc::new(FakeTarget::new("test:snap"));
|
||||
let options = TlsReloadOptions::default();
|
||||
|
||||
let adapter = TlsReloadAdapter::try_register(target, options, &coordinator).await.unwrap();
|
||||
let snap = adapter.status_snapshot();
|
||||
assert_eq!(snap.target_label, "test:snap");
|
||||
assert!(snap.reload_enabled);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
// 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.
|
||||
|
||||
//! Target-level TLS reload configuration.
|
||||
//!
|
||||
//! Re-exports the shared types from `rustfs_tls_runtime::config` so that
|
||||
//! targets and their callers use a single source of truth.
|
||||
|
||||
pub use rustfs_tls_runtime::config::{ReloadApplyHint as ReloadApplyMode, ReloadDetectMode, TlsReloadOptions};
|
||||
@@ -0,0 +1,656 @@
|
||||
// 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.
|
||||
|
||||
//! Target TLS reload coordinator. Manages per-target background poll loops
|
||||
//! that periodically check TLS material fingerprints and drive safe reload.
|
||||
|
||||
use super::config::{ReloadApplyMode, ReloadDetectMode, TlsReloadOptions};
|
||||
#[cfg(test)]
|
||||
use super::fingerprint::TargetTlsFingerprint;
|
||||
use super::fingerprint::{TargetTlsGeneration, build_target_tls_fingerprint};
|
||||
use super::metrics::{record_target_tls_publication_fail, record_target_tls_reload_result, record_target_tls_reload_skipped};
|
||||
#[cfg(test)]
|
||||
use super::state::TargetTlsInputSet;
|
||||
use super::state::{TargetTlsPublishedState, TargetTlsRuntimeState, TargetTlsStatusSnapshot};
|
||||
use super::r#trait::ReloadableTargetTls;
|
||||
use super::validate::validate_tls_material;
|
||||
use crate::error::TargetError;
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
use tokio::sync::RwLock;
|
||||
use tokio::task::JoinHandle;
|
||||
use tracing::{debug, info, warn};
|
||||
|
||||
struct TargetReloadEntry {
|
||||
#[expect(dead_code)]
|
||||
target_label: String,
|
||||
cancel_tx: tokio::sync::mpsc::Sender<()>,
|
||||
poll_handle: JoinHandle<()>,
|
||||
}
|
||||
|
||||
/// The top-level coordinator that manages TLS reload for all registered targets.
|
||||
///
|
||||
/// Typically one instance per process, held alongside `TargetRuntimeManager`.
|
||||
/// Each registered target gets its own background poll loop that periodically
|
||||
/// checks TLS fingerprints and drives the build/apply cycle.
|
||||
pub struct TargetTlsReloadCoordinator {
|
||||
entries: RwLock<HashMap<String, TargetReloadEntry>>,
|
||||
}
|
||||
|
||||
impl Default for TargetTlsReloadCoordinator {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl TargetTlsReloadCoordinator {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
entries: RwLock::new(HashMap::new()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Register a target for coordinated TLS reload. Spawns a background poll loop.
|
||||
///
|
||||
/// Returns the initial runtime state that the target should hold for
|
||||
/// accessing the current TLS material via `ArcSwap`.
|
||||
pub async fn register<T: ReloadableTargetTls>(
|
||||
&self,
|
||||
target: Arc<T>,
|
||||
options: TlsReloadOptions,
|
||||
) -> Result<Arc<TargetTlsRuntimeState<T::Material>>, TargetError> {
|
||||
if !options.enabled {
|
||||
return Err(TargetError::Configuration("TLS reload is disabled".to_string()));
|
||||
}
|
||||
|
||||
let inputs = target.tls_input_set();
|
||||
let target_label = inputs.target_label.clone();
|
||||
|
||||
// Build initial material
|
||||
let initial_material = Arc::new(target.build_tls_material().await?);
|
||||
let initial_fingerprint =
|
||||
build_target_tls_fingerprint(&inputs.ca_path, &inputs.client_cert_path, &inputs.client_key_path).await?;
|
||||
|
||||
let initial_state = Arc::new(TargetTlsPublishedState {
|
||||
generation: TargetTlsGeneration(1),
|
||||
fingerprint: initial_fingerprint,
|
||||
material: initial_material,
|
||||
loaded_at_unix_ms: unix_time_ms(),
|
||||
});
|
||||
|
||||
let runtime_state = Arc::new(TargetTlsRuntimeState::new(initial_state.clone(), inputs));
|
||||
|
||||
if options.detect_mode == ReloadDetectMode::Poll || options.detect_mode == ReloadDetectMode::Hybrid {
|
||||
let (cancel_tx, cancel_rx) = tokio::sync::mpsc::channel(1);
|
||||
let poll_handle = tokio::spawn(spawn_target_poll_loop(target, Arc::clone(&runtime_state), options, cancel_rx));
|
||||
|
||||
let mut entries = self.entries.write().await;
|
||||
entries.insert(
|
||||
target_label.clone(),
|
||||
TargetReloadEntry {
|
||||
target_label: target_label.clone(),
|
||||
cancel_tx,
|
||||
poll_handle,
|
||||
},
|
||||
);
|
||||
|
||||
info!(target = %target_label, "Registered target for TLS reload coordinator");
|
||||
}
|
||||
|
||||
Ok(runtime_state)
|
||||
}
|
||||
|
||||
/// Unregister a target and stop its poll loop.
|
||||
pub async fn unregister(&self, target_label: &str) -> Result<(), TargetError> {
|
||||
let mut entries = self.entries.write().await;
|
||||
if let Some(entry) = entries.remove(target_label) {
|
||||
let _ = entry.cancel_tx.send(()).await;
|
||||
entry.poll_handle.abort();
|
||||
info!(target = %target_label, "Unregistered target from TLS reload coordinator");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Force an immediate reload check for a specific target.
|
||||
/// Used by admin endpoints and test harnesses.
|
||||
pub async fn force_reload<T: ReloadableTargetTls>(
|
||||
&self,
|
||||
target: &T,
|
||||
runtime_state: &TargetTlsRuntimeState<T::Material>,
|
||||
options: &TlsReloadOptions,
|
||||
) -> Result<TargetTlsGeneration, TargetError> {
|
||||
reload_target_once(target, runtime_state, options).await
|
||||
}
|
||||
|
||||
/// Stop all poll loops.
|
||||
pub async fn shutdown(&self) {
|
||||
let mut entries = self.entries.write().await;
|
||||
for (label, entry) in entries.drain() {
|
||||
let _ = entry.cancel_tx.send(()).await;
|
||||
entry.poll_handle.abort();
|
||||
debug!(target = %label, "Stopped TLS reload poll loop");
|
||||
}
|
||||
}
|
||||
|
||||
/// Collect status snapshots from all registered targets.
|
||||
/// The caller must provide the runtime states separately since the
|
||||
/// coordinator does not hold type-erased references to them.
|
||||
pub fn build_status_snapshot<M>(
|
||||
runtime_state: &TargetTlsRuntimeState<M>,
|
||||
options: &TlsReloadOptions,
|
||||
) -> TargetTlsStatusSnapshot {
|
||||
let current = runtime_state.current.load();
|
||||
let last_attempt = runtime_state.last_attempt_unix_ms();
|
||||
let last_success = runtime_state.last_success_unix_ms();
|
||||
let last_error = runtime_state.last_error.read().clone();
|
||||
|
||||
TargetTlsStatusSnapshot {
|
||||
target_label: runtime_state.inputs.target_label.clone(),
|
||||
generation: current.generation.0,
|
||||
reload_enabled: options.enabled,
|
||||
detect_mode: match options.detect_mode {
|
||||
ReloadDetectMode::Poll => "poll",
|
||||
ReloadDetectMode::Watch => "watch",
|
||||
ReloadDetectMode::Hybrid => "hybrid",
|
||||
},
|
||||
apply_mode: match options.apply_hint {
|
||||
ReloadApplyMode::Lazy => "lazy",
|
||||
ReloadApplyMode::SoftReconnect => "soft_reconnect",
|
||||
},
|
||||
last_attempt_time: if last_attempt > 0 { Some(last_attempt) } else { None },
|
||||
last_success_time: if last_success > 0 { Some(last_success) } else { None },
|
||||
last_error,
|
||||
ca_path: runtime_state.inputs.ca_path.clone(),
|
||||
client_cert_path: runtime_state.inputs.client_cert_path.clone(),
|
||||
client_key_path: runtime_state.inputs.client_key_path.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Background poll loop for a single target.
|
||||
async fn spawn_target_poll_loop<T: ReloadableTargetTls>(
|
||||
target: Arc<T>,
|
||||
runtime_state: Arc<TargetTlsRuntimeState<T::Material>>,
|
||||
options: TlsReloadOptions,
|
||||
mut cancel_rx: tokio::sync::mpsc::Receiver<()>,
|
||||
) {
|
||||
let mut interval = tokio::time::interval(options.interval);
|
||||
interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
|
||||
interval.tick().await; // skip the immediate first tick
|
||||
|
||||
let label = &runtime_state.inputs.target_label;
|
||||
let debounce = options.debounce;
|
||||
debug!(target = %label, interval_secs = options.interval.as_secs(), "TLS reload poll loop started");
|
||||
|
||||
loop {
|
||||
tokio::select! {
|
||||
biased;
|
||||
_ = cancel_rx.recv() => {
|
||||
info!(target = %label, "TLS reload poll loop stopped");
|
||||
return;
|
||||
}
|
||||
_ = interval.tick() => {
|
||||
// Enforce minimum stable age: if the last attempt was too recent
|
||||
// (e.g. a rapid succession of ticks), wait one debounce period
|
||||
// before reading files again to avoid picking up half-written certs.
|
||||
let last_attempt = runtime_state.last_attempt_unix_ms();
|
||||
if last_attempt > 0 {
|
||||
let elapsed_since_last = unix_time_ms().saturating_sub(last_attempt);
|
||||
if elapsed_since_last < debounce.as_millis() as u64 {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
if let Err(err) = reload_target_once(target.as_ref(), runtime_state.as_ref(), &options).await {
|
||||
warn!(target = %label, error = %err, "TLS reload poll check failed (will retry)");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Single reload cycle: read → compare → validate → build → apply → publish.
|
||||
///
|
||||
/// Returns the new generation on success, or an error on failure.
|
||||
/// On failure the current generation and material are untouched.
|
||||
async fn reload_target_once<T: ReloadableTargetTls>(
|
||||
target: &T,
|
||||
runtime_state: &TargetTlsRuntimeState<T::Material>,
|
||||
options: &TlsReloadOptions,
|
||||
) -> Result<TargetTlsGeneration, TargetError> {
|
||||
let now = unix_time_ms();
|
||||
runtime_state.mark_attempt(now);
|
||||
let started_at = std::time::Instant::now();
|
||||
let label = &runtime_state.inputs.target_label;
|
||||
|
||||
// 1. Read TLS files and compute fingerprint
|
||||
let inputs = &runtime_state.inputs;
|
||||
let next_fingerprint =
|
||||
build_target_tls_fingerprint(&inputs.ca_path, &inputs.client_cert_path, &inputs.client_key_path).await?;
|
||||
|
||||
// 2. Compare with current — skip if unchanged
|
||||
let current = runtime_state.current.load();
|
||||
if current.fingerprint == next_fingerprint {
|
||||
record_target_tls_reload_skipped(label, "unchanged");
|
||||
return Ok(current.generation);
|
||||
}
|
||||
|
||||
// 3. Validate TLS files (cert/key pairing, CA parseable)
|
||||
if let Err(err) = validate_tls_material(&inputs.ca_path, &inputs.client_cert_path, &inputs.client_key_path) {
|
||||
*runtime_state.last_error.write() = Some(err.to_string());
|
||||
record_target_tls_publication_fail(label);
|
||||
return Err(err);
|
||||
}
|
||||
|
||||
// Also call target-specific validation
|
||||
if let Err(err) = target.validate_tls_files().await {
|
||||
*runtime_state.last_error.write() = Some(err.to_string());
|
||||
record_target_tls_publication_fail(label);
|
||||
return Err(err);
|
||||
}
|
||||
|
||||
// 4. Build new material (does not touch current state yet)
|
||||
let new_material = match target.build_tls_material().await {
|
||||
Ok(m) => Arc::new(m),
|
||||
Err(err) => {
|
||||
*runtime_state.last_error.write() = Some(err.to_string());
|
||||
record_target_tls_publication_fail(label);
|
||||
return Err(err);
|
||||
}
|
||||
};
|
||||
|
||||
// 5. Bump generation and apply
|
||||
let new_generation = runtime_state.bump_generation();
|
||||
if let Err(err) = target
|
||||
.apply_tls_material(new_generation, Arc::clone(&new_material), options.apply_hint)
|
||||
.await
|
||||
{
|
||||
*runtime_state.last_error.write() = Some(err.to_string());
|
||||
record_target_tls_publication_fail(label);
|
||||
return Err(err);
|
||||
}
|
||||
|
||||
// 6. Publish new state
|
||||
let published = Arc::new(TargetTlsPublishedState {
|
||||
generation: new_generation,
|
||||
fingerprint: next_fingerprint,
|
||||
material: new_material,
|
||||
loaded_at_unix_ms: now,
|
||||
});
|
||||
runtime_state.current.store(published.clone());
|
||||
runtime_state.last_good.store(published);
|
||||
runtime_state.mark_success(now);
|
||||
*runtime_state.last_error.write() = None;
|
||||
|
||||
record_target_tls_reload_result(label, "ok", started_at.elapsed().as_secs_f64(), new_generation.0);
|
||||
|
||||
debug!(target = %label, generation = new_generation.0, "TLS reload successful");
|
||||
Ok(new_generation)
|
||||
}
|
||||
|
||||
fn unix_time_ms() -> u64 {
|
||||
SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or_default().as_millis() as u64
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
|
||||
|
||||
struct MockTarget {
|
||||
inputs: TargetTlsInputSet,
|
||||
build_calls: AtomicUsize,
|
||||
apply_calls: AtomicUsize,
|
||||
validate_calls: AtomicUsize,
|
||||
should_fail_build: AtomicBool,
|
||||
should_fail_apply: AtomicBool,
|
||||
should_fail_validate: AtomicBool,
|
||||
}
|
||||
|
||||
impl MockTarget {
|
||||
fn new(label: &str) -> Self {
|
||||
Self {
|
||||
inputs: TargetTlsInputSet {
|
||||
ca_path: String::new(),
|
||||
client_cert_path: String::new(),
|
||||
client_key_path: String::new(),
|
||||
target_label: label.to_string(),
|
||||
},
|
||||
build_calls: AtomicUsize::new(0),
|
||||
apply_calls: AtomicUsize::new(0),
|
||||
validate_calls: AtomicUsize::new(0),
|
||||
should_fail_build: AtomicBool::new(false),
|
||||
should_fail_apply: AtomicBool::new(false),
|
||||
should_fail_validate: AtomicBool::new(false),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl ReloadableTargetTls for MockTarget {
|
||||
type Material = String;
|
||||
|
||||
fn tls_input_set(&self) -> TargetTlsInputSet {
|
||||
self.inputs.clone()
|
||||
}
|
||||
|
||||
async fn build_tls_material(&self) -> Result<Self::Material, TargetError> {
|
||||
self.build_calls.fetch_add(1, Ordering::SeqCst);
|
||||
if self.should_fail_build.load(Ordering::SeqCst) {
|
||||
return Err(TargetError::Configuration("build failed".to_string()));
|
||||
}
|
||||
Ok("mock-material".to_string())
|
||||
}
|
||||
|
||||
async fn apply_tls_material(
|
||||
&self,
|
||||
_generation: TargetTlsGeneration,
|
||||
_material: Arc<Self::Material>,
|
||||
_mode: ReloadApplyMode,
|
||||
) -> Result<(), TargetError> {
|
||||
self.apply_calls.fetch_add(1, Ordering::SeqCst);
|
||||
if self.should_fail_apply.load(Ordering::SeqCst) {
|
||||
return Err(TargetError::Configuration("apply failed".to_string()));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn validate_tls_files(&self) -> Result<(), TargetError> {
|
||||
self.validate_calls.fetch_add(1, Ordering::SeqCst);
|
||||
if self.should_fail_validate.load(Ordering::SeqCst) {
|
||||
return Err(TargetError::Configuration("validate failed".to_string()));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn default_options() -> TlsReloadOptions {
|
||||
TlsReloadOptions {
|
||||
enabled: true,
|
||||
detect_mode: ReloadDetectMode::Poll,
|
||||
interval: std::time::Duration::from_secs(1),
|
||||
debounce: std::time::Duration::from_secs(1),
|
||||
min_stable_age: std::time::Duration::from_millis(100),
|
||||
apply_hint: ReloadApplyMode::Lazy,
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn register_builds_initial_material() {
|
||||
let coordinator = TargetTlsReloadCoordinator::new();
|
||||
let target = Arc::new(MockTarget::new("test:webhook"));
|
||||
|
||||
let options = TlsReloadOptions {
|
||||
detect_mode: ReloadDetectMode::Watch, // no poll loop for this test
|
||||
..default_options()
|
||||
};
|
||||
let state = coordinator.register(target.clone(), options).await.unwrap();
|
||||
|
||||
assert_eq!(state.current.load().generation, TargetTlsGeneration(1));
|
||||
assert_eq!(target.build_calls.load(Ordering::SeqCst), 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn register_disabled_returns_error() {
|
||||
let coordinator = TargetTlsReloadCoordinator::new();
|
||||
let target = Arc::new(MockTarget::new("test:webhook"));
|
||||
|
||||
let options = TlsReloadOptions {
|
||||
enabled: false,
|
||||
..default_options()
|
||||
};
|
||||
let result = coordinator.register(target, options).await;
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn shutdown_stops_all_loops() {
|
||||
let coordinator = TargetTlsReloadCoordinator::new();
|
||||
let target = Arc::new(MockTarget::new("test:webhook"));
|
||||
|
||||
let _state = coordinator.register(target.clone(), default_options()).await.unwrap();
|
||||
assert_eq!(coordinator.entries.read().await.len(), 1);
|
||||
|
||||
coordinator.shutdown().await;
|
||||
assert!(coordinator.entries.read().await.is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn force_reload_calls_build_and_apply() {
|
||||
let target = MockTarget::new("test:webhook");
|
||||
let initial_material = Arc::new("initial".to_string());
|
||||
let initial_state = Arc::new(TargetTlsPublishedState {
|
||||
generation: TargetTlsGeneration(1),
|
||||
fingerprint: TargetTlsFingerprint::default(),
|
||||
material: initial_material,
|
||||
loaded_at_unix_ms: 0,
|
||||
});
|
||||
let inputs = target.tls_input_set();
|
||||
let runtime_state = Arc::new(TargetTlsRuntimeState::new(initial_state, inputs));
|
||||
let options = default_options();
|
||||
|
||||
// Force reload should succeed since MockTarget uses empty paths
|
||||
// and the fingerprint won't change from default
|
||||
let result = reload_target_once(&target, &runtime_state, &options).await.unwrap();
|
||||
// Since fingerprint is unchanged (empty paths), generation stays at 1
|
||||
assert_eq!(result, TargetTlsGeneration(1));
|
||||
// Build should NOT be called because fingerprint unchanged
|
||||
assert_eq!(target.build_calls.load(Ordering::SeqCst), 0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn build_failure_preserves_old_generation() {
|
||||
let target = MockTarget::new("test:webhook");
|
||||
target.should_fail_build.store(true, Ordering::SeqCst);
|
||||
|
||||
// Use a non-default fingerprint so the reload will detect a change
|
||||
// (empty paths → default fingerprint ≠ initial fingerprint)
|
||||
let initial_material = Arc::new("initial".to_string());
|
||||
let initial_fingerprint = TargetTlsFingerprint {
|
||||
ca_sha256: Some([1; 32]),
|
||||
client_cert_sha256: None,
|
||||
client_key_sha256: None,
|
||||
};
|
||||
let initial_state = Arc::new(TargetTlsPublishedState {
|
||||
generation: TargetTlsGeneration(1),
|
||||
fingerprint: initial_fingerprint,
|
||||
material: initial_material,
|
||||
loaded_at_unix_ms: 0,
|
||||
});
|
||||
let runtime_state = Arc::new(TargetTlsRuntimeState::new(initial_state, target.tls_input_set()));
|
||||
let options = default_options();
|
||||
|
||||
// Empty paths produce default fingerprint which differs from initial →
|
||||
// validate passes (empty paths), then build is called and fails.
|
||||
let result = reload_target_once(&target, &runtime_state, &options).await;
|
||||
assert!(result.is_err());
|
||||
// Generation should remain at 1
|
||||
assert_eq!(runtime_state.current_generation(), TargetTlsGeneration(1));
|
||||
assert_eq!(target.build_calls.load(Ordering::SeqCst), 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn status_snapshot_reflects_state() {
|
||||
let target = MockTarget::new("test:webhook");
|
||||
let initial_material = Arc::new("initial".to_string());
|
||||
let initial_state = Arc::new(TargetTlsPublishedState {
|
||||
generation: TargetTlsGeneration(1),
|
||||
fingerprint: TargetTlsFingerprint::default(),
|
||||
material: initial_material,
|
||||
loaded_at_unix_ms: 0,
|
||||
});
|
||||
let runtime_state = Arc::new(TargetTlsRuntimeState::new(initial_state, target.tls_input_set()));
|
||||
let options = default_options();
|
||||
|
||||
let snapshot = TargetTlsReloadCoordinator::build_status_snapshot(runtime_state.as_ref(), &options);
|
||||
assert_eq!(snapshot.target_label, "test:webhook");
|
||||
assert_eq!(snapshot.generation, 1);
|
||||
assert!(snapshot.reload_enabled);
|
||||
assert_eq!(snapshot.detect_mode, "poll");
|
||||
assert_eq!(snapshot.apply_mode, "lazy");
|
||||
assert!(snapshot.last_attempt_time.is_none());
|
||||
assert!(snapshot.last_error.is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn apply_failure_preserves_old_generation() {
|
||||
let target = MockTarget::new("test:webhook");
|
||||
target.should_fail_apply.store(true, Ordering::SeqCst);
|
||||
|
||||
// Use a non-default fingerprint so reload detects a change
|
||||
let initial_material = Arc::new("initial".to_string());
|
||||
let initial_fingerprint = TargetTlsFingerprint {
|
||||
ca_sha256: Some([42; 32]),
|
||||
client_cert_sha256: None,
|
||||
client_key_sha256: None,
|
||||
};
|
||||
let initial_state = Arc::new(TargetTlsPublishedState {
|
||||
generation: TargetTlsGeneration(3),
|
||||
fingerprint: initial_fingerprint,
|
||||
material: initial_material,
|
||||
loaded_at_unix_ms: 0,
|
||||
});
|
||||
let runtime_state = Arc::new(TargetTlsRuntimeState::new(initial_state, target.tls_input_set()));
|
||||
let options = default_options();
|
||||
|
||||
let result = reload_target_once(&target, &runtime_state, &options).await;
|
||||
assert!(result.is_err());
|
||||
// Generation should remain at 3
|
||||
assert_eq!(runtime_state.current_generation(), TargetTlsGeneration(3));
|
||||
assert!(runtime_state.last_error.read().is_some());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn validate_failure_prevents_build() {
|
||||
let target = MockTarget::new("test:kafka");
|
||||
target.should_fail_validate.store(true, Ordering::SeqCst);
|
||||
|
||||
// Non-default fingerprint to trigger reload
|
||||
let initial_material = Arc::new("initial".to_string());
|
||||
let initial_fingerprint = TargetTlsFingerprint {
|
||||
ca_sha256: Some([99; 32]),
|
||||
client_cert_sha256: None,
|
||||
client_key_sha256: None,
|
||||
};
|
||||
let initial_state = Arc::new(TargetTlsPublishedState {
|
||||
generation: TargetTlsGeneration(1),
|
||||
fingerprint: initial_fingerprint,
|
||||
material: initial_material,
|
||||
loaded_at_unix_ms: 0,
|
||||
});
|
||||
let runtime_state = Arc::new(TargetTlsRuntimeState::new(initial_state, target.tls_input_set()));
|
||||
let options = default_options();
|
||||
|
||||
let result = reload_target_once(&target, &runtime_state, &options).await;
|
||||
assert!(result.is_err());
|
||||
// Build should NOT have been called
|
||||
assert_eq!(target.build_calls.load(Ordering::SeqCst), 0);
|
||||
// Validate was called
|
||||
assert!(target.validate_calls.load(Ordering::SeqCst) > 0);
|
||||
assert_eq!(runtime_state.current_generation(), TargetTlsGeneration(1));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn error_is_cleared_on_successful_reload() {
|
||||
let target = MockTarget::new("test:nats");
|
||||
|
||||
let initial_material = Arc::new("initial".to_string());
|
||||
let initial_fingerprint = TargetTlsFingerprint {
|
||||
ca_sha256: Some([1; 32]),
|
||||
client_cert_sha256: None,
|
||||
client_key_sha256: None,
|
||||
};
|
||||
let initial_state = Arc::new(TargetTlsPublishedState {
|
||||
generation: TargetTlsGeneration(1),
|
||||
fingerprint: initial_fingerprint,
|
||||
material: initial_material,
|
||||
loaded_at_unix_ms: 0,
|
||||
});
|
||||
let runtime_state = Arc::new(TargetTlsRuntimeState::new(initial_state, target.tls_input_set()));
|
||||
let options = default_options();
|
||||
|
||||
// First: fail the reload
|
||||
target.should_fail_build.store(true, Ordering::SeqCst);
|
||||
let _ = reload_target_once(&target, &runtime_state, &options).await;
|
||||
assert!(runtime_state.last_error.read().is_some());
|
||||
|
||||
// Now succeed (fingerprint still different from default)
|
||||
target.should_fail_build.store(false, Ordering::SeqCst);
|
||||
let result = reload_target_once(&target, &runtime_state, &options).await;
|
||||
assert!(result.is_ok());
|
||||
assert!(runtime_state.last_error.read().is_none());
|
||||
assert!(runtime_state.last_success_unix_ms() > 0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn last_good_is_never_overwritten_by_failed_reload() {
|
||||
let target = MockTarget::new("test:amqp");
|
||||
|
||||
let initial_material = Arc::new("good".to_string());
|
||||
let initial_fingerprint = TargetTlsFingerprint {
|
||||
ca_sha256: Some([5; 32]),
|
||||
client_cert_sha256: None,
|
||||
client_key_sha256: None,
|
||||
};
|
||||
let initial_state = Arc::new(TargetTlsPublishedState {
|
||||
generation: TargetTlsGeneration(2),
|
||||
fingerprint: initial_fingerprint.clone(),
|
||||
material: initial_material.clone(),
|
||||
loaded_at_unix_ms: 100,
|
||||
});
|
||||
let runtime_state = Arc::new(TargetTlsRuntimeState::new(initial_state, target.tls_input_set()));
|
||||
|
||||
// Verify last_good matches initial
|
||||
let good = runtime_state.last_good.load();
|
||||
assert_eq!(good.generation, TargetTlsGeneration(2));
|
||||
|
||||
// Fail a reload
|
||||
target.should_fail_build.store(true, Ordering::SeqCst);
|
||||
let _ = reload_target_once(&target, &runtime_state, &default_options()).await;
|
||||
|
||||
// last_good should still be the initial state
|
||||
let good_after = runtime_state.last_good.load();
|
||||
assert_eq!(good_after.generation, TargetTlsGeneration(2));
|
||||
assert_eq!(good_after.fingerprint, initial_fingerprint);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn unregister_stops_target_poll_loop() {
|
||||
let coordinator = TargetTlsReloadCoordinator::new();
|
||||
let target = Arc::new(MockTarget::new("test:pulsar"));
|
||||
|
||||
let _state = coordinator.register(target.clone(), default_options()).await.unwrap();
|
||||
assert_eq!(coordinator.entries.read().await.len(), 1);
|
||||
|
||||
coordinator.unregister("test:pulsar").await.unwrap();
|
||||
assert!(coordinator.entries.read().await.is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn bump_generation_saturates_at_max() {
|
||||
let target = MockTarget::new("test:saturation");
|
||||
let initial_material = Arc::new("initial".to_string());
|
||||
let max_gen_state = Arc::new(TargetTlsPublishedState {
|
||||
generation: TargetTlsGeneration(u64::MAX),
|
||||
fingerprint: TargetTlsFingerprint::default(),
|
||||
material: initial_material,
|
||||
loaded_at_unix_ms: 0,
|
||||
});
|
||||
let runtime_state = Arc::new(TargetTlsRuntimeState::new(max_gen_state, target.tls_input_set()));
|
||||
|
||||
let bumped = runtime_state.bump_generation();
|
||||
assert_eq!(bumped, TargetTlsGeneration(u64::MAX)); // saturating add
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
// 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.
|
||||
|
||||
//! TLS fingerprint types for per-target certificate hot-reload detection.
|
||||
|
||||
use crate::error::TargetError;
|
||||
|
||||
/// SHA256 digest per TLS file component used to detect certificate changes.
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
||||
pub struct TargetTlsFingerprint {
|
||||
pub ca_sha256: Option<[u8; 32]>,
|
||||
pub client_cert_sha256: Option<[u8; 32]>,
|
||||
pub client_key_sha256: Option<[u8; 32]>,
|
||||
}
|
||||
|
||||
/// Monotonically increasing generation counter bumped on each successful reload.
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
||||
pub struct TargetTlsGeneration(pub u64);
|
||||
|
||||
/// Combined TLS state held per-target for tracking reload progress.
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
||||
pub struct TargetTlsState {
|
||||
pub generation: TargetTlsGeneration,
|
||||
pub fingerprint: Option<TargetTlsFingerprint>,
|
||||
}
|
||||
|
||||
impl TargetTlsState {
|
||||
/// Compares `next_fingerprint` with the current one. If different, bumps
|
||||
/// generation and stores the new fingerprint. Returns `true` when changed.
|
||||
pub fn refresh(&mut self, next_fingerprint: TargetTlsFingerprint) -> bool {
|
||||
if self.fingerprint.as_ref() == Some(&next_fingerprint) {
|
||||
return false;
|
||||
}
|
||||
|
||||
self.generation = TargetTlsGeneration(self.generation.0.saturating_add(1));
|
||||
self.fingerprint = Some(next_fingerprint);
|
||||
true
|
||||
}
|
||||
|
||||
/// Checks whether `candidate` differs from the stored fingerprint without
|
||||
/// mutating state. Use this to gate a rebuild, then call `refresh` only
|
||||
/// after the rebuild succeeds.
|
||||
pub fn needs_update(&self, candidate: &TargetTlsFingerprint) -> bool {
|
||||
self.fingerprint.as_ref() != Some(candidate)
|
||||
}
|
||||
|
||||
/// Resets state to default (generation 0, no fingerprint).
|
||||
pub fn reset(&mut self) {
|
||||
*self = Self::default();
|
||||
}
|
||||
}
|
||||
|
||||
/// Reads the three TLS material files from disk and returns a fingerprint
|
||||
/// computed from their SHA256 digests. Empty paths produce `None` digests.
|
||||
pub async fn build_target_tls_fingerprint(
|
||||
ca_path: &str,
|
||||
client_cert_path: &str,
|
||||
client_key_path: &str,
|
||||
) -> Result<TargetTlsFingerprint, TargetError> {
|
||||
async fn load_optional_digest(path: &str) -> Result<Option<[u8; 32]>, TargetError> {
|
||||
if path.is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let bytes = tokio::fs::read(path)
|
||||
.await
|
||||
.map_err(|e| TargetError::Configuration(format!("Failed to read TLS material '{path}': {e}")))?;
|
||||
let digest = rustfs_tls_runtime::TlsFingerprint::from_optional_bytes(Some(&bytes), None, None, None, None).server_sha256;
|
||||
Ok(digest)
|
||||
}
|
||||
|
||||
Ok(TargetTlsFingerprint {
|
||||
ca_sha256: load_optional_digest(ca_path).await?,
|
||||
client_cert_sha256: load_optional_digest(client_cert_path).await?,
|
||||
client_key_sha256: load_optional_digest(client_key_path).await?,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{TargetTlsFingerprint, TargetTlsGeneration, TargetTlsState};
|
||||
|
||||
#[test]
|
||||
fn refresh_increments_generation_only_when_fingerprint_changes() {
|
||||
let mut state = TargetTlsState::default();
|
||||
let first = TargetTlsFingerprint {
|
||||
ca_sha256: Some([1; 32]),
|
||||
client_cert_sha256: None,
|
||||
client_key_sha256: None,
|
||||
};
|
||||
let second = TargetTlsFingerprint {
|
||||
ca_sha256: Some([2; 32]),
|
||||
client_cert_sha256: None,
|
||||
client_key_sha256: None,
|
||||
};
|
||||
|
||||
assert!(state.refresh(first.clone()));
|
||||
assert_eq!(state.generation, TargetTlsGeneration(1));
|
||||
assert!(!state.refresh(first));
|
||||
assert_eq!(state.generation, TargetTlsGeneration(1));
|
||||
assert!(state.refresh(second));
|
||||
assert_eq!(state.generation, TargetTlsGeneration(2));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_clears_generation_and_fingerprint() {
|
||||
let mut state = TargetTlsState {
|
||||
generation: TargetTlsGeneration(5),
|
||||
fingerprint: Some(TargetTlsFingerprint {
|
||||
ca_sha256: Some([9; 32]),
|
||||
client_cert_sha256: None,
|
||||
client_key_sha256: None,
|
||||
}),
|
||||
};
|
||||
|
||||
state.reset();
|
||||
assert_eq!(state, TargetTlsState::default());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fingerprint_eq_when_all_fields_match() {
|
||||
let a = TargetTlsFingerprint {
|
||||
ca_sha256: Some([42; 32]),
|
||||
client_cert_sha256: Some([1; 32]),
|
||||
client_key_sha256: None,
|
||||
};
|
||||
let b = TargetTlsFingerprint {
|
||||
ca_sha256: Some([42; 32]),
|
||||
client_cert_sha256: Some([1; 32]),
|
||||
client_key_sha256: None,
|
||||
};
|
||||
assert_eq!(a, b);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fingerprint_ne_when_ca_differs() {
|
||||
let a = TargetTlsFingerprint {
|
||||
ca_sha256: Some([1; 32]),
|
||||
client_cert_sha256: None,
|
||||
client_key_sha256: None,
|
||||
};
|
||||
let b = TargetTlsFingerprint {
|
||||
ca_sha256: Some([2; 32]),
|
||||
client_cert_sha256: None,
|
||||
client_key_sha256: None,
|
||||
};
|
||||
assert_ne!(a, b);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
// 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.
|
||||
|
||||
//! Target-level TLS reload metrics.
|
||||
|
||||
use ::metrics::{counter, describe_counter, describe_gauge, describe_histogram, gauge, histogram};
|
||||
|
||||
const TARGET_TLS_RELOAD_TOTAL: &str = "rustfs_target_tls_reload_total";
|
||||
const TARGET_TLS_RELOAD_SKIPPED_TOTAL: &str = "rustfs_target_tls_reload_skipped_total";
|
||||
const TARGET_TLS_GENERATION: &str = "rustfs_target_tls_generation";
|
||||
const TARGET_TLS_RELOAD_DURATION_SECONDS: &str = "rustfs_target_tls_reload_duration_seconds";
|
||||
const TARGET_TLS_PUBLICATION_FAIL_TOTAL: &str = "rustfs_target_tls_publication_fail_total";
|
||||
const TARGET_TLS_ACTIVE_GENERATION_MISMATCH_TOTAL: &str = "rustfs_target_tls_active_generation_mismatch_total";
|
||||
|
||||
/// Describes all target TLS metrics. Call once during initialization.
|
||||
pub fn init_target_tls_metrics() {
|
||||
describe_counter!(TARGET_TLS_RELOAD_TOTAL, "Total number of TLS reload attempts per target");
|
||||
describe_counter!(
|
||||
TARGET_TLS_RELOAD_SKIPPED_TOTAL,
|
||||
"Number of TLS reloads skipped per target (unchanged, etc.)"
|
||||
);
|
||||
describe_gauge!(TARGET_TLS_GENERATION, "Current TLS generation per target");
|
||||
describe_histogram!(TARGET_TLS_RELOAD_DURATION_SECONDS, "Duration of TLS reload attempts per target");
|
||||
describe_counter!(TARGET_TLS_PUBLICATION_FAIL_TOTAL, "Number of TLS reload publication failures per target");
|
||||
describe_counter!(
|
||||
TARGET_TLS_ACTIVE_GENERATION_MISMATCH_TOTAL,
|
||||
"Number of times a target's active connection used a stale TLS generation"
|
||||
);
|
||||
}
|
||||
|
||||
/// Records a TLS reload result (success or failure).
|
||||
pub fn record_target_tls_reload_result(target: &str, result: &str, duration_secs: f64, generation: u64) {
|
||||
counter!(TARGET_TLS_RELOAD_TOTAL, "target_id" => target.to_string(), "result" => result.to_string()).increment(1);
|
||||
histogram!(TARGET_TLS_RELOAD_DURATION_SECONDS, "target_id" => target.to_string(), "result" => result.to_string())
|
||||
.record(duration_secs);
|
||||
gauge!(TARGET_TLS_GENERATION, "target_id" => target.to_string()).set(generation as f64);
|
||||
}
|
||||
|
||||
/// Records a skipped reload (typically because fingerprint was unchanged).
|
||||
pub fn record_target_tls_reload_skipped(target: &str, reason: &str) {
|
||||
counter!(TARGET_TLS_RELOAD_SKIPPED_TOTAL, "target_id" => target.to_string(), "reason" => reason.to_string()).increment(1);
|
||||
}
|
||||
|
||||
/// Records a TLS reload publication failure.
|
||||
pub fn record_target_tls_publication_fail(target: &str) {
|
||||
counter!(TARGET_TLS_PUBLICATION_FAIL_TOTAL, "target_id" => target.to_string()).increment(1);
|
||||
}
|
||||
|
||||
/// Records that a target used a stale TLS generation (active ≠ latest published).
|
||||
pub fn record_target_tls_stale_generation(target: &str) {
|
||||
counter!(TARGET_TLS_ACTIVE_GENERATION_MISMATCH_TOTAL, "target_id" => target.to_string()).increment(1);
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
// 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.
|
||||
|
||||
//! Unified TLS hot-reload infrastructure for notification targets.
|
||||
//!
|
||||
//! This module provides:
|
||||
//! - Fingerprint-based change detection (`fingerprint`)
|
||||
//! - Per-target reload configuration (`config`)
|
||||
//! - Runtime state tracking with atomic timestamps (`state`)
|
||||
//! - The `ReloadableTargetTls` trait protocol (`trait`)
|
||||
//! - TLS material validation helpers (`validate`)
|
||||
//! - The reload coordinator with background poll loops (`coordinator`)
|
||||
//! - Target-level reload metrics (`metrics`)
|
||||
|
||||
pub mod adapter;
|
||||
pub mod config;
|
||||
pub mod coordinator;
|
||||
pub mod fingerprint;
|
||||
pub mod metrics;
|
||||
pub mod state;
|
||||
pub mod r#trait;
|
||||
pub mod validate;
|
||||
|
||||
pub use adapter::TlsReloadAdapter;
|
||||
pub use coordinator::TargetTlsReloadCoordinator;
|
||||
pub use fingerprint::{TargetTlsFingerprint, TargetTlsGeneration, TargetTlsState, build_target_tls_fingerprint};
|
||||
pub use metrics::init_target_tls_metrics;
|
||||
pub use state::{TargetTlsInputSet, TargetTlsPublishedState, TargetTlsRuntimeState, TargetTlsStatusSnapshot};
|
||||
pub use r#trait::ReloadableTargetTls;
|
||||
pub use validate::validate_tls_material;
|
||||
@@ -0,0 +1,127 @@
|
||||
// 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.
|
||||
|
||||
//! Per-target TLS reload runtime state with atomic timestamps and error tracking.
|
||||
|
||||
use super::fingerprint::{TargetTlsFingerprint, TargetTlsGeneration};
|
||||
use ::arc_swap::ArcSwap;
|
||||
use serde::Serialize;
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
|
||||
/// Describes which TLS files a target reads.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct TargetTlsInputSet {
|
||||
pub ca_path: String,
|
||||
pub client_cert_path: String,
|
||||
pub client_key_path: String,
|
||||
/// Human-readable label for logging and metrics (e.g. "webhook:primary").
|
||||
pub target_label: String,
|
||||
}
|
||||
|
||||
impl TargetTlsInputSet {
|
||||
/// Returns `true` when no TLS paths are configured (no CA, cert, or key).
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.ca_path.is_empty() && self.client_cert_path.is_empty() && self.client_key_path.is_empty()
|
||||
}
|
||||
}
|
||||
|
||||
/// Immutable snapshot of a successfully published TLS material generation.
|
||||
pub struct TargetTlsPublishedState<M> {
|
||||
pub generation: TargetTlsGeneration,
|
||||
pub fingerprint: TargetTlsFingerprint,
|
||||
pub material: Arc<M>,
|
||||
pub loaded_at_unix_ms: u64,
|
||||
}
|
||||
|
||||
/// Per-target TLS reload runtime state. Owns the current published material
|
||||
/// and tracks timestamps and the last error for observability.
|
||||
pub struct TargetTlsRuntimeState<M> {
|
||||
/// The currently active TLS material generation.
|
||||
pub current: ArcSwap<TargetTlsPublishedState<M>>,
|
||||
/// The last known-good generation (never overwritten by a failed reload).
|
||||
pub last_good: ArcSwap<TargetTlsPublishedState<M>>,
|
||||
/// Unix-millis timestamp of the last reload *attempt* (success or failure).
|
||||
pub last_attempt_unix_ms: AtomicU64,
|
||||
/// Unix-millis timestamp of the last *successful* reload.
|
||||
pub last_success_unix_ms: AtomicU64,
|
||||
/// Last reload error message, if any.
|
||||
pub last_error: parking_lot::RwLock<Option<String>>,
|
||||
/// The TLS file paths this state watches.
|
||||
pub inputs: TargetTlsInputSet,
|
||||
}
|
||||
|
||||
impl<M> TargetTlsRuntimeState<M> {
|
||||
/// Creates a new runtime state with the given initial published state.
|
||||
pub fn new(initial: Arc<TargetTlsPublishedState<M>>, inputs: TargetTlsInputSet) -> Self {
|
||||
Self {
|
||||
current: ArcSwap::from(initial.clone()),
|
||||
last_good: ArcSwap::from(initial),
|
||||
last_attempt_unix_ms: AtomicU64::new(0),
|
||||
last_success_unix_ms: AtomicU64::new(0),
|
||||
last_error: parking_lot::RwLock::new(None),
|
||||
inputs,
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the generation of the currently active material.
|
||||
pub fn current_generation(&self) -> TargetTlsGeneration {
|
||||
self.current.load().generation
|
||||
}
|
||||
|
||||
/// Atomically bumps and returns the next generation.
|
||||
pub fn bump_generation(&self) -> TargetTlsGeneration {
|
||||
// Load the current generation from the arc-swap, compute next,
|
||||
// and return it. The caller is responsible for publishing the new state.
|
||||
let current = self.current.load();
|
||||
TargetTlsGeneration(current.generation.0.saturating_add(1))
|
||||
}
|
||||
|
||||
/// Records the timestamp of a reload attempt.
|
||||
pub fn mark_attempt(&self, unix_ms: u64) {
|
||||
self.last_attempt_unix_ms.store(unix_ms, Ordering::Release);
|
||||
}
|
||||
|
||||
/// Records the timestamp of a successful reload.
|
||||
pub fn mark_success(&self, unix_ms: u64) {
|
||||
self.last_success_unix_ms.store(unix_ms, Ordering::Release);
|
||||
}
|
||||
|
||||
/// Returns the last attempt timestamp.
|
||||
pub fn last_attempt_unix_ms(&self) -> u64 {
|
||||
self.last_attempt_unix_ms.load(Ordering::Acquire)
|
||||
}
|
||||
|
||||
/// Returns the last success timestamp.
|
||||
pub fn last_success_unix_ms(&self) -> u64 {
|
||||
self.last_success_unix_ms.load(Ordering::Acquire)
|
||||
}
|
||||
}
|
||||
|
||||
/// Read-only status snapshot for admin/debug visibility.
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct TargetTlsStatusSnapshot {
|
||||
pub target_label: String,
|
||||
pub generation: u64,
|
||||
pub reload_enabled: bool,
|
||||
pub detect_mode: &'static str,
|
||||
pub apply_mode: &'static str,
|
||||
pub last_attempt_time: Option<u64>,
|
||||
pub last_success_time: Option<u64>,
|
||||
pub last_error: Option<String>,
|
||||
/// TLS file paths this target watches (for admin diagnostics).
|
||||
pub ca_path: String,
|
||||
pub client_cert_path: String,
|
||||
pub client_key_path: String,
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
// 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.
|
||||
|
||||
//! The `ReloadableTargetTls` trait — the public protocol each TLS-capable
|
||||
//! target implements to participate in coordinated hot-reload.
|
||||
|
||||
use crate::error::TargetError;
|
||||
use async_trait::async_trait;
|
||||
use std::sync::Arc;
|
||||
|
||||
use super::config::ReloadApplyMode;
|
||||
use super::fingerprint::TargetTlsGeneration;
|
||||
use super::state::TargetTlsInputSet;
|
||||
|
||||
/// Protocol that each TLS-capable target implements so the reload coordinator
|
||||
/// can drive certificate hot-reload without knowing the target's internals.
|
||||
///
|
||||
/// The target is responsible for:
|
||||
/// - Declaring which TLS files it reads (`tls_input_set`)
|
||||
/// - Building a new client/pool/connector from current files (`build_tls_material`)
|
||||
/// - Atomically swapping the active connection state (`apply_tls_material`)
|
||||
///
|
||||
/// The coordinator is responsible for:
|
||||
/// - Deciding *when* to check
|
||||
/// - Detecting *whether* material changed
|
||||
/// - Ensuring *safety* (validate, build-then-apply, fallback on failure)
|
||||
#[async_trait]
|
||||
pub trait ReloadableTargetTls: Send + Sync + 'static {
|
||||
/// The rebuilt connection/client/pool object this target uses.
|
||||
type Material: Send + Sync + 'static;
|
||||
|
||||
/// Returns the TLS file paths this target reads.
|
||||
fn tls_input_set(&self) -> TargetTlsInputSet;
|
||||
|
||||
/// Build a fresh TLS material object from current files on disk.
|
||||
///
|
||||
/// Called by the coordinator on the reload path only — never on the send hot path.
|
||||
async fn build_tls_material(&self) -> Result<Self::Material, TargetError>;
|
||||
|
||||
/// Atomically apply new TLS material, replacing the current active connection state.
|
||||
///
|
||||
/// On success, the target's internal state must point to the new material.
|
||||
/// On failure, the target must keep its current state unchanged.
|
||||
async fn apply_tls_material(
|
||||
&self,
|
||||
generation: TargetTlsGeneration,
|
||||
material: Arc<Self::Material>,
|
||||
mode: ReloadApplyMode,
|
||||
) -> Result<(), TargetError>;
|
||||
|
||||
/// Optional pre-check: validate that TLS files on disk are self-consistent
|
||||
/// (cert/key pair parseable, CA loadable) before attempting `build_tls_material`.
|
||||
/// Default implementation returns `Ok(())`.
|
||||
async fn validate_tls_files(&self) -> Result<(), TargetError> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
// 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.
|
||||
|
||||
//! TLS material validation helpers used by the reload coordinator before
|
||||
//! attempting to build new client/pool objects.
|
||||
|
||||
use crate::error::TargetError;
|
||||
use rustfs_tls_runtime::{load_certs, load_private_key};
|
||||
|
||||
/// Validates that a client certificate and private key file can be loaded
|
||||
/// and paired together. Returns `Ok(())` if both files parse successfully,
|
||||
/// or `Ok(())` if both paths are empty (no mTLS configured).
|
||||
pub fn validate_cert_key_pairing(cert_path: &str, key_path: &str) -> Result<(), TargetError> {
|
||||
if cert_path.is_empty() && key_path.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
if cert_path.is_empty() || key_path.is_empty() {
|
||||
return Err(TargetError::Configuration(
|
||||
"Client certificate and key must both be specified or both be empty".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
load_certs(cert_path).map_err(|e| TargetError::Configuration(format!("Invalid client certificate '{cert_path}': {e}")))?;
|
||||
|
||||
load_private_key(key_path).map_err(|e| TargetError::Configuration(format!("Invalid client key '{key_path}': {e}")))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Validates that a CA certificate file can be loaded. Returns `Ok(())`
|
||||
/// if the path is empty (no custom CA) or if the file parses successfully.
|
||||
pub fn validate_ca_file(ca_path: &str) -> Result<(), TargetError> {
|
||||
if ca_path.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
load_certs(ca_path).map_err(|e| TargetError::Configuration(format!("Invalid CA certificate '{ca_path}': {e}")))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Validates all three TLS material files in one call.
|
||||
pub fn validate_tls_material(ca_path: &str, cert_path: &str, key_path: &str) -> Result<(), TargetError> {
|
||||
validate_ca_file(ca_path)?;
|
||||
validate_cert_key_pairing(cert_path, key_path)
|
||||
}
|
||||
Reference in New Issue
Block a user