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
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
128 changes: 127 additions & 1 deletion crates/cli/src/docker_init.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
use std::{
net::{Ipv4Addr, SocketAddr},
path::{Path, PathBuf},
path::{Component, Path, PathBuf},
vec,
};

Expand Down Expand Up @@ -269,6 +269,43 @@ 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(),
Comment thread
ManuelBilbao marked this conversation as resolved.
"Relay header file must be an absolute path to be mounted into cb_pbs: {}",
path.display()
);
// Docker resolves a mount source through symlinks but cleans its target
// as text, so `..` would point the two at different files
eyre::ensure!(
!path.components().any(|part| part == Component::ParentDir),
"Relay header file must not contain `..`: {}",
path.display()
);
// Docker's short volume syntax is colon-separated
eyre::ensure!(
!path.to_string_lossy().contains(':'),
"Relay header file must not contain a colon: {}",
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 +1173,95 @@ 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),
// the mount source is resolved through symlinks and the target is
// cleaned as text, so `..` can split the pair
(r#"{ X-Api-Key = { file = "/run/secrets/../relay-key" } }"#, "must not contain `..`"),
(r#"{ X-Api-Key = { file = "/run/secrets/relay:key" } }"#, "must not contain a colon"),
] {
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
Loading
Loading