Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

17 changes: 14 additions & 3 deletions crates/health/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -31,20 +31,28 @@ carbide-instrument = { path = "../instrument" }
arc-swap = { workspace = true }
async-trait = { workspace = true }
base64 = { workspace = true }
bytes = { workspace = true }
chrono = { workspace = true }
dashmap = { workspace = true }
figment = { workspace = true, features = ["toml", "env"] }
futures = { workspace = true }
http = { workspace = true }
http-body-util = { workspace = true }
humantime = { workspace = true }
humantime-serde = { workspace = true }
hyper = { workspace = true }
hyper-rustls = { workspace = true, features = ["http2"] }
hyper-util = { workspace = true }
hyper-rustls = { workspace = true, features = ["http1", "http2"] }
hyper-util = { workspace = true, features = [
"client-legacy",
"http1",
"http2",
"tokio",
] }
mac_address = { workspace = true }
prometheus = { workspace = true }
reqwest = { workspace = true, features = ["query", "json"] }
reqwest = { workspace = true, features = ["query", "json", "rustls"] }
rustls = { workspace = true }
rustls-pemfile = { workspace = true }
rustls-pki-types = { workspace = true }
serde = { features = ["derive"], workspace = true }
serde_json = { workspace = true }
Expand Down Expand Up @@ -80,6 +88,7 @@ nv-redfish = { workspace = true, features = [
] }
rand = { workspace = true }
url = { workspace = true, features = ["serde"] }
x509-parser = { workspace = true }

# [local-dependencies]
carbide-health-report = { path = "../health-report" }
Expand All @@ -100,7 +109,9 @@ bench-hooks = []
[dev-dependencies]
carbide-test-support = { path = "../test-support" }
criterion = { workspace = true }
rcgen = { workspace = true }
tempfile = { workspace = true }
time = { workspace = true }

[[bench]]
name = "sink_pipeline"
Expand Down
40 changes: 36 additions & 4 deletions crates/health/example/config.example.toml
Original file line number Diff line number Diff line change
Expand Up @@ -208,18 +208,50 @@ logs_collection_interval = "5m"
state_refresh_interval = "30m"
logs_state_file = "/tmp/logs_collector_{machine_id}.json"

# ==============================================================================
# TLS: Shared certificate configuration for switch collectors
# ==============================================================================

# Optional mTLS profile for direct switch collector connections.
# This section is shared by switch collectors, but it is not itself a collector.
# This is intentionally separate from Carbide API / SPIFFE certificate paths.
# When configured, NVUE REST, NVUE gNMI, NMX-T, and NMX-C use this CA bundle,
# client certificate, and client key for switch host connections.
#
# Switch server certificates must be valid for the TLS server name used by
# health. Collectors always open TCP connections to discovered switch IPs. If
# switch certificates contain DNS SANs instead of IP SANs, set tls_server_name
# here. The value is used only for TLS SNI and certificate verification, not
# for DNS resolution or endpoint discovery.
# For HTTP collectors, the request URL and HTTP Host header stay on the
# discovered switch IP. Only the TLS server name changes.
#
# Rotation behavior:
# - NMX-T and NVUE REST share one cached HTTP client for this mTLS profile.
# The cache is checked on the shortest enabled HTTP collector poll interval,
# so cert file changes are adopted by the next reload window without building
# a client per switch target.
# - NMX-C and NVUE gNMI read refreshed material when a long-lived stream
# reconnects.
# - Existing HTTP clients and long-lived streams are not proactively closed on
# file changes.
#
# [tls.switch]
# ca_cert_path = "/var/run/secrets/switch-mtls/ca.crt"
# client_cert_path = "/var/run/secrets/switch-mtls/tls.crt"
# client_key_path = "/var/run/secrets/switch-mtls/tls.key"
# tls_server_name = "switches.example.forge"

# ==============================================================================
# Switch Host Collectors: What data to collect from NVLink Switch Hosts
# ==============================================================================

# NMX-C collection connects directly to the primary switch-host gRPC endpoint
# when switch discovery metadata says NMX-C is enabled for that switch.
# FabricManager (NMX-C) must be running there. Current switch endpoints use
# plaintext gRPC over HTTP/2 on 9370.
# FabricManager (NMX-C) must be running there. Without [tls.switch],
# current switch endpoints use plaintext gRPC over HTTP/2 on 9370.
# NMX-C notifications currently emit log events only; metrics and health reports
# are separate scope.
#
# TODO: TLS, certificate loading, and mTLS are future scope.
[collectors.nmxc]
# Enable the NMX-C streaming collector for eligible primary switch-host endpoints.
enabled = false
Expand Down
2 changes: 1 addition & 1 deletion crates/health/src/collectors/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ pub use logs::{
};
pub use nmxc::{NmxcCollector, NmxcCollectorConfig};
pub use nmxt::{NmxtCollector, NmxtCollectorConfig};
pub use nvue::gnmi::subscriber::spawn_gnmi_collector;
pub(crate) use nvue::gnmi::subscriber::spawn_gnmi_collector;
pub use nvue::rest::collector::{NvueRestCollector, NvueRestCollectorConfig};
pub use runtime::{
BackoffConfig, Collector, CollectorStartContext, EventStream, ExponentialBackoff,
Expand Down
66 changes: 46 additions & 20 deletions crates/health/src/collectors/nmxc.rs
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,6 @@
*/

use std::borrow::Cow;
use std::net::IpAddr;
use std::sync::Arc;
use std::time::Duration;

Expand All @@ -35,7 +34,7 @@ use tonic::transport::{Channel, Endpoint};

use crate::HealthError;
use crate::collectors::runtime::{StreamingCollector, StreamingConnectResult};
use crate::config::NmxcCollectorConfig as NmxcCollectorOptions;
use crate::config::{MtlsProfileConfig, NmxcCollectorConfig as NmxcCollectorOptions};
use crate::endpoint::BmcEndpoint;
use crate::sink::{CollectorEvent, LogRecord};

Expand All @@ -49,6 +48,9 @@ type NmxcNotificationStream = Streaming<ServerNotification>;
pub struct NmxcCollectorConfig {
/// User-facing collector settings from the health service configuration.
pub nmxc_config: NmxcCollectorOptions,

/// mTLS profile used for the gRPC channel when configured.
pub(crate) tls_config: Option<MtlsProfileConfig>,
}

/// Streaming collector for NMX-C server notifications.
Expand All @@ -59,6 +61,10 @@ pub struct NmxcCollector {
heartbeat_rate: u32,
connect_timeout: Duration,
rpc_timeout: Duration,

// NMX-C subscriptions are long-lived. Rotated mTLS profile files are picked
// up when the stream reconnects and builds a new channel.
tls_config: Option<MtlsProfileConfig>,
}

#[async_trait]
Expand All @@ -74,8 +80,13 @@ impl<B: Bmc + 'static> StreamingCollector<B> for NmxcCollector {
let nmxc_config = config.nmxc_config;
let connect_timeout = nmxc_config.connect_timeout();
let rpc_timeout = nmxc_config.rpc_timeout();
let switch_connect_host = endpoint.switch_connect_host_for_uri();

let endpoint_url = nmxc_endpoint_url(&endpoint.addr.ip, nmxc_config.grpc_port);
let endpoint_url = nmxc_endpoint_url(
switch_connect_host.as_ref(),
nmxc_config.grpc_port,
config.tls_config.is_some(),
);

Ok(Self {
endpoint_url,
Expand All @@ -84,12 +95,19 @@ impl<B: Bmc + 'static> StreamingCollector<B> for NmxcCollector {
heartbeat_rate: nmxc_config.heartbeat_rate,
connect_timeout,
rpc_timeout,
tls_config: config.tls_config,
})
}

/// Connects to NMX-C, completes Hello and Subscribe, and maps notifications into collector events.
async fn connect(&mut self) -> Result<StreamingConnectResult<'_>, HealthError> {
let mut client = nmxc_client(&self.endpoint_url, self.connect_timeout).await?;
let mut client = nmxc_client(
&self.endpoint_url,
self.connect_timeout,
self.tls_config.as_ref(),
)
.await?;

send_hello(&mut client, &self.gateway_id, self.rpc_timeout).await?;

let subscribe_request = SubscribeRequest {
Expand Down Expand Up @@ -217,22 +235,21 @@ fn hello_request(gateway_id: &str) -> ClientHello {
}

/// Builds the switch-host gRPC endpoint URL, including IPv6 bracket formatting.
fn nmxc_endpoint_url(ip: &IpAddr, port: u16) -> String {
let host = match ip {
IpAddr::V4(v4) => v4.to_string(),
IpAddr::V6(v6) => format!("[{v6}]"),
};

// TODO: Replace hard-coded HTTP when NMX-C HTTPS/mTLS support is designed.
format!("http://{host}:{port}")
fn nmxc_endpoint_url(host: &str, port: u16, tls_enabled: bool) -> String {
let scheme = if tls_enabled { "https" } else { "http" };
format!("{scheme}://{host}:{port}")
}

/// Creates a tonic NMX-C client with transport settings scoped to the collector.
///
/// When an mTLS profile is configured, certificate files are read while
/// building the channel, so reconnects pick up rotated material.
async fn nmxc_client(
endpoint_url: &str,
connect_timeout: Duration,
tls_config: Option<&MtlsProfileConfig>,
) -> Result<NmxcClient, HealthError> {
let endpoint = Endpoint::from_shared(endpoint_url.to_string())
let mut endpoint = Endpoint::from_shared(endpoint_url.to_string())
.map_err(|error| nmxc_transport_error(endpoint_url, "parse endpoint", error))?
.connect_timeout(connect_timeout)
// Long-lived Subscribe streams need keepalive so dead peers eventually
Expand All @@ -242,6 +259,13 @@ async fn nmxc_client(
.keep_alive_timeout(NMX_C_HTTP2_KEEPALIVE_TIMEOUT)
.keep_alive_while_idle(true);

if let Some(config) = tls_config {
let tls_config = crate::tls::tonic_tls_config(config).await?;
endpoint = endpoint
.tls_config(tls_config)
.map_err(|error| nmxc_transport_error(endpoint_url, "configure TLS", error))?;
}

let channel = endpoint
.connect()
.await
Expand Down Expand Up @@ -606,8 +630,6 @@ fn log_record(

#[cfg(test)]
mod tests {
use std::net::{IpAddr, Ipv4Addr, Ipv6Addr};

use carbide_test_support::value_scenarios;
use rpc::protos::nmx_c::{
ConfigKeyVal, ConfigKeyVals, FmEventPartitionChange, HealthStateChanged, ServerHeader,
Expand Down Expand Up @@ -720,16 +742,20 @@ mod tests {
.map(|(_, value)| value.as_str())
}

#[test]
/// Verifies NMX-C endpoint URL formatting for IPv4 and IPv6 targets.
fn endpoint_url_brackets_ipv6() {
#[test]
fn endpoint_url_formats_ip_hosts() {
value_scenarios!(
run = |ip| nmxc_endpoint_url(&ip, 9370);
run = |(host, tls_enabled)| nmxc_endpoint_url(host, 9370, tls_enabled);

"endpoint URL" {
IpAddr::V4(Ipv4Addr::new(10, 0, 0, 1)) => "http://10.0.0.1:9370".to_string(),
("10.0.0.1", false) => "http://10.0.0.1:9370".to_string(),

("[::1]", false) => "http://[::1]:9370".to_string(),

("10.0.0.1", true) => "https://10.0.0.1:9370".to_string(),

IpAddr::V6(Ipv6Addr::LOCALHOST) => "http://[::1]:9370".to_string(),
("[::1]", true) => "https://[::1]:9370".to_string(),
}
);
}
Expand Down
Loading
Loading