diff --git a/bobbin/crates/bobbin/src/config.rs b/bobbin/crates/bobbin/src/config.rs index b3f897e1..10a3d895 100644 --- a/bobbin/crates/bobbin/src/config.rs +++ b/bobbin/crates/bobbin/src/config.rs @@ -27,6 +27,7 @@ const KNOWN_KEYS: &[&str] = &[ "search.heap_bytes", "knot.allow_private", "knot.require_https", + "knot.extra_ca_cert", "log.format", "log.filter", ]; @@ -49,6 +50,7 @@ const KNOWN_ENVS: &[&str] = &[ "BOBBIN_SEARCH_HEAP_BYTES", "BOBBIN_KNOT_ALLOW_PRIVATE", "BOBBIN_KNOT_REQUIRE_HTTPS", + "BOBBIN_KNOT_EXTRA_CA_CERT", "BOBBIN_LOG_FORMAT", "BOBBIN_LOG", ]; @@ -223,6 +225,12 @@ pub struct KnotConfig { /// knot for development. #[config(env = "BOBBIN_KNOT_REQUIRE_HTTPS", default = true)] pub require_https: bool, + + /// Path to an extra PEM certificate to trust as a knot CA, in addition to + /// the bundled webpki roots. Set for local dev so a self-signed/dev CA is + /// trusted without disabling certificate verification. Empty disables this. + #[config(env = "BOBBIN_KNOT_EXTRA_CA_CERT", default = "")] + pub extra_ca_cert: String, } #[derive(Debug, Config)] diff --git a/bobbin/crates/bobbin/src/main.rs b/bobbin/crates/bobbin/src/main.rs index bdb082dd..a790dafd 100644 --- a/bobbin/crates/bobbin/src/main.rs +++ b/bobbin/crates/bobbin/src/main.rs @@ -191,10 +191,13 @@ async fn run(cfg: BobbinConfig) -> anyhow::Result<()> { let coverage = Arc::new(CoverageWatch::new()); let warming_buffer = Arc::new(WarmingBuffer::new(hasher.clone())); let knot_registry = Arc::new(KnotRegistry::new()); + let knot_extra_ca_cert: Option = (!cfg.knot.extra_ca_cert.is_empty()) + .then(|| PathBuf::from(&cfg.knot.extra_ca_cert)); let knots = Arc::new(KnotProxy::new( KnotProxyConfig { allow_private_hosts: cfg.knot.allow_private, require_https: cfg.knot.require_https, + extra_ca_cert: knot_extra_ca_cert.clone(), ..KnotProxyConfig::default() }, KnotHttpConfig::default(), @@ -223,7 +226,8 @@ async fn run(cfg: BobbinConfig) -> anyhow::Result<()> { let knot_acl_dev = !cfg.knot.require_https; let knot_allow_private = cfg.knot.allow_private; - let knot_client = KnotClient::with_default_http(knot_allow_private)?; + let knot_client = + KnotClient::with_default_http(knot_allow_private, knot_extra_ca_cert.as_deref())?; let knot_gate = Arc::new(CapabilityGate::new( knot_client.clone(), clock.clone(), diff --git a/bobbin/crates/ingest/src/lib.rs b/bobbin/crates/ingest/src/lib.rs index 86e4f50d..8393ad28 100644 --- a/bobbin/crates/ingest/src/lib.rs +++ b/bobbin/crates/ingest/src/lib.rs @@ -1707,7 +1707,7 @@ mod tests { let native_host = format!("{}:{}", url.host_str().unwrap(), url.port().unwrap()); let gate = CapabilityGate::new( - KnotClient::with_default_http(true).unwrap(), + KnotClient::with_default_http(true, None).unwrap(), Arc::new(SystemClock::new()), true, true, diff --git a/bobbin/crates/knot-ingest/src/client.rs b/bobbin/crates/knot-ingest/src/client.rs index 773366b1..5d9b8cd3 100644 --- a/bobbin/crates/knot-ingest/src/client.rs +++ b/bobbin/crates/knot-ingest/src/client.rs @@ -72,6 +72,8 @@ pub enum KnotClientError { BodyTooLarge { limit: u64 }, #[error("decode response: {0}")] Decode(#[from] serde_json::Error), + #[error("extra ca cert: {0}")] + ExtraCa(#[from] bobbin_runtime::ExtraCaError), } pub fn knot_endpoint( @@ -94,14 +96,22 @@ pub fn knot_endpoint( Ok(knot) } -fn default_http_client(allow_private: bool) -> Result { - reqwest::Client::builder() +fn default_http_client( + allow_private: bool, + extra_ca_cert: Option<&std::path::Path>, +) -> Result { + let mut builder = reqwest::Client::builder() .user_agent(USER_AGENT) .timeout(REQUEST_TIMEOUT) .connect_timeout(CONNECT_TIMEOUT) .redirect(reqwest::redirect::Policy::none()) - .dns_resolver(Arc::new(PrivateAddressFilter::new(allow_private))) + .dns_resolver(Arc::new(PrivateAddressFilter::new(allow_private))); + if let Some(path) = extra_ca_cert { + builder = builder.add_root_certificate(bobbin_runtime::load_extra_ca_cert(path)?); + } + builder .build() + .map_err(|e| KnotClientError::Build(e.to_string())) } fn nsid(s: &'static str) -> Nsid { @@ -122,9 +132,11 @@ impl KnotClient { Self { http } } - pub fn with_default_http(allow_private: bool) -> Result { - let client = default_http_client(allow_private) - .map_err(|e| KnotClientError::Build(e.to_string()))?; + pub fn with_default_http( + allow_private: bool, + extra_ca_cert: Option<&std::path::Path>, + ) -> Result { + let client = default_http_client(allow_private, extra_ca_cert)?; Ok(Self::new(ReqwestHttp::shared(client))) } @@ -282,7 +294,7 @@ mod tests { } fn client() -> KnotClient { - KnotClient::new(ReqwestHttp::shared(default_http_client(true).unwrap())) + KnotClient::new(ReqwestHttp::shared(default_http_client(true, None).unwrap())) } fn endpoint(server: &MockServer) -> KnotHost { diff --git a/bobbin/crates/knot-proxy/src/lib.rs b/bobbin/crates/knot-proxy/src/lib.rs index 0f1e5c10..6f85dae2 100644 --- a/bobbin/crates/knot-proxy/src/lib.rs +++ b/bobbin/crates/knot-proxy/src/lib.rs @@ -1,11 +1,12 @@ +use std::path::PathBuf; use std::pin::Pin; use std::sync::Arc; use std::task::{Context, Poll}; use std::time::Duration; use bobbin_runtime::{ - BodyStream as InnerBodyStream, Clock, HttpRequest, HttpResponseHead, HttpTransport, - NetworkError, ReqwestHttp, RuntimeHasher, + BodyStream as InnerBodyStream, Clock, ExtraCaError, HttpRequest, HttpResponseHead, + HttpTransport, NetworkError, ReqwestHttp, RuntimeHasher, load_extra_ca_cert, }; use bytes::Bytes; use futures::Stream; @@ -34,6 +35,9 @@ pub struct KnotProxyConfig { pub cooldown: Duration, pub allow_private_hosts: bool, pub require_https: bool, + /// Extra PEM CA certificate to trust for knot TLS connections, in + /// addition to the bundled webpki roots. For local dev CAs only. + pub extra_ca_cert: Option, } impl Default for KnotProxyConfig { @@ -43,6 +47,7 @@ impl Default for KnotProxyConfig { cooldown: Duration::from_secs(30), allow_private_hosts: false, require_https: true, + extra_ca_cert: None, } } } @@ -95,15 +100,23 @@ pub struct KnotProxy { clock: Arc, } +#[derive(Debug, Error)] +pub enum KnotProxyBuildError { + #[error("extra ca cert: {0}")] + ExtraCa(#[from] ExtraCaError), + #[error("build http client: {0}")] + Client(#[from] reqwest::Error), +} + impl KnotProxy { pub fn new( config: KnotProxyConfig, http: KnotHttpConfig, clock: Arc, hasher: RuntimeHasher, - ) -> Result { + ) -> Result { let resolver = Arc::new(dns::PrivateAddressFilter::new(config.allow_private_hosts)); - let client = Client::builder() + let mut builder = Client::builder() .user_agent(USER_AGENT) .connect_timeout(http.connect_timeout) .read_timeout(http.read_timeout) @@ -111,8 +124,11 @@ impl KnotProxy { .no_gzip() .no_brotli() .no_deflate() - .dns_resolver(resolver) - .build()?; + .dns_resolver(resolver); + if let Some(path) = &config.extra_ca_cert { + builder = builder.add_root_certificate(load_extra_ca_cert(path)?); + } + let client = builder.build()?; Ok(Self::with_transport( ReqwestHttp::shared(client), config, @@ -347,6 +363,7 @@ mod tests { cooldown: Duration::from_millis(80), allow_private_hosts: true, require_https: false, + extra_ca_cert: None, } } diff --git a/bobbin/crates/runtime/src/lib.rs b/bobbin/crates/runtime/src/lib.rs index c8cb3809..f4e8f190 100644 --- a/bobbin/crates/runtime/src/lib.rs +++ b/bobbin/crates/runtime/src/lib.rs @@ -14,7 +14,8 @@ pub use mem_network::{ MemWsResponder, MemWsServerFuture, MemWsTransport, }; pub use network::{ - AddrGuard, BodyStream, GuardedWs, HttpRequest, HttpResponseFuture, HttpResponseHead, - HttpResult, HttpTransport, NetworkError, ReqwestHttp, TungsteniteWs, WsConn, WsConnectFuture, - WsMessage, WsMessageFuture, WsSendFuture, WsSink, WsStream, WsTransport, + AddrGuard, BodyStream, ExtraCaError, GuardedWs, HttpRequest, HttpResponseFuture, + HttpResponseHead, HttpResult, HttpTransport, NetworkError, ReqwestHttp, TungsteniteWs, WsConn, + WsConnectFuture, WsMessage, WsMessageFuture, WsSendFuture, WsSink, WsStream, WsTransport, + load_extra_ca_cert, }; diff --git a/bobbin/crates/runtime/src/network.rs b/bobbin/crates/runtime/src/network.rs index 80afcee7..b17ebe8f 100644 --- a/bobbin/crates/runtime/src/network.rs +++ b/bobbin/crates/runtime/src/network.rs @@ -65,6 +65,33 @@ impl ReqwestHttp { } } +#[derive(Debug, Error)] +pub enum ExtraCaError { + #[error("read {path}: {source}")] + Read { + path: String, + source: std::io::Error, + }, + #[error("parse PEM certificate at {path}: {source}")] + Parse { path: String, source: reqwest::Error }, +} + +/// Loads a PEM certificate from `path` (if given) so it can be added to a +/// `reqwest::ClientBuilder` via `add_root_certificate`, extending the bundled +/// webpki roots rather than replacing them. Used to trust a local dev CA - +/// `rustls-tls-webpki-roots` ignores the OS trust store, so mounting a CA +/// into `/etc/ssl/certs` has no effect on reqwest without this. +pub fn load_extra_ca_cert(path: &std::path::Path) -> Result { + let pem = std::fs::read(path).map_err(|source| ExtraCaError::Read { + path: path.display().to_string(), + source, + })?; + reqwest::Certificate::from_pem(&pem).map_err(|source| ExtraCaError::Parse { + path: path.display().to_string(), + source, + }) +} + impl HttpTransport for ReqwestHttp { fn execute(&self, request: HttpRequest) -> HttpResponseFuture { let client = self.client.clone(); diff --git a/bobbin/crates/xrpc/tests/knot_proxy.rs b/bobbin/crates/xrpc/tests/knot_proxy.rs index d4bd032c..a2826489 100644 --- a/bobbin/crates/xrpc/tests/knot_proxy.rs +++ b/bobbin/crates/xrpc/tests/knot_proxy.rs @@ -37,6 +37,7 @@ fn test_config() -> KnotProxyConfig { cooldown: Duration::from_millis(80), allow_private_hosts: true, require_https: false, + extra_ca_cert: None, } } diff --git a/docker-compose.yml b/docker-compose.yml index 3f4820f0..5ea41a73 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -424,6 +424,7 @@ services: BOBBIN_SLINGSHOT_URL: http://hydrant:3000 BOBBIN_KNOT_ALLOW_PRIVATE: "true" BOBBIN_KNOT_REQUIRE_HTTPS: "false" + BOBBIN_KNOT_EXTRA_CA_CERT: /etc/ssl/certs/ca-certificates.crt BOBBIN_LOG: info volumes: - .:/src:cached diff --git a/localinfra/Caddyfile b/localinfra/Caddyfile index 7e14ce2a..5ea82c5c 100644 --- a/localinfra/Caddyfile +++ b/localinfra/Caddyfile @@ -32,6 +32,10 @@ jetstream.tngl.boltless.dev { } # knot +http://knot.tngl.boltless.dev { + reverse_proxy knot:5555 +} + knot.tngl.boltless.dev { tls internal reverse_proxy knot:5555