diff --git a/config.example.toml b/config.example.toml index d1fefe639..64b6d66a4 100644 --- a/config.example.toml +++ b/config.example.toml @@ -86,7 +86,12 @@ mux_registry_refresh_interval_seconds = 384 id = "example-relay" # Relay URL in the format scheme://pubkey@host url = "http://0xa1cec75a3f0661e99299274182938151e8433c61a19222347ea1313d839229cb4ce4e3e5aa2bdeb71c8fcf1b084963c2@abc.xyz" -# Headers to send with each request for this relay +# Headers to send with each request for this relay, which is how a relay api key is supplied. +# A value is written one of three ways: +# literal -> headers = { X-Api-Key = "my-api-key" } +# file -> headers = { X-Api-Key = { file = "/run/secrets/relay-key" } } +# env -> headers = { X-Api-Key = { env = "RELAY_API_KEY" } } +# A file or env value is read at startup and on every config reload (see the configuration docs). # OPTIONAL headers = { X-MyCustomHeader = "MyCustomValue" } # GET parameters to add to each request URL for this relay diff --git a/crates/cli/src/docker_init.rs b/crates/cli/src/docker_init.rs index 111ab65e3..1738a2581 100644 --- a/crates/cli/src/docker_init.rs +++ b/crates/cli/src/docker_init.rs @@ -269,6 +269,30 @@ fn create_pbs_service(service_config: &mut ServiceCreationInfo) -> eyre::Result< } } + // Relay header secret files, mounted read-only at their own path so the + // config's `{ file = ... }` resolves inside the container unchanged + for path in cb_config.relay_header_files() { + eyre::ensure!( + path.is_absolute(), + "Relay header file must be an absolute path to be mounted into cb_pbs: {}", + path.display() + ); + eyre::ensure!( + path.is_file(), + "Relay header file does not exist or is not a regular file: {}", + path.display() + ); + volumes.push(Volumes::Simple(format!("{}:{}:ro", path.display(), path.display()))); + } + + for env in cb_config.relay_header_envs() { + let (key, val) = get_env_same(env); + envs.insert(key, val); + service_config.warnings.push(format!( + "cb_pbs reads the relay header secret {env} from the environment; set it before `docker compose up`" + )); + } + // Chain spec env/volume if let Some(spec) = &service_config.chain_spec { envs.insert(spec.env.0.clone(), spec.env.1.clone()); @@ -1136,6 +1160,91 @@ mod tests { Ok(()) } + /// Every `{ file = ... }` relay header is bind-mounted read-only at its own + /// path and must exist as an absolute regular file; every `{ env = ... }` + /// is passed through from the compose environment. Both walk mux relays. + #[test] + fn test_create_pbs_service_mounts_relay_header_secrets() -> eyre::Result<()> { + let with_headers = |default: &str, mux: &str| -> CommitBoostConfig { + toml::from_str(&format!( + r#" + chain = "Holesky" + [pbs] + docker_image = "ghcr.io/commit-boost/commit-boost:latest" + [[relays]] + url = "http://0xa1cec75a3f0661e99299274182938151e8433c61a19222347ea1313d839229cb4ce4e3e5aa2bdeb71c8fcf1b084963c2@abc.xyz" + headers = {default} + [[relays]] + url = "http://0xa119589bb33ef52acbb8116832bec2b58fca590fe5c85eac5d3230b44d5bc09fe73ccd21f88eab31d6de16194d17782e@def.xyz" + headers = {default} + [[mux]] + id = "m" + validator_pubkeys = [] + [[mux.relays]] + url = "http://0xa1cec75a3f0661e99299274182938151e8433c61a19222347ea1313d839229cb4ce4e3e5aa2bdeb71c8fcf1b084963c2@ghi.xyz" + headers = {mux} + "# + )) + .expect("valid test config") + }; + let default_key = tempfile::NamedTempFile::new()?; + let mux_key = tempfile::NamedTempFile::new()?; + let mount_of = |file: &tempfile::NamedTempFile| { + format!("{}:{}:ro", file.path().display(), file.path().display()) + }; + + let service_before = create_pbs_service(&mut minimal_service_config())?; + let mut sc = minimal_service_config(); + sc.config_info.cb_config = with_headers( + &format!( + r#"{{ X-Api-Key = {{ file = "{}" }}, X-Token = {{ env = "RELAY_TOKEN" }}, X-Plain = "plain" }}"#, + default_key.path().display() + ), + &format!( + r#"{{ X-Api-Key = {{ file = "{}" }}, X-Token = {{ env = "MUX_TOKEN" }} }}"#, + mux_key.path().display() + ), + ); + let service = create_pbs_service(&mut sc)?; + + let mounts: Vec<&str> = service + .volumes + .iter() + .filter_map(|v| match v { + Volumes::Simple(s) if s.ends_with(":ro") => Some(s.as_str()), + _ => None, + }) + .collect(); + let default_mount = mount_of(&default_key); + let mux_mount = mount_of(&mux_key); + assert!(mounts.contains(&default_mount.as_str()), "{mounts:?}"); + assert!(mounts.contains(&mux_mount.as_str()), "{mounts:?}"); + // the two default relays share a file, so it is mounted once + assert_eq!( + service.volumes.len(), + service_before.volumes.len() + 2, + "one mount per distinct file: {:?}", + service.volumes + ); + assert_eq!(env_str(&service, "RELAY_TOKEN").as_deref(), Some("${RELAY_TOKEN}")); + assert_eq!(env_str(&service, "MUX_TOKEN").as_deref(), Some("${MUX_TOKEN}")); + assert!(sc.warnings.iter().any(|w| w.contains("RELAY_TOKEN")), "{:?}", sc.warnings); + assert!(sc.warnings.iter().any(|w| w.contains("MUX_TOKEN")), "{:?}", sc.warnings); + + const NOT_A_FILE: &str = "does not exist or is not a regular file"; + for (headers, expected) in [ + (r#"{ X-Api-Key = { file = "secrets/relay-key" } }"#, "must be an absolute path"), + (r#"{ X-Api-Key = { file = "/nonexistent/relay-key" } }"#, NOT_A_FILE), + (r#"{ X-Api-Key = { file = "/tmp" } }"#, NOT_A_FILE), + ] { + let mut sc = minimal_service_config(); + sc.config_info.cb_config = with_headers("{}", headers); + let err = create_pbs_service(&mut sc).unwrap_err(); + assert!(err.to_string().contains(expected), "{headers}: {err}"); + } + Ok(()) + } + #[test] fn test_create_pbs_service_exposes_pbs_port() -> eyre::Result<()> { let mut sc = minimal_service_config(); diff --git a/crates/common/src/config/constants.rs b/crates/common/src/config/constants.rs index dc6bb30dd..58dd42ee9 100644 --- a/crates/common/src/config/constants.rs +++ b/crates/common/src/config/constants.rs @@ -90,6 +90,9 @@ pub const HTTP_TIMEOUT_SECONDS_DEFAULT: u64 = 10; /// Max content length for Muxer HTTP responses, in bytes pub const MUXER_HTTP_MAX_LENGTH: usize = 1024 * 1024 * 10; // 10 MiB +/// Caps a mispointed `file`, which would otherwise be read into memory whole +pub const RELAY_HEADER_FILE_MAX_BYTES: u64 = 8 * 1024; + ///////////////////////// MODULES ///////////////////////// /// The unique ID of the module diff --git a/crates/common/src/config/mod.rs b/crates/common/src/config/mod.rs index e0958342c..378c57649 100644 --- a/crates/common/src/config/mod.rs +++ b/crates/common/src/config/mod.rs @@ -1,4 +1,7 @@ -use std::path::PathBuf; +use std::{ + collections::BTreeSet, + path::{Path, PathBuf}, +}; use eyre::{Result, bail}; use serde::{Deserialize, Serialize}; @@ -129,6 +132,23 @@ impl CommitBoostConfig { } } + /// Every custom header value configured on a relay, default or mux + fn relay_header_sources(&self) -> impl Iterator { + let mux_relays = self.muxes.iter().flat_map(|m| m.muxes.iter()).flat_map(|m| &m.relays); + self.relays + .iter() + .chain(mux_relays) + .flat_map(|relay| relay.headers.iter().flat_map(|headers| headers.values())) + } + + pub fn relay_header_files(&self) -> BTreeSet<&Path> { + self.relay_header_sources().filter_map(HeaderSource::as_file).collect() + } + + pub fn relay_header_envs(&self) -> BTreeSet<&str> { + self.relay_header_sources().filter_map(HeaderSource::as_env).collect() + } + /// Helper to return if the signer module is needed based on the config pub fn needs_signer_module(&self) -> bool { self.pbs.with_signer || diff --git a/crates/common/src/config/pbs.rs b/crates/common/src/config/pbs.rs index c261db59d..cdade80ed 100644 --- a/crates/common/src/config/pbs.rs +++ b/crates/common/src/config/pbs.rs @@ -2,8 +2,11 @@ use std::{ collections::HashMap, + fmt, + fs::File, + io::Read, net::{Ipv4Addr, SocketAddr}, - path::PathBuf, + path::{Path, PathBuf}, sync::Arc, }; @@ -12,13 +15,13 @@ use alloy::{ providers::{Provider, ProviderBuilder}, }; use docker_image::DockerImage; -use eyre::{Result, ensure}; +use eyre::{Context, Result, ensure}; use serde::{Deserialize, Serialize, de::DeserializeOwned}; use url::Url; use super::{ - CommitBoostConfig, HTTP_TIMEOUT_SECONDS_DEFAULT, PBS_ENDPOINT_ENV, RuntimeMuxConfig, - load_optional_env_var, + CommitBoostConfig, HTTP_TIMEOUT_SECONDS_DEFAULT, PBS_ENDPOINT_ENV, RELAY_HEADER_FILE_MAX_BYTES, + RuntimeMuxConfig, load_optional_env_var, }; use crate::{ commit::client::SignerClient, @@ -46,6 +49,76 @@ pub enum GetHeaderTransport { Stream, } +/// A custom relay header value: a literal, or a secret read from a file or an +/// environment variable when the relay client is built (at startup and on every +/// reload), so an API key never has to sit in plaintext in the config file. +/// +/// ```toml +/// headers = { X-Api-Key = "literal" } +/// headers = { X-Api-Key = { file = "/run/secrets/relay-key" } } +/// headers = { X-Api-Key = { env = "RELAY_KEY" } } +/// ``` +#[derive(Clone, PartialEq, Eq, Deserialize, Serialize)] +#[serde(untagged)] +pub enum HeaderSource { + Literal(String), + File { file: PathBuf }, + Env { env: String }, +} + +impl HeaderSource { + /// The header value to send. A file or env value has its trailing + /// whitespace dropped (secret stores write a newline) and must be + /// non-empty; a literal is sent as written. + pub fn resolve(&self) -> Result { + let value = match self { + Self::Literal(value) => return Ok(value.clone()), + Self::File { file } => read_secret_file(file)?, + Self::Env { env } => load_env_var(env)?, + }; + let value = value.trim_end().to_string(); + ensure!(!value.is_empty(), "header value from {self:?} is empty"); + Ok(value) + } + + pub(crate) fn as_file(&self) -> Option<&Path> { + match self { + Self::File { file } => Some(file), + _ => None, + } + } + + pub(crate) fn as_env(&self) -> Option<&str> { + match self { + Self::Env { env } => Some(env), + _ => None, + } + } +} + +// A literal is often the secret itself, so Debug never prints it +impl fmt::Debug for HeaderSource { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Literal(_) => f.write_str("Literal()"), + Self::File { file } => write!(f, "File({file:?})"), + Self::Env { env } => write!(f, "Env({env})"), + } + } +} + +fn read_secret_file(file: &Path) -> Result { + let mut value = String::new(); + File::open(file) + .and_then(|f| f.take(RELAY_HEADER_FILE_MAX_BYTES + 1).read_to_string(&mut value)) + .wrap_err_with(|| format!("unable to read header file {file:?}"))?; + ensure!( + value.len() as u64 <= RELAY_HEADER_FILE_MAX_BYTES, + "header file {file:?} is larger than {RELAY_HEADER_FILE_MAX_BYTES} bytes" + ); + Ok(value) +} + #[derive(Debug, Clone, Deserialize, Serialize)] #[serde(deny_unknown_fields)] pub struct RelayConfig { @@ -55,7 +128,7 @@ pub struct RelayConfig { #[serde(rename = "url")] pub entry: RelayEntry, /// Optional headers to send with each request - pub headers: Option>, + pub headers: Option>, /// Optional GET parameters to add to each request pub get_params: Option>, /// How to fetch headers from this relay @@ -462,3 +535,103 @@ fn default_ssv_node_api_url() -> Url { fn default_public_ssv_api_url() -> Url { Url::parse("https://api.ssv.network/api/v4/").expect("default URL is valid") } + +#[cfg(test)] +mod tests { + use std::io::Write; + + use super::*; + use crate::config::test_env::{RELAY_URL, with_env}; + + fn relay_with_headers(headers: &str) -> Result { + toml::from_str(&format!("url = \"{RELAY_URL}\"\nheaders = {headers}\n")) + } + + #[test] + fn test_header_source_parses_all_shapes() { + let config = relay_with_headers( + r#"{ X-Literal = "plain", X-File = { file = "/run/secrets/key" }, X-Env = { env = "RELAY_KEY" } }"#, + ) + .unwrap(); + let headers = config.headers.as_ref().unwrap(); + assert_eq!(headers["X-Literal"], HeaderSource::Literal("plain".into())); + assert_eq!(headers["X-File"], HeaderSource::File { file: "/run/secrets/key".into() }); + assert_eq!(headers["X-Env"], HeaderSource::Env { env: "RELAY_KEY".into() }); + assert_eq!(headers["X-File"].as_file(), Some(Path::new("/run/secrets/key"))); + assert_eq!(headers["X-Env"].as_env(), Some("RELAY_KEY")); + assert_eq!(headers["X-Literal"].as_file(), None); + assert_eq!(headers["X-Literal"].as_env(), None); + + // A table matching neither shape is an error, not a silent literal + let err = relay_with_headers(r#"{ X-Key = { path = "/x" } }"#).unwrap_err(); + assert!(err.to_string().contains("X-Key"), "{err}"); + + // Both keys at once reads the file; the startup log names the source + let config = relay_with_headers(r#"{ X-Key = { file = "/x", env = "Y" } }"#).unwrap(); + assert_eq!(config.headers.as_ref().unwrap()["X-Key"].as_file(), Some(Path::new("/x"))); + } + + #[test] + fn test_header_source_file_resolution() { + let file = |contents: &[u8]| { + let mut f = tempfile::NamedTempFile::new().unwrap(); + f.write_all(contents).unwrap(); + f + }; + let resolve = |path: &Path| HeaderSource::File { file: path.to_path_buf() }.resolve(); + + // secret stores end the file with a newline; leading whitespace is kept + assert_eq!(resolve(file(b"s3cret \n").path()).unwrap(), "s3cret"); + assert_eq!(resolve(file(b" pad \n").path()).unwrap(), " pad"); + // a literal is sent exactly as written, empty included + assert_eq!(HeaderSource::Literal(String::new()).resolve().unwrap(), ""); + assert_eq!(HeaderSource::Literal(" x ".into()).resolve().unwrap(), " x "); + + // the cap is inclusive + let max = RELAY_HEADER_FILE_MAX_BYTES as usize; + assert_eq!(resolve(file(&vec![b'a'; max]).path()).unwrap().len(), max); + + let dir = tempfile::tempdir().unwrap(); + let big = file(&vec![b'a'; max + 1]); + for (path, expected) in [ + (file(b"\n").path().to_path_buf(), "empty"), + ("/nonexistent/relay-key".into(), "unable to read header file"), + (dir.path().to_path_buf(), "unable to read header file"), + (big.path().to_path_buf(), "larger than"), + ] { + let err = resolve(&path).unwrap_err(); + assert!(err.to_string().contains(expected), "{path:?}: {err}"); + } + } + + #[test] + fn test_header_source_env_var() { + with_env(&[("CB_TEST_HEADER_SOURCE_KEY", Some("from-env\n"))], || { + assert_eq!( + HeaderSource::Env { env: "CB_TEST_HEADER_SOURCE_KEY".into() }.resolve().unwrap(), + "from-env" + ); + }); + with_env(&[("CB_TEST_HEADER_SOURCE_ABSENT", None)], || { + let err = HeaderSource::Env { env: "CB_TEST_HEADER_SOURCE_ABSENT".into() } + .resolve() + .unwrap_err(); + assert!(err.to_string().contains("CB_TEST_HEADER_SOURCE_ABSENT"), "{err}"); + }); + with_env(&[("CB_TEST_HEADER_SOURCE_EMPTY", Some(""))], || { + let err = HeaderSource::Env { env: "CB_TEST_HEADER_SOURCE_EMPTY".into() } + .resolve() + .unwrap_err(); + assert!(err.to_string().contains("empty"), "{err}"); + }); + } + + #[test] + fn test_header_source_debug_redacts_literal() { + let debug = format!("{:?}", HeaderSource::Literal("s3cret".into())); + assert!(!debug.contains("s3cret"), "{debug}"); + // and the whole relay config inherits that + let debug = format!("{:?}", relay_with_headers(r#"{ X-Api-Key = "s3cret" }"#).unwrap()); + assert!(!debug.contains("s3cret"), "{debug}"); + } +} diff --git a/crates/common/src/config/utils.rs b/crates/common/src/config/utils.rs index 9bcf1595d..2e47eb941 100644 --- a/crates/common/src/config/utils.rs +++ b/crates/common/src/config/utils.rs @@ -70,11 +70,10 @@ pub fn decode_string_to_map(raw: &str) -> Result> { } #[cfg(test)] -mod tests { +pub(crate) mod test_env { use std::sync::Mutex; - use super::*; - use crate::utils::TestRandomSeed; + pub(crate) const RELAY_URL: &str = "http://0xa1cec75a3f0661e99299274182938151e8433c61a19222347ea1313d839229cb4ce4e3e5aa2bdeb71c8fcf1b084963c2@abc.xyz"; // Serializes all tests that read/write environment variables. // std::env::set_var is unsafe (Rust 1.81+) because mutating `environ` @@ -84,7 +83,7 @@ mod tests { /// Sets or removes env vars for the duration of `f`, then restores the /// original values. Pass `Some("val")` to set, `None` to ensure absent. - fn with_env(vars: &[(&str, Option<&str>)], f: impl FnOnce() -> R) -> R { + pub(crate) fn with_env(vars: &[(&str, Option<&str>)], f: impl FnOnce() -> R) -> R { let _guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner()); let saved: Vec<(&str, Option)> = vars.iter().map(|(k, _)| (*k, std::env::var(k).ok())).collect(); @@ -103,6 +102,12 @@ mod tests { } result } +} + +#[cfg(test)] +mod tests { + use super::{test_env::with_env, *}; + use crate::utils::TestRandomSeed; // Minimal TOML-deserializable type used by load_from_file / load_file_from_env // tests. diff --git a/crates/common/src/pbs/relay.rs b/crates/common/src/pbs/relay.rs index 91327a952..bb043df08 100644 --- a/crates/common/src/pbs/relay.rs +++ b/crates/common/src/pbs/relay.rs @@ -1,9 +1,11 @@ use std::{str::FromStr, sync::Arc}; -use alloy::primitives::B256; +use alloy::{hex, primitives::B256}; use eyre::WrapErr; use reqwest::header::{HeaderMap, HeaderName, HeaderValue}; use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use tracing::info; use url::Url; use super::{ @@ -15,7 +17,7 @@ use super::{ }; use crate::{ DEFAULT_REQUEST_TIMEOUT, - config::{GetHeaderTransport, RelayConfig}, + config::{GetHeaderTransport, HeaderSource, RelayConfig}, pbs::BuilderApiVersion, types::BlsPublicKey, }; @@ -103,11 +105,28 @@ impl RelayClient { headers.insert(HEADER_VERSION_KEY, HeaderValue::from_static(HEADER_VERSION_VALUE)); if let Some(custom_headers) = &config.headers { - for (key, value) in custom_headers { + for (key, source) in custom_headers { + let resolved = source + .resolve() + .wrap_err_with(|| format!("header {key} of relay {}", config.id()))?; + let mut value = HeaderValue::from_str(&resolved) + .wrap_err_with(|| format!("{key} has an invalid header value"))?; + // Custom headers carry API keys: keep them out of Debug output + value.set_sensitive(true); headers.insert( - HeaderName::from_str(key).wrap_err("{key} is an invalid header name")?, - HeaderValue::from_str(value).wrap_err("{key} has an invalid header value")?, + HeaderName::from_str(key) + .wrap_err_with(|| format!("{key} is an invalid header name"))?, + value, ); + if !matches!(source, HeaderSource::Literal(_)) { + info!( + relay_id = config.id(), + key, + ?source, + value_sha256 = value_fingerprint(&resolved), + "relay header loaded from a secret source" + ); + } } } @@ -202,15 +221,22 @@ impl RelayClient { } } +/// First 4 bytes of the value's SHA-256: enough to see a rotation +/// across reloads, too short to identify a value on its own +fn value_fingerprint(value: &str) -> String { + let digest = Sha256::digest(value.as_bytes()); + hex::encode(&digest[..4]) +} + #[cfg(test)] mod tests { use std::collections::HashMap; use alloy::primitives::B256; - use super::{GetHeaderRequest, RelayClient, RelayEntry}; + use super::{GetHeaderRequest, RelayClient, RelayEntry, value_fingerprint}; use crate::{ - config::{GetHeaderTransport, RelayConfig}, + config::{GetHeaderTransport, RelayConfig, test_env::RELAY_URL}, utils::bls_pubkey_from_hex_unchecked, }; @@ -368,6 +394,47 @@ mod tests { assert!(RelayClient::new(config).is_err()); } + #[test] + fn test_value_fingerprint_tracks_the_value() { + assert_eq!(value_fingerprint("s3cret").len(), 8); + assert_eq!(value_fingerprint("s3cret"), value_fingerprint("s3cret")); + assert_ne!(value_fingerprint("s3cret"), value_fingerprint("s3cret-rotated")); + } + + /// A header sourced from a secret file reaches the client's headers with + /// the file's value, marked sensitive so it never shows in a Debug dump. + #[test] + fn test_relay_headers_from_a_secret_file() { + use std::io::Write; + + let mut file = tempfile::NamedTempFile::new().unwrap(); + file.write_all(b"file-api-key\n").unwrap(); + let relay_config = format!( + r#" + url = "{RELAY_URL}" + headers = {{ X-Api-Key = {{ file = "{}" }}, X-Plain = "plain" }} + "#, + file.path().display() + ); + let relay = RelayClient::new(toml::from_str(&relay_config).unwrap()).unwrap(); + + let api_key = relay.stream_headers().get("x-api-key").unwrap(); + assert_eq!(api_key, "file-api-key"); + assert!(api_key.is_sensitive()); + assert_eq!(relay.stream_headers().get("x-plain").unwrap(), "plain"); + assert!(!format!("{:?}", relay.stream_headers()).contains("file-api-key")); + + // A missing secret file fails the relay, naming the header and relay + let relay_config = format!( + r#" + url = "{RELAY_URL}" + headers = {{ X-Api-Key = {{ file = "/nonexistent/relay-key" }} }} + "# + ); + let err = RelayClient::new(toml::from_str(&relay_config).unwrap()).unwrap_err(); + assert!(format!("{err:#}").contains("header X-Api-Key of relay"), "{err:#}"); + } + #[test] fn test_get_header_transport_config() { let with_transport = |value: &str| { diff --git a/docs/docs/get_started/configuration.md b/docs/docs/get_started/configuration.md index 7eefb2774..7e4a0477f 100644 --- a/docs/docs/get_started/configuration.md +++ b/docs/docs/get_started/configuration.md @@ -31,6 +31,20 @@ After the sidecar is started, it will expose a port (`18550` in this example), t In this setup, the signer module will not be started. ::: +## Relay API keys + +`headers` on a `[[relays]]` (or `[[mux.relays]]`) entry is a map of custom headers sent with every request to that relay, which is how a relay API key is supplied. A value is a literal, or a secret that Commit-Boost reads when the relay is loaded (at startup and on every config reload) from a file or an environment variable, so the key never has to be written in the config file: + +```toml +[[relays]] +url = "https://0x...@relay.example.com" +headers = { X-Api-Key = { file = "/run/secrets/relay-key" } } +# or: headers = { X-Api-Key = { env = "RELAY_API_KEY" } } +# or: headers = { X-Api-Key = "literal-key" } +``` + +The value is used as written apart from trailing whitespace (secret stores end the file with a newline); an empty or unreadable secret is a startup error naming the file. `commit-boost init` mounts every `file` path read-only into the PBS container at the same path and passes every `env` variable through from the environment `docker compose` runs in. The file must be readable by the container user. A secret rotated in place is picked up by a config reload; a bind-mounted file that is rotated by replacing it needs a container restart, since the mount keeps the original. + ## Signer module Commit-Boost supports both local and remote signers. The signer module is responsible for signing the transactions that other modules generates. Please note that only one signer at a time is allowed. diff --git a/tests/src/utils.rs b/tests/src/utils.rs index fa71d5fac..9a4b0a2a6 100644 --- a/tests/src/utils.rs +++ b/tests/src/utils.rs @@ -1,15 +1,15 @@ use std::{ collections::HashMap, net::{Ipv4Addr, SocketAddr}, - path::PathBuf, + path::{Path, PathBuf}, sync::{Arc, Once}, }; use alloy::primitives::{B256, U256}; use cb_common::{ config::{ - COMMIT_BOOST_IMAGE_DEFAULT, CommitBoostConfig, GetHeaderTransport, LogsSettings, - ModuleKind, ModuleSigningConfig, PbsConfig, PbsModuleConfig, RelayConfig, + COMMIT_BOOST_IMAGE_DEFAULT, CommitBoostConfig, GetHeaderTransport, HeaderSource, + LogsSettings, ModuleKind, ModuleSigningConfig, PbsConfig, PbsModuleConfig, RelayConfig, ReverseProxyHeaderSetup, SIGNER_JWT_AUTH_FAIL_LIMIT_DEFAULT, SIGNER_JWT_AUTH_FAIL_TIMEOUT_SECONDS_DEFAULT, SIGNER_PORT_DEFAULT, SignerConfig, SignerType, StartSignerConfig, StaticModuleConfig, StaticPbsConfig, TlsMode, @@ -53,7 +53,10 @@ fn mock_relay_config(port: u16, pubkey: BlsPublicKey) -> Result { url: get_local_address(port).parse()?, }, id: None, - headers: Some(HashMap::from([(HEADER_API_KEY.into(), API_KEY.into())])), + headers: Some(HashMap::from([( + HEADER_API_KEY.into(), + HeaderSource::Literal(API_KEY.into()), + )])), get_params: None, get_header: GetHeaderTransport::Http, enable_timing_games: false, @@ -77,13 +80,16 @@ pub fn generate_mock_relay_with_batch_size( RelayClient::new(config) } -pub fn generate_mock_relay_with_api_key( +/// A relay whose api key is read from `path` instead of written in the config +pub fn generate_mock_relay_with_api_key_file( port: u16, pubkey: BlsPublicKey, - api_key: &str, + path: &Path, ) -> Result { let mut config = mock_relay_config(port, pubkey)?; - config.headers = Some(HashMap::from([(HEADER_API_KEY.into(), api_key.into())])); + config.headers = Some(HashMap::from([(HEADER_API_KEY.into(), HeaderSource::File { + file: path.to_path_buf(), + })])); RelayClient::new(config) } diff --git a/tests/tests/config.rs b/tests/tests/config.rs index b84ce54ce..c687417fe 100644 --- a/tests/tests/config.rs +++ b/tests/tests/config.rs @@ -1,7 +1,11 @@ use std::{net::Ipv4Addr, path::PathBuf}; use alloy::primitives::U256; -use cb_common::{config::CommitBoostConfig, types::Chain, utils::WEI_PER_ETH}; +use cb_common::{ + config::{CommitBoostConfig, HeaderSource}, + types::Chain, + utils::WEI_PER_ETH, +}; use eyre::Result; use url::Url; @@ -32,7 +36,7 @@ async fn test_load_pbs_happy() -> Result<()> { assert_eq!(config.chain, Chain::Holesky); assert_eq!( config.relays[0].headers.as_ref().unwrap().get("X-MyCustomHeader").unwrap(), - "MyCustomHeader" + &HeaderSource::Literal("MyCustomHeader".into()) ); // Docker and general settings diff --git a/tests/tests/pbs_get_status.rs b/tests/tests/pbs_get_status.rs index cd2ab51dc..08afb231a 100644 --- a/tests/tests/pbs_get_status.rs +++ b/tests/tests/pbs_get_status.rs @@ -1,11 +1,16 @@ -use std::{path::PathBuf, sync::Arc, time::Duration}; +use std::{io::Write, path::PathBuf, sync::Arc, time::Duration}; use cb_common::{signer::random_secret, types::Chain}; -use cb_pbs::{DefaultBuilderApi, PbsService, PbsState}; +use cb_pbs::{DefaultBuilderApi, PbsService, PbsState, STATUS_ENDPOINT_TAG}; use cb_tests::{ - mock_relay::{MockRelayState, start_mock_relay_service}, + mock_relay::{ + MockRelayState, start_mock_relay_service, start_mock_relay_service_with_listener, + }, mock_validator::MockValidator, - utils::{generate_mock_relay, get_pbs_config, setup_test_env, to_pbs_config}, + utils::{ + generate_mock_relay, generate_mock_relay_with_api_key_file, get_free_listener, + get_pbs_config, setup_test_env, to_pbs_config, + }, }; use eyre::Result; use reqwest::StatusCode; @@ -79,3 +84,40 @@ async fn test_get_status_returns_502_if_relay_down() -> Result<()> { assert_eq!(mock_state.received_get_status(), 0); Ok(()) } + +#[tokio::test] +async fn test_get_status_sends_api_key_from_file() -> Result<()> { + setup_test_env(); + let signer = random_secret(); + let pubkey = signer.public_key(); + let chain = Chain::Holesky; + + let pbs_listener = get_free_listener().await; + let pbs_port = pbs_listener.local_addr()?.port(); + let relay_listener = get_free_listener().await; + let relay_port = relay_listener.local_addr()?.port(); + + let mut key_file = tempfile::NamedTempFile::new()?; + key_file.write_all(b"file-only-api-key\n")?; + let relays = vec![generate_mock_relay_with_api_key_file(relay_port, pubkey, key_file.path())?]; + let mock_state = Arc::new(MockRelayState::new(chain, signer)); + tokio::spawn(start_mock_relay_service_with_listener(mock_state.clone(), relay_listener)); + + let config = to_pbs_config(chain, get_pbs_config(pbs_port), relays); + let state = PbsState::new(config, PathBuf::new()); + drop(pbs_listener); + tokio::spawn(PbsService::run::<(), DefaultBuilderApi>(state)); + + // leave some time to start servers + tokio::time::sleep(Duration::from_millis(100)).await; + + let mock_validator = MockValidator::new(pbs_port)?; + let res = mock_validator.do_get_status().await?; + assert_eq!(res.status(), StatusCode::OK); + assert_eq!( + mock_state.api_key_seen(STATUS_ENDPOINT_TAG).as_deref(), + Some("file-only-api-key"), + "the relay must receive the key from the file, without its trailing newline" + ); + Ok(()) +}