diff --git a/src/cli.rs b/src/cli.rs index d4cf8df..3c0b465 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -163,6 +163,7 @@ pub struct CliArgs { pub rsync_command: Option, pub http_timeout_secs: u64, + pub http_root_cert_paths: Vec, pub rsync_timeout_secs: u64, pub rsync_mirror_root: Option, pub rsync_scope_policy: RsyncScopePolicy, @@ -254,6 +255,7 @@ Options: --disable-rrdp Disable RRDP and synchronize only via rsync --rsync-command Use this rsync command instead of the default rsync binary --http-timeout-secs HTTP fetch timeout seconds (default: 20) + --http-root-cert Extra PEM root certificate trusted by HTTPS fetches (repeatable) --rsync-timeout-secs rsync I/O timeout seconds (default: 60) --rsync-mirror-root Persist rsync mirrors under this directory (default: disabled) --rsync-scope rsync scope policy: host, publication-point, or module-root (default: module-root) @@ -313,6 +315,7 @@ pub fn parse_args(argv: &[String]) -> Result { let mut disable_rrdp: bool = false; let mut rsync_command: Option = None; let mut http_timeout_secs: u64 = 30; + let mut http_root_cert_paths: Vec = Vec::new(); let mut rsync_timeout_secs: u64 = 30; let mut rsync_mirror_root: Option = None; let mut rsync_scope_policy = RsyncScopePolicy::default(); @@ -636,6 +639,11 @@ pub fn parse_args(argv: &[String]) -> Result { .parse::() .map_err(|_| format!("invalid --http-timeout-secs: {v}"))?; } + "--http-root-cert" => { + i += 1; + let v = argv.get(i).ok_or("--http-root-cert requires a value")?; + http_root_cert_paths.push(PathBuf::from(v)); + } "--rsync-timeout-secs" => { i += 1; let v = argv.get(i).ok_or("--rsync-timeout-secs requires a value")?; @@ -977,6 +985,7 @@ pub fn parse_args(argv: &[String]) -> Result { disable_rrdp, rsync_command, http_timeout_secs, + http_root_cert_paths, rsync_timeout_secs, rsync_mirror_root, rsync_scope_policy, @@ -2001,6 +2010,14 @@ pub fn run(argv: &[String]) -> Result<(), String> { let validation_time = args .validation_time .unwrap_or_else(time::OffsetDateTime::now_utc); + let http_root_certificates_pem = args + .http_root_cert_paths + .iter() + .map(|path| { + std::fs::read(path) + .map_err(|e| format!("read HTTP root certificate failed: {}: {e}", path.display())) + }) + .collect::, _>>()?; let store = if args.raw_store_db.is_some() || args.repo_bytes_db.is_some() { Arc::new( @@ -2221,6 +2238,7 @@ pub fn run(argv: &[String]) -> Result<(), String> { } else if let Some(dir) = args.rsync_local_dir.as_ref() { let http = BlockingHttpFetcher::new(HttpFetcherConfig { timeout: std::time::Duration::from_secs(args.http_timeout_secs.max(1)), + extra_root_certificates_pem: http_root_certificates_pem.clone(), ..HttpFetcherConfig::default() }) .map_err(|e| e.to_string())?; @@ -2239,6 +2257,7 @@ pub fn run(argv: &[String]) -> Result<(), String> { } else { let http = BlockingHttpFetcher::new(HttpFetcherConfig { timeout: std::time::Duration::from_secs(args.http_timeout_secs.max(1)), + extra_root_certificates_pem: http_root_certificates_pem.clone(), ..HttpFetcherConfig::default() }) .map_err(|e| e.to_string())?; diff --git a/src/cli/tests.rs b/src/cli/tests.rs index 86c9005..43e2c01 100644 --- a/src/cli/tests.rs +++ b/src/cli/tests.rs @@ -289,6 +289,29 @@ fn parse_accepts_host_rsync_scope_policy() { assert_eq!(args.rsync_scope_policy, RsyncScopePolicy::Host); } +#[test] +fn parse_accepts_repeatable_http_root_cert_paths() { + let argv = vec![ + "rpki".to_string(), + "--db".to_string(), + "db".to_string(), + "--tal-url".to_string(), + "https://example.test/x.tal".to_string(), + "--http-root-cert".to_string(), + "local-ca-1.pem".to_string(), + "--http-root-cert".to_string(), + "local-ca-2.pem".to_string(), + ]; + let args = parse_args(&argv).expect("parse args"); + assert_eq!( + args.http_root_cert_paths, + vec![ + PathBuf::from("local-ca-1.pem"), + PathBuf::from("local-ca-2.pem") + ] + ); +} + #[test] fn parse_rejects_invalid_rsync_scope_policy() { let argv = vec![ diff --git a/src/fetch/http.rs b/src/fetch/http.rs index 604b521..91859b3 100644 --- a/src/fetch/http.rs +++ b/src/fetch/http.rs @@ -29,6 +29,8 @@ pub struct HttpFetcherConfig { /// Larger timeout used for RRDP snapshot / delta bodies. pub large_body_timeout: Duration, pub user_agent: String, + /// Extra PEM trust anchors for HTTPS transport tests or private RRDP endpoints. + pub extra_root_certificates_pem: Vec>, } impl Default for HttpFetcherConfig { @@ -38,6 +40,7 @@ impl Default for HttpFetcherConfig { timeout: Duration::from_secs(30), large_body_timeout: Duration::from_secs(180), user_agent: "rpki-dev/0.1 (stage2)".to_string(), + extra_root_certificates_pem: Vec::new(), } } } @@ -61,24 +64,30 @@ impl BlockingHttpFetcher { let short_timeout = config.timeout; let large_body_timeout = std::cmp::max(config.large_body_timeout, config.timeout); let connect_timeout = std::cmp::min(config.connect_timeout, config.timeout); - let short_client = Client::builder() - .connect_timeout(connect_timeout) - .timeout(config.timeout) - .user_agent(config.user_agent.clone()) - .build() - .map_err(|e| e.to_string())?; - let large_body_client = Client::builder() - .connect_timeout(connect_timeout) - .timeout(large_body_timeout) - .user_agent(config.user_agent) - .build() - .map_err(|e| e.to_string())?; - let retry_short_client = Client::builder() - .connect_timeout(Duration::from_secs(1)) - .timeout(Duration::from_secs(1)) - .user_agent("rpki-dev/0.1 (stage2)") - .build() - .map_err(|e| e.to_string())?; + let short_client = Self::client_builder( + &config, + connect_timeout, + config.timeout, + config.user_agent.clone(), + )? + .build() + .map_err(|e| e.to_string())?; + let large_body_client = Self::client_builder( + &config, + connect_timeout, + large_body_timeout, + config.user_agent.clone(), + )? + .build() + .map_err(|e| e.to_string())?; + let retry_short_client = Self::client_builder( + &config, + Duration::from_secs(1), + Duration::from_secs(1), + "rpki-dev/0.1 (stage2)".to_string(), + )? + .build() + .map_err(|e| e.to_string())?; Ok(Self { short_client, large_body_client, @@ -88,11 +97,29 @@ impl BlockingHttpFetcher { }) } + fn client_builder( + config: &HttpFetcherConfig, + connect_timeout: Duration, + timeout: Duration, + user_agent: String, + ) -> Result { + let mut builder = Client::builder() + .connect_timeout(connect_timeout) + .timeout(timeout) + .user_agent(user_agent); + for (idx, pem) in config.extra_root_certificates_pem.iter().enumerate() { + let certificate = reqwest::Certificate::from_pem(pem) + .map_err(|e| format!("parse HTTP root certificate #{idx} failed: {e}"))?; + builder = builder.add_root_certificate(certificate); + } + Ok(builder) + } + pub fn fetch_bytes(&self, uri: &str) -> Result, String> { let started = std::time::Instant::now(); let (client, timeout_profile, timeout_value) = self.client_for_uri(uri); let resp = client.get(uri).send().map_err(|e| { - let msg = format!("http request failed: {e}"); + let msg = format!("http request failed: {e:?}"); crate::progress_log::emit( "http_fetch_failed", serde_json::json!({ @@ -222,7 +249,7 @@ impl Fetcher for BlockingHttpFetcher { let started = std::time::Instant::now(); let (client, timeout_profile, timeout_value) = self.client_for_uri(uri); let resp = client.get(uri).send().map_err(|e| { - let msg = format!("http request failed: {e}"); + let msg = format!("http request failed: {e:?}"); crate::progress_log::emit( "http_fetch_failed", serde_json::json!({