Skip to content
Open
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
7 changes: 6 additions & 1 deletion config.example.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
109 changes: 109 additions & 0 deletions crates/cli/src/docker_init.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(),

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why do we need this to be 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());
Expand Down Expand Up @@ -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();
Expand Down
3 changes: 3 additions & 0 deletions crates/common/src/config/constants.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
22 changes: 21 additions & 1 deletion crates/common/src/config/mod.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
use std::path::PathBuf;
use std::{
collections::BTreeSet,
path::{Path, PathBuf},
};

use eyre::{Result, bail};
use serde::{Deserialize, Serialize};
Expand Down Expand Up @@ -129,6 +132,23 @@ impl CommitBoostConfig {
}
}

/// Every custom header value configured on a relay, default or mux
fn relay_header_sources(&self) -> impl Iterator<Item = &HeaderSource> {
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 ||
Expand Down
183 changes: 178 additions & 5 deletions crates/common/src/config/pbs.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,11 @@

use std::{
collections::HashMap,
fmt,
fs::File,
io::Read,
net::{Ipv4Addr, SocketAddr},
path::PathBuf,
path::{Path, PathBuf},
sync::Arc,
};

Expand All @@ -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,
Expand Down Expand Up @@ -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<String> {
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(<redacted>)"),
Self::File { file } => write!(f, "File({file:?})"),
Self::Env { env } => write!(f, "Env({env})"),
}
}
}

fn read_secret_file(file: &Path) -> Result<String> {
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 {
Expand All @@ -55,7 +128,7 @@ pub struct RelayConfig {
#[serde(rename = "url")]
pub entry: RelayEntry,
/// Optional headers to send with each request
pub headers: Option<HashMap<String, String>>,
pub headers: Option<HashMap<String, HeaderSource>>,
/// Optional GET parameters to add to each request
pub get_params: Option<HashMap<String, String>>,
/// How to fetch headers from this relay
Expand Down Expand Up @@ -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<RelayConfig, toml::de::Error> {
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}");
}
}
Loading
Loading