diff --git a/Cargo.lock b/Cargo.lock index 12326cbb..b50dd5fb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -369,6 +369,45 @@ dependencies = [ "regex-syntax", ] +[[package]] +name = "asn1-rs" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f43a50ac4fdca5df8e885c21b835997f0a1cdee65494a6847694a98652d9d8" +dependencies = [ + "asn1-rs-derive", + "asn1-rs-impl", + "displaydoc", + "nom", + "num-traits", + "rusticata-macros", + "thiserror", + "time", +] + +[[package]] +name = "asn1-rs-derive" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3109e49b1e4909e9db6515a30c633684d68cdeaa252f215214cb4fa1a5bfee2c" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "asn1-rs-impl" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b18050c2cd6fe86c3a76584ef5e0baf286d038cda203eb6223df2cc413565f7" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "astral-tokio-tar" version = "0.6.3" @@ -511,7 +550,7 @@ version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3" dependencies = [ - "bit-vec", + "bit-vec 0.8.0", ] [[package]] @@ -520,6 +559,15 @@ version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" +[[package]] +name = "bit-vec" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b71798fca2c1fe1086445a7258a4bc81e6e49dcd24c8d0dd9a1e57395b603f51" +dependencies = [ + "serde", +] + [[package]] name = "bitflags" version = "2.13.0" @@ -1733,6 +1781,20 @@ dependencies = [ "zeroize", ] +[[package]] +name = "der-parser" +version = "10.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07da5016415d5a3c4dd39b11ed26f915f52fc4e0dc197d87908bc916e51bc1a6" +dependencies = [ + "asn1-rs", + "displaydoc", + "nom", + "num-bigint", + "num-traits", + "rusticata-macros", +] + [[package]] name = "deranged" version = "0.5.8" @@ -2898,6 +2960,12 @@ version = "0.3.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + [[package]] name = "miniz_oxide" version = "0.8.9" @@ -2919,6 +2987,16 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "nom" +version = "7.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +dependencies = [ + "memchr", + "minimal-lexical", +] + [[package]] name = "num" version = "0.4.3" @@ -3059,6 +3137,15 @@ dependencies = [ "web-time", ] +[[package]] +name = "oid-registry" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12f40cff3dde1b6087cc5d5f5d4d65712f34016a03ed60e9c08dcc392736b5b7" +dependencies = [ + "asn1-rs", +] + [[package]] name = "once_cell" version = "1.21.4" @@ -3239,6 +3326,9 @@ dependencies = [ "parquet", "proptest", "prost", + "rcgen", + "rustls", + "rustls-pki-types", "serde_json", "tempfile", "tokio", @@ -3343,6 +3433,7 @@ dependencies = [ "proptest", "prost", "rand 0.8.6", + "rcgen", "reqwest", "rmcp", "schemars 1.2.1", @@ -3720,7 +3811,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4b45fcc2344c680f5025fe57779faef368840d0bd1f42f216291f0dc4ace4744" dependencies = [ "bit-set", - "bit-vec", + "bit-vec 0.8.0", "bitflags", "num-traits", "rand 0.9.4", @@ -3961,6 +4052,20 @@ dependencies = [ "crossbeam-utils", ] +[[package]] +name = "rcgen" +version = "0.14.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57f6d249aad744e274e682777a50283a225a32705394ee6d5fcc01efa25e4055" +dependencies = [ + "pem", + "ring", + "rustls-pki-types", + "time", + "x509-parser", + "yasna", +] + [[package]] name = "redox_syscall" version = "0.5.18" @@ -4181,6 +4286,15 @@ dependencies = [ "semver", ] +[[package]] +name = "rusticata-macros" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "faf0c4a6ece9950b9abdb62b1cfcf2a68b3b67a10ba445b3bb85be2a293d0632" +dependencies = [ + "nom", +] + [[package]] name = "rustix" version = "0.38.44" @@ -5672,6 +5786,24 @@ version = "0.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" +[[package]] +name = "x509-parser" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d43b0f71ce057da06bc0851b23ee24f3f86190b07203dd8f567d0b706a185202" +dependencies = [ + "asn1-rs", + "data-encoding", + "der-parser", + "lazy_static", + "nom", + "oid-registry", + "ring", + "rusticata-macros", + "thiserror", + "time", +] + [[package]] name = "xattr" version = "1.6.1" @@ -5682,6 +5814,16 @@ dependencies = [ "rustix 1.1.4", ] +[[package]] +name = "yasna" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5f6765e852b9b4dc8e2a76843e4d64d1cea8e79bcde0b6901aea8e7c7f08282" +dependencies = [ + "bit-vec 0.9.1", + "time", +] + [[package]] name = "yoke" version = "0.8.3" diff --git a/crates/ourios-ingester/Cargo.toml b/crates/ourios-ingester/Cargo.toml index 31b9c87e..25aa524c 100644 --- a/crates/ourios-ingester/Cargo.toml +++ b/crates/ourios-ingester/Cargo.toml @@ -40,6 +40,12 @@ doctest = false ourios-config = { path = "../ourios-config" } # Shared types (TenantId, config) for the ingest path. ourios-core = { path = "../ourios-core" } +# RFC 0030 TLS seam: rustls builds each listener's ServerConfig (already +# in the workspace tree via reqwest; the provider is pinned to ring in +# code, so only that provider feature is enabled here), and pki-types' +# `pem` feature reads the cert/key/CA files. +rustls = { version = "0.23", default-features = false, features = ["ring", "std", "tls12", "logging"] } +rustls-pki-types = { version = "1", default-features = false, features = ["std"] } # The ingest pipeline's WAL-before-ack durability (RFC 0003 §6.5 / # CLAUDE.md §3.4): append the OtlpBatch frame + fsync before acking. ourios-wal = { path = "../ourios-wal" } @@ -107,6 +113,9 @@ uuid = { version = "1", default-features = false, features = ["std", "v7"] } oidc = ["ourios-core/oidc"] [dev-dependencies] +# RFC 0030 test CAs / leaf certs, minted at test time — no committed +# key material (the RFC 0029 house rule). +rcgen = { version = "0.14", default-features = false, features = ["pem", "crypto", "ring"] } # The RFC0026.2 served-stack arm: serve `LogsServiceServer::with_interceptor` # over a local socket and drive it with a real tonic client, so the # metadata → interceptor → extension → handler handoff is covered end to end. diff --git a/crates/ourios-ingester/src/receiver.rs b/crates/ourios-ingester/src/receiver.rs index 1118a4bc..fa58d940 100644 --- a/crates/ourios-ingester/src/receiver.rs +++ b/crates/ourios-ingester/src/receiver.rs @@ -39,6 +39,7 @@ pub mod http; pub mod materialize; pub mod pipeline; pub mod tenant; +pub mod tls; pub use auth::{AuthBinding, AuthResolver, Unauthenticated, authenticate_bearer}; pub use commit::CommitCoordinator; diff --git a/crates/ourios-ingester/src/receiver/tls.rs b/crates/ourios-ingester/src/receiver/tls.rs new file mode 100644 index 00000000..05f3bfbf --- /dev/null +++ b/crates/ourios-ingester/src/receiver/tls.rs @@ -0,0 +1,202 @@ +//! RFC 0030 §3.1/§3.2 — validated TLS listener settings. +//! +//! One seam for every listener (receiver gRPC/HTTP and the querier +//! surface): [`TlsSettings::from_parts`] is the single validation path +//! for a `*_tls` config block (the RFC 0020 §3.1 doctrine — the error +//! text these functions produce *is* the startup error, whichever +//! front-end supplied the values), and [`TlsSettings::load`] turns the +//! validated settings into a `rustls::ServerConfig` at startup, so an +//! unreadable or malformed PEM fails fast naming the path. + +use std::path::PathBuf; +use std::sync::Arc; +use std::time::Duration; + +use rustls::RootCertStore; +use rustls::server::WebPkiClientVerifier; +use rustls_pki_types::{CertificateDer, PrivateKeyDer, pem::PemObject}; + +/// A config leaf: trimmed, with empty-after-trim treated as unset (the +/// same normalisation the other `build_*` validators apply). +fn present(v: Option<&str>) -> Option<&str> { + v.map(str::trim).filter(|s| !s.is_empty()) +} + +/// `min_version` (RFC 0030 §3.1): TLS 1.0/1.1 are not implemented +/// (rustls does not ship them; the Collector deprecates them). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum TlsMinVersion { + #[default] + V1_2, + V1_3, +} + +/// A validated `*_tls` block (RFC 0030 §3.1). Construction via +/// [`TlsSettings::from_parts`] enforces the §3.1 rules; the PEM files +/// themselves are read by [`TlsSettings::load`], never embedded in +/// config. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct TlsSettings { + pub cert_file: PathBuf, + pub key_file: PathBuf, + /// Present ⇒ mTLS: require-and-verify client certificates against + /// this CA (§3.3). + pub client_ca_file: Option, + pub min_version: TlsMinVersion, + /// `reload_interval_secs` — `None` means never reload. + pub reload_interval: Option, +} + +impl TlsSettings { + /// Validate the raw string leaves of one `*_tls` block. `prefix` is + /// the block's YAML key (e.g. `receiver.grpc_tls`), so every error + /// names the exact offending field (RFC0030.5). All-unset resolves + /// to `Ok(None)` — TLS is opt-in per listener. + /// + /// # Errors + /// + /// A §3.1 rule violation: a lone half of the cert/key pair, any + /// other field without the pair, an unknown `min_version`, or a + /// non-positive `reload_interval_secs` — each naming the exact + /// `{prefix}.*` field. + pub fn from_parts( + prefix: &str, + cert_file: Option<&str>, + key_file: Option<&str>, + client_ca_file: Option<&str>, + min_version: Option<&str>, + reload_interval_secs: Option<&str>, + ) -> Result, String> { + if present(cert_file).is_none() + && present(key_file).is_none() + && present(client_ca_file).is_none() + && present(min_version).is_none() + && present(reload_interval_secs).is_none() + { + return Ok(None); + } + let (cert_file, key_file) = match (present(cert_file), present(key_file)) { + (Some(c), Some(k)) => (PathBuf::from(c), PathBuf::from(k)), + (Some(_), None) => { + return Err(format!( + "{prefix}.key_file must be set alongside {prefix}.cert_file" + )); + } + (None, Some(_)) => { + return Err(format!( + "{prefix}.cert_file must be set alongside {prefix}.key_file" + )); + } + (None, None) => { + // Some other field (client_ca_file / min_version / + // reload_interval_secs) is set without the server pair. + return Err(format!( + "{prefix}.cert_file and {prefix}.key_file are required to enable TLS \ + (the other {prefix}.* fields presuppose server TLS)" + )); + } + }; + let min_version = match present(min_version) { + None | Some("1.2") => TlsMinVersion::V1_2, + Some("1.3") => TlsMinVersion::V1_3, + Some(other) => { + return Err(format!( + "{prefix}.min_version must be \"1.2\" or \"1.3\", got {other:?}" + )); + } + }; + let reload_interval = match present(reload_interval_secs) { + None => None, + Some(raw) => match raw.parse::() { + Ok(secs) if secs > 0 => Some(Duration::from_secs(secs)), + _ => { + return Err(format!( + "{prefix}.reload_interval_secs must be a positive integer number \ + of seconds, got {raw:?}" + )); + } + }, + }; + Ok(Some(Self { + cert_file, + key_file, + client_ca_file: present(client_ca_file).map(PathBuf::from), + min_version, + reload_interval, + })) + } + + /// Read the PEM material and build the listener's + /// `rustls::ServerConfig`. Called at startup (fail fast — RFC0030.5 + /// names the unreadable/malformed path) and again on each reload + /// tick (RFC0030.6). ALPN is left to the listener wiring — gRPC + /// requires `h2`, the HTTP surfaces offer both (§3.2). + /// + /// The crypto provider is pinned to ring explicitly: the workspace + /// dependency tree is what decides which providers are compiled in, + /// and relying on the process default would make this seam's + /// behaviour depend on unrelated crates' feature flags. + /// + /// # Errors + /// + /// An unreadable or non-PEM file (naming the path), an empty + /// certificate chain, an unusable CA, or a cert/key mismatch. + pub fn load(&self) -> Result { + let certs: Vec> = CertificateDer::pem_file_iter(&self.cert_file) + .map_err(|e| format!("cannot read {}: {e}", self.cert_file.display()))? + .collect::>() + .map_err(|e| format!("cannot parse {}: {e}", self.cert_file.display()))?; + if certs.is_empty() { + return Err(format!( + "no PEM certificates found in {}", + self.cert_file.display() + )); + } + let key = PrivateKeyDer::from_pem_file(&self.key_file) + .map_err(|e| format!("cannot read {}: {e}", self.key_file.display()))?; + + let versions: &[&rustls::SupportedProtocolVersion] = match self.min_version { + TlsMinVersion::V1_2 => &[&rustls::version::TLS12, &rustls::version::TLS13], + TlsMinVersion::V1_3 => &[&rustls::version::TLS13], + }; + let builder = rustls::ServerConfig::builder_with_provider(Arc::new( + rustls::crypto::ring::default_provider(), + )) + .with_protocol_versions(versions) + .map_err(|e| format!("TLS protocol-version selection failed: {e}"))?; + + let builder = match &self.client_ca_file { + None => builder.with_no_client_auth(), + Some(ca_path) => { + let mut roots = RootCertStore::empty(); + for ca in CertificateDer::pem_file_iter(ca_path) + .map_err(|e| format!("cannot read {}: {e}", ca_path.display()))? + { + let ca = ca.map_err(|e| format!("cannot parse {}: {e}", ca_path.display()))?; + roots + .add(ca) + .map_err(|e| format!("cannot use a CA from {}: {e}", ca_path.display()))?; + } + if roots.is_empty() { + return Err(format!( + "no PEM certificates found in {}", + ca_path.display() + )); + } + let verifier = WebPkiClientVerifier::builder(Arc::new(roots)) + .build() + .map_err(|e| { + format!( + "cannot build the client verifier from {}: {e}", + ca_path.display() + ) + })?; + builder.with_client_cert_verifier(verifier) + } + }; + + builder + .with_single_cert(certs, key) + .map_err(|e| format!("cannot use {}: {e}", self.cert_file.display())) + } +} diff --git a/crates/ourios-ingester/tests/it/rfc0030_tls.rs b/crates/ourios-ingester/tests/it/rfc0030_tls.rs index ee74d52c..491e2fab 100644 --- a/crates/ourios-ingester/tests/it/rfc0030_tls.rs +++ b/crates/ourios-ingester/tests/it/rfc0030_tls.rs @@ -1,8 +1,12 @@ -//! RFC 0030 §5 — TLS/mTLS on the receiver listeners (seven of the -//! nine scenarios; .3/.8 live in the `ourios-server` harness per §6). +//! RFC 0030 §5 — TLS/mTLS on the receiver listeners (six of the nine +//! scenarios; .3/.7/.8 live in the `ourios-server` harness per §6 — +//! .7 asserts a startup warning of the served binary, which only the +//! server crate can spawn). //! -//! Stubs are `#[ignore]`d so the default run stays green while the -//! RFC is red; each names the green slice that discharges it. +//! Remaining stubs are `#[ignore]`d so the default run stays green +//! while the RFC is red; each names the green slice that discharges it. + +use ourios_ingester::receiver::tls::{TlsMinVersion, TlsSettings}; /// Scenario RFC0030.1 — gRPC ingest over TLS. /// See `docs/rfcs/0030-tls-mtls-listeners.md` §5. @@ -43,17 +47,182 @@ fn rfc0030_4_mtls_require_and_verify() { ); } -/// Scenario RFC0030.5 — config validation. +/// Scenario RFC0030.5 — config validation. The §3.1 rules live in +/// `TlsSettings::from_parts` / `load` — the single validation path +/// whose error text *is* the startup error (the RFC 0020 §3.1 +/// doctrine), so the arms assert against the seam directly. /// See `docs/rfcs/0030-tls-mtls-listeners.md` §5. #[test] -#[ignore = "RFC0030.5 stub — implemented in the config green slice"] +// One scenario, many Given/When/Then arms — splitting it would break the +// RFC0030.5 scenario→test mapping (the rfc0029 dex-test precedent). +#[allow(clippy::too_many_lines)] fn rfc0030_5_config_validation() { - todo!( - "RFC0030.5 — cert_file without key_file, client_ca_file \ - without a server pair, min_version 1.1, unreadable or \ - non-PEM cert_file: startup fails naming the exact offending \ - field or path" + // A lone half of the cert/key pair names the missing field. + let err = TlsSettings::from_parts( + "receiver.grpc_tls", + Some("/etc/tls/server.crt"), + None, + None, + None, + None, + ) + .expect_err("cert without key"); + assert!( + err.contains("receiver.grpc_tls.key_file"), + "names the missing key field: {err}" + ); + + let err = TlsSettings::from_parts( + "receiver.http_tls", + None, + Some("/etc/tls/server.key"), + None, + None, + None, + ) + .expect_err("key without cert"); + assert!( + err.contains("receiver.http_tls.cert_file"), + "names the missing cert field: {err}" + ); + + // client_ca_file without the server pair — mTLS presupposes TLS. + let err = TlsSettings::from_parts( + "querier.http_tls", + None, + None, + Some("/etc/tls/ca.crt"), + None, + None, + ) + .expect_err("client CA without a server pair"); + assert!( + err.contains("querier.http_tls.cert_file") && err.contains("querier.http_tls.key_file"), + "names the required pair: {err}" + ); + + // min_version accepts only 1.2 / 1.3. + let err = TlsSettings::from_parts( + "receiver.grpc_tls", + Some("/etc/tls/server.crt"), + Some("/etc/tls/server.key"), + None, + Some("1.1"), + None, + ) + .expect_err("TLS 1.1 is not implemented"); + assert!( + err.contains("receiver.grpc_tls.min_version") && err.contains("1.1"), + "names the field and echoes the value: {err}" + ); + + // reload_interval_secs must be a positive integer. + for bad in ["0", "-5", "5m"] { + let err = TlsSettings::from_parts( + "receiver.grpc_tls", + Some("/etc/tls/server.crt"), + Some("/etc/tls/server.key"), + None, + None, + Some(bad), + ) + .expect_err("non-positive / non-integer reload interval"); + assert!( + err.contains("receiver.grpc_tls.reload_interval_secs"), + "names the field for {bad:?}: {err}" + ); + } + + // All-unset is open (plaintext), not an error — TLS is opt-in. + assert_eq!( + TlsSettings::from_parts("receiver.grpc_tls", None, None, None, None, None) + .expect("all-unset is valid"), + None + ); + + // An unreadable cert path fails naming the path. + let missing = TlsSettings::from_parts( + "receiver.grpc_tls", + Some("/nonexistent/rfc0030/server.crt"), + Some("/nonexistent/rfc0030/server.key"), + None, + None, + None, + ) + .expect("shape-valid settings") + .expect("configured"); + let err = missing.load().expect_err("unreadable cert file"); + assert!( + err.contains("/nonexistent/rfc0030/server.crt"), + "names the path: {err}" + ); + + // A non-PEM cert file fails naming the path. + let tmp = tempfile::TempDir::new().expect("temp"); + let garbage = tmp.path().join("garbage.crt"); + std::fs::write(&garbage, b"this is not PEM").expect("write garbage"); + let key = tmp.path().join("garbage.key"); + std::fs::write(&key, b"also not PEM").expect("write garbage key"); + let non_pem = TlsSettings::from_parts( + "receiver.http_tls", + Some(&garbage.display().to_string()), + Some(&key.display().to_string()), + None, + None, + None, + ) + .expect("shape-valid settings") + .expect("configured"); + let err = non_pem.load().expect_err("non-PEM cert file"); + assert!( + err.contains(&garbage.display().to_string()), + "names the path: {err}" ); + + // An empty client-CA file fails at load, naming the path — never a + // silently empty trust store. + let signed = rcgen::generate_simple_self_signed(vec!["localhost".into()]) + .expect("mint a self-signed pair"); + let cert_path = tmp.path().join("server.crt"); + let key_path = tmp.path().join("server.key"); + std::fs::write(&cert_path, signed.cert.pem()).expect("write cert"); + std::fs::write(&key_path, signed.signing_key.serialize_pem()).expect("write key"); + let empty_ca = tmp.path().join("empty-ca.crt"); + std::fs::write(&empty_ca, b"").expect("write empty CA"); + let with_empty_ca = TlsSettings::from_parts( + "receiver.grpc_tls", + Some(&cert_path.display().to_string()), + Some(&key_path.display().to_string()), + Some(&empty_ca.display().to_string()), + None, + None, + ) + .expect("shape-valid settings") + .expect("configured"); + let err = with_empty_ca.load().expect_err("empty CA file"); + assert!( + err.contains(&empty_ca.display().to_string()), + "names the path: {err}" + ); + + // And a valid pair loads — for both min_version selections. + for (raw, expected) in [ + (None, TlsMinVersion::V1_2), + (Some("1.3"), TlsMinVersion::V1_3), + ] { + let settings = TlsSettings::from_parts( + "receiver.grpc_tls", + Some(&cert_path.display().to_string()), + Some(&key_path.display().to_string()), + None, + raw, + None, + ) + .expect("valid settings") + .expect("configured"); + assert_eq!(settings.min_version, expected); + settings.load().expect("a valid PEM pair builds"); + } } /// Scenario RFC0030.6 — certificate reload. @@ -68,18 +237,6 @@ fn rfc0030_6_certificate_reload() { ); } -/// Scenario RFC0030.7 — plaintext-auth warning. -/// See `docs/rfcs/0030-tls-mtls-listeners.md` §5. -#[test] -#[ignore = "RFC0030.7 stub — implemented in the config green slice"] -fn rfc0030_7_plaintext_auth_warning() { - todo!( - "RFC0030.7 — auth.tokens without a *_tls block: exactly one \ - startup warning naming the listener; with the *_tls block, \ - no warning" - ); -} - /// Scenario RFC0030.9 — `min_version` enforcement. /// See `docs/rfcs/0030-tls-mtls-listeners.md` §5. #[test] diff --git a/crates/ourios-semconv/src/lib.rs b/crates/ourios-semconv/src/lib.rs index 735b7e72..afdd80ec 100644 --- a/crates/ourios-semconv/src/lib.rs +++ b/crates/ourios-semconv/src/lib.rs @@ -204,3 +204,7 @@ pub const EVENT_OURIOS_SERVER_COMPACTION_DISABLED: &str = "ourios.server.compact /// `ourios.server.signal_handler.error` log event name. pub const EVENT_OURIOS_SERVER_SIGNAL_HANDLER_ERROR: &str = "ourios.server.signal_handler.error"; + +/// `ourios.server.tls.plaintext_credentials` log event name. +pub const EVENT_OURIOS_SERVER_TLS_PLAINTEXT_CREDENTIALS: &str = + "ourios.server.tls.plaintext_credentials"; diff --git a/crates/ourios-server/Cargo.toml b/crates/ourios-server/Cargo.toml index 6b3b1dac..568cc3d0 100644 --- a/crates/ourios-server/Cargo.toml +++ b/crates/ourios-server/Cargo.toml @@ -84,6 +84,9 @@ rmcp = { version = "2.1.0", default-features = false, features = ["macros", "ser schemars = { version = "1.2.1", default-features = false, features = ["derive"] } [dev-dependencies] +# RFC 0030 test CAs / leaf certs, minted at test time — no committed +# key material (the RFC 0029 house rule). +rcgen = { version = "0.14", default-features = false, features = ["pem", "crypto", "ring"] } # RFC 0020 env-substitution resolver property tests (config::env_subst): the # OTel Config WG vector table + invariants (escape round-trip, non-recursion). proptest = "=1.11.0" diff --git a/crates/ourios-server/src/config/file.rs b/crates/ourios-server/src/config/file.rs index ff92fbdd..ae0b40bd 100644 --- a/crates/ourios-server/src/config/file.rs +++ b/crates/ourios-server/src/config/file.rs @@ -229,12 +229,48 @@ pub struct ReceiverSection { pub enabled: Option, #[serde(deserialize_with = "scalar_opt")] pub grpc_addr: Option, + /// RFC 0030 §3.1 — TLS on the gRPC listener (`receiver.grpc_tls`). + pub grpc_tls: TlsSection, #[serde(deserialize_with = "scalar_opt")] pub http_addr: Option, + /// RFC 0030 §3.1 — TLS on the HTTP listener (`receiver.http_tls`). + pub http_tls: TlsSection, #[serde(deserialize_with = "scalar_opt")] pub wal_root: Option, } +/// One `*_tls` block (RFC 0030 §3.1). Raw string leaves — the §3.1 +/// rules live in `TlsSettings::from_parts` (the single validation +/// path); paths may ride `${env:…}` like any other value, the file +/// contents never appear in config. +#[derive(Debug, Default, Deserialize)] +#[serde(default, deny_unknown_fields)] +pub struct TlsSection { + #[serde(deserialize_with = "scalar_opt")] + pub cert_file: Option, + #[serde(deserialize_with = "scalar_opt")] + pub key_file: Option, + #[serde(deserialize_with = "scalar_opt")] + pub client_ca_file: Option, + #[serde(deserialize_with = "scalar_opt")] + pub min_version: Option, + #[serde(deserialize_with = "scalar_opt")] + pub reload_interval_secs: Option, +} + +impl TlsSection { + fn substitute( + &mut self, + lookup: &dyn Fn(&str) -> Option, + ) -> Result<(), MalformedReference> { + substitute(&mut self.cert_file, lookup)?; + substitute(&mut self.key_file, lookup)?; + substitute(&mut self.client_ca_file, lookup)?; + substitute(&mut self.min_version, lookup)?; + substitute(&mut self.reload_interval_secs, lookup) + } +} + /// `querier.*` — the query role (RFC 0016 §3.2). #[derive(Debug, Default, Deserialize)] #[serde(default, deny_unknown_fields)] @@ -243,6 +279,9 @@ pub struct QuerierSection { pub enabled: Option, #[serde(deserialize_with = "scalar_opt")] pub http_addr: Option, + /// RFC 0030 §3.1 — TLS on the querier listener (`querier.http_tls`), + /// covering `/mcp`. + pub http_tls: TlsSection, #[serde(deserialize_with = "scalar_opt")] pub default_window_secs: Option, /// The RFC 0027 MCP surface (`querier.mcp.*`). @@ -530,7 +569,9 @@ impl ReceiverSection { ) -> Result<(), MalformedReference> { substitute(&mut self.enabled, lookup)?; substitute(&mut self.grpc_addr, lookup)?; + self.grpc_tls.substitute(lookup)?; substitute(&mut self.http_addr, lookup)?; + self.http_tls.substitute(lookup)?; substitute(&mut self.wal_root, lookup) } } @@ -542,6 +583,7 @@ impl QuerierSection { ) -> Result<(), MalformedReference> { substitute(&mut self.enabled, lookup)?; substitute(&mut self.http_addr, lookup)?; + self.http_tls.substitute(lookup)?; substitute(&mut self.default_window_secs, lookup)?; substitute(&mut self.mcp.enabled, lookup) } diff --git a/crates/ourios-server/src/main.rs b/crates/ourios-server/src/main.rs index 4aade819..2931b234 100644 --- a/crates/ourios-server/src/main.rs +++ b/crates/ourios-server/src/main.rs @@ -41,10 +41,11 @@ use std::time::Duration; use clap::Parser; use ourios_ingester::Compactor; +use ourios_ingester::receiver::tls::TlsSettings; use ourios_parquet::{ CompactionPolicy, ParquetAuditSink, PromotedAttributes, S3Config, StoreConfig, }; -use ourios_server::config::file::FileConfig; +use ourios_server::config::file::{FileConfig, TlsSection}; use ourios_telemetry::TelemetryConfig; use ourios_wal::WalConfig; @@ -99,6 +100,10 @@ struct ServerConfig { #[derive(Debug, Clone, PartialEq, Eq)] struct QuerierParams { http_addr: SocketAddr, + /// RFC 0030 §3.1 — TLS on the querier listener (config-file only; + /// `None` = plaintext). Carried here from this slice on; the + /// acceptor wiring consumes it in the RFC0030.3 slice. + http_tls: Option, default_window_nanos: u64, /// Serve the RFC 0027 MCP surface at `/mcp` (`querier.mcp.enabled` / /// `OURIOS_QUERIER_MCP_ENABLED`; default off). @@ -109,7 +114,12 @@ struct QuerierParams { #[derive(Debug, Clone, PartialEq, Eq)] struct ReceiverParams { grpc_addr: SocketAddr, + /// RFC 0030 §3.1 — TLS per listener (config-file only; `None` = + /// plaintext). Carried here from this slice on; the acceptor wiring + /// consumes them in the RFC0030.1/.2 slices. + grpc_tls: Option, http_addr: SocketAddr, + http_tls: Option, wal_root: PathBuf, } @@ -254,12 +264,19 @@ fn server_config_from_file(file: &FileConfig) -> Result { file.receiver.http_addr.as_deref(), file.receiver.wal_root.as_deref().map(PathBuf::from), )?; + if let Some(receiver) = config.receiver.as_mut() { + receiver.grpc_tls = tls_settings("receiver.grpc_tls", &file.receiver.grpc_tls)?; + receiver.http_tls = tls_settings("receiver.http_tls", &file.receiver.http_tls)?; + } config.querier = build_querier_config( file.querier.enabled.as_deref(), file.querier.http_addr.as_deref(), file.querier.default_window_secs.as_deref(), file.querier.mcp.enabled.as_deref(), )?; + if let Some(querier) = config.querier.as_mut() { + querier.http_tls = tls_settings("querier.http_tls", &file.querier.http_tls)?; + } config.promoted = build_promoted_attributes( &file.storage.promoted_attributes.resource, &file.storage.promoted_attributes.log, @@ -393,6 +410,7 @@ fn build_querier_config( .ok_or("OURIOS_QUERIER_DEFAULT_WINDOW_SECS overflows when converted to nanoseconds")?; Ok(Some(QuerierParams { http_addr, + http_tls: None, default_window_nanos, mcp_enabled, })) @@ -416,7 +434,9 @@ fn build_receiver_config( .ok_or("OURIOS_WAL_ROOT must be set when the receiver role is enabled")?; Ok(Some(ReceiverParams { grpc_addr, + grpc_tls: None, http_addr, + http_tls: None, wal_root, })) } @@ -554,12 +574,87 @@ async fn terminate_signal(#[cfg(unix)] sigterm: Option Option { warn_if_open_mode(config); + warn_if_plaintext_credentials(config); install_terminate_signal() } #[cfg(not(unix))] fn startup_guards(config: &ServerConfig) { warn_if_open_mode(config); + warn_if_plaintext_credentials(config); +} + +/// One `*_tls` block through the single validation path (RFC 0030 +/// §3.1): the raw file leaves into [`TlsSettings::from_parts`], with +/// the block's YAML key as the error prefix. +fn tls_settings(prefix: &str, section: &TlsSection) -> Result, String> { + TlsSettings::from_parts( + prefix, + section.cert_file.as_deref(), + section.key_file.as_deref(), + section.client_ca_file.as_deref(), + section.min_version.as_deref(), + section.reload_interval_secs.as_deref(), + ) +} + +/// Fail fast on unusable TLS material (RFC0030.5): every configured +/// `*_tls` block's files are read and built into a `rustls` config at +/// startup, so an unreadable or malformed PEM is a startup error naming +/// the block and the path — not a first-handshake surprise. +fn preflight_tls(config: &ServerConfig) -> Result<(), String> { + let blocks = [ + ( + "receiver.grpc_tls", + config.receiver.as_ref().and_then(|r| r.grpc_tls.as_ref()), + ), + ( + "receiver.http_tls", + config.receiver.as_ref().and_then(|r| r.http_tls.as_ref()), + ), + ( + "querier.http_tls", + config.querier.as_ref().and_then(|q| q.http_tls.as_ref()), + ), + ]; + for (key, settings) in blocks { + if let Some(settings) = settings { + settings.load().map_err(|e| format!("{key}: {e}"))?; + } + } + Ok(()) +} + +/// RFC 0030 §3.4: credentials over a plaintext listener get one startup +/// warning naming the listener — visible, not fatal (TLS may +/// legitimately terminate at a fronting proxy or mesh). +fn warn_if_plaintext_credentials(config: &ServerConfig) { + if config.auth.is_none() { + return; + } + let mut plaintext: Vec<&str> = Vec::new(); + if let Some(receiver) = &config.receiver { + if receiver.grpc_tls.is_none() { + plaintext.push("receiver.grpc_addr"); + } + if receiver.http_tls.is_none() { + plaintext.push("receiver.http_addr"); + } + } + if let Some(querier) = &config.querier + && querier.http_tls.is_none() + { + plaintext.push("querier.http_addr"); + } + for listener in plaintext { + tracing::warn!( + name: ourios_semconv::EVENT_OURIOS_SERVER_TLS_PLAINTEXT_CREDENTIALS, + listener, + "{listener} serves bearer credentials over plaintext (no *_tls \ + block; RFC 0030 §3.4) — acceptable only behind a \ + TLS-terminating proxy or mesh" + ); + } } /// RFC 0026 §3.1 open mode: with no `auth` configured, any client that can @@ -624,6 +719,7 @@ fn resolve_config(config_path: Option<&Path>) -> Result { std::fs::create_dir_all(root) .map_err(|e| format!("create store root {}: {e}", root.display()))?; } + preflight_tls(&config)?; Ok(config) } diff --git a/crates/ourios-server/tests/it/rfc0030_tls.rs b/crates/ourios-server/tests/it/rfc0030_tls.rs index 7a71d5ff..763b75fb 100644 --- a/crates/ourios-server/tests/it/rfc0030_tls.rs +++ b/crates/ourios-server/tests/it/rfc0030_tls.rs @@ -1,9 +1,15 @@ -//! RFC 0030 §5 — TLS on the querier surface and the served -//! end-to-end (.3/.8; the receiver arms live in the -//! `ourios-ingester` harness per §6). -//! -//! Stubs are `#[ignore]`d so the default run stays green while the -//! RFC is red; each names the green slice that discharges it. +//! RFC 0030 §5 — the server-owned scenarios: the plaintext-auth +//! startup warning (`.7`, implemented — it observes the spawned +//! binary, which only this crate can do), TLS on the querier surface +//! (`.3`), and the served end-to-end (`.8`). The receiver arms live in +//! `crates/ourios-ingester/tests/it/rfc0030_tls.rs` per §6. + +use std::process::Stdio; +use std::time::Duration; + +use tokio::io::{AsyncBufReadExt, BufReader}; +use tokio::process::Command; +use tokio::time::timeout; /// Scenario RFC0030.3 — querier + MCP over TLS. /// See `docs/rfcs/0030-tls-mtls-listeners.md` §5. @@ -18,6 +24,108 @@ fn rfc0030_3_querier_and_mcp_over_tls() { ); } +/// Spawn the server with the given config file, collect stderr until +/// the querier readiness line appears on stdout, and return how many +/// stderr lines contained `needle`. The readiness line is the "warning +/// window" bound: `startup_guards` runs before any role announces +/// readiness, so a warning that exists is on stderr by then. +async fn warnings_before_ready(config_yaml: &str, tmp: &tempfile::TempDir, needle: &str) -> usize { + let config_path = tmp.path().join("ourios.yaml"); + // write + close before the spawn — no handle stays open across it. + std::fs::write(&config_path, config_yaml).expect("write config"); + + let mut child = Command::new(env!("CARGO_BIN_EXE_ourios-server")) + .arg("--config") + .arg(&config_path) + .env("EDGE_TOKEN", "rfc0030-test-token") + // Deterministic regardless of the harness environment: an + // inherited RUST_LOG=error would filter the warning off stderr. + .env("RUST_LOG", "info") + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .kill_on_drop(true) + .spawn() + .expect("spawn ourios-server"); + + let stderr = child.stderr.take().expect("server stderr piped"); + let stdout = child.stdout.take().expect("server stdout piped"); + + let collector = tokio::spawn(async move { + let mut lines = BufReader::new(stderr).lines(); + let mut collected = Vec::new(); + while let Some(line) = lines.next_line().await.expect("read stderr") { + collected.push(line); + } + collected + }); + + let mut stdout_lines = BufReader::new(stdout).lines(); + timeout(Duration::from_secs(15), async { + while let Some(line) = stdout_lines.next_line().await.expect("read stdout") { + if line.contains("querier HTTP listening on") { + return; + } + } + panic!("the querier never announced readiness"); + }) + .await + .expect("readiness before timeout"); + + // Readiness follows the startup guards, so the warning (if any) is + // already written; kill the child to end the stderr stream, and + // reap it so no zombie outlives the test. + child.start_kill().expect("kill server"); + timeout(Duration::from_secs(15), child.wait()) + .await + .expect("child exits after kill") + .expect("wait on child"); + let collected = timeout(Duration::from_secs(15), collector) + .await + .expect("stderr drains after kill") + .expect("collector task"); + collected.iter().filter(|l| l.contains(needle)).count() +} + +/// Scenario RFC0030.7 — plaintext-auth warning: credentials configured +/// and a listener without a `*_tls` block get exactly one startup +/// warning naming that listener; with the block configured, none. +/// See `docs/rfcs/0030-tls-mtls-listeners.md` §5. +#[tokio::test] +async fn rfc0030_7_plaintext_auth_warning() { + let tmp = tempfile::TempDir::new().expect("temp"); + let base = format!( + "storage:\n local:\n bucket_root: {root}\n\ + auth:\n tokens:\n - name: edge\n token: ${{env:EDGE_TOKEN}}\n tenants: [\"acme\"]\n\ + querier:\n enabled: true\n http_addr: 127.0.0.1:0\n", + root = tmp.path().display(), + ); + + // Plaintext listener + credentials: exactly one warning, naming it. + let count = warnings_before_ready( + &base, + &tmp, + "querier.http_addr serves bearer credentials over plaintext", + ) + .await; + assert_eq!(count, 1, "exactly one warning names the listener"); + + // The same listener with its *_tls block: no warning. + let signed = rcgen::generate_simple_self_signed(vec!["localhost".into()]) + .expect("mint a self-signed pair"); + let cert_path = tmp.path().join("server.crt"); + let key_path = tmp.path().join("server.key"); + std::fs::write(&cert_path, signed.cert.pem()).expect("write cert"); + std::fs::write(&key_path, signed.signing_key.serialize_pem()).expect("write key"); + let with_tls = format!( + "{base} http_tls:\n cert_file: {cert}\n key_file: {key}\n", + cert = cert_path.display(), + key = key_path.display(), + ); + let count = + warnings_before_ready(&with_tls, &tmp, "serves bearer credentials over plaintext").await; + assert_eq!(count, 0, "a TLS-configured listener draws no warning"); +} + /// Scenario RFC0030.8 — served end-to-end (Collector-shaped client). /// See `docs/rfcs/0030-tls-mtls-listeners.md` §5. #[test] diff --git a/docs/rfcs/0030-tls-mtls-listeners.md b/docs/rfcs/0030-tls-mtls-listeners.md index 0aac3e6f..78745ea1 100644 --- a/docs/rfcs/0030-tls-mtls-listeners.md +++ b/docs/rfcs/0030-tls-mtls-listeners.md @@ -275,9 +275,10 @@ fixture-key incident). ## 6. Testing strategy - §5 arms live as integration tests in the owning crates - (`ourios-ingester` for .1/.2/.4–.7/.9 receiver arms, - `ourios-server` for .3/.8), joining the consolidated harnesses - (RFC 0028) — no new test binaries. + (`ourios-ingester` for the .1/.2/.4/.5/.6/.9 receiver + seam arms, + `ourios-server` for .3/.7/.8 — .7 observes the spawned binary's + startup warning, which only the server crate can do), joining the + consolidated harnesses (RFC 0028) — no new test binaries. - `rcgen` mints a CA + server/client leaves per test; nothing key-shaped is committed (RFC 0029 precedent). - Reload (.6) drives a temp-dir cert swap and polls handshakes with a diff --git a/semconv/registry/events.yaml b/semconv/registry/events.yaml index e9dca3e9..0d5af722 100644 --- a/semconv/registry/events.yaml +++ b/semconv/registry/events.yaml @@ -28,6 +28,15 @@ groups: (RFC 0026 §3.1 open mode): the listeners accept unauthenticated requests, and any client that can reach them can write into and read from any tenant. + - id: event.ourios.server.tls.plaintext_credentials + type: event + name: ourios.server.tls.plaintext_credentials + stability: development + brief: > + Credentials (`auth.tokens` / `auth.oidc`) are configured but a + listener has no `*_tls` block (RFC 0030 §3.4): bearer tokens + cross the network in cleartext unless TLS terminates at a + fronting proxy or mesh. - id: event.ourios.compaction.sweep.error type: event name: ourios.compaction.sweep.error