use std::sync::Arc; use std::time::Duration; use bobbin_runtime::{Clock, RuntimeHasher}; use http::HeaderMap; use jacquard_common::DefaultStr; use jacquard_common::types::nsid::Nsid; use thiserror::Error; use url::Url; use crate::breaker::FailureThreshold; use crate::host::{KnotHost, KnotHostError}; use crate::{KnotHttpConfig, KnotProxy, KnotProxyConfig, KnotProxyError, ProxyResponse}; const MIRROR_HTTP: KnotHttpConfig = KnotHttpConfig { connect_timeout: Duration::from_secs(3), read_timeout: Duration::from_secs(5), }; #[derive(Debug, Error)] pub enum MirrorProxyError { #[error("mirror url: {0}")] Host(#[from] KnotHostError), #[error("mirror http client: {0}")] Http(#[from] reqwest::Error), #[error("mirror url must be a bare origin, got {0}")] NotAnOrigin(String), } pub struct MirrorProxy { proxy: KnotProxy, host: KnotHost, } impl MirrorProxy { pub fn new( url: &Url, clock: Arc, hasher: RuntimeHasher, ) -> Result { Self::with_breaker( url, clock, hasher, FailureThreshold::new(3).expect("nonzero literal"), KnotProxyConfig::default().cooldown, ) } /// `failure_threshold` failures in a row stop requests to the mirror for `cooldown` pub fn with_breaker( url: &Url, clock: Arc, hasher: RuntimeHasher, failure_threshold: FailureThreshold, cooldown: Duration, ) -> Result { let host = KnotHost::parse(url.as_str())?; match beyond_origin(url) { Some(extra) => Err(MirrorProxyError::NotAnOrigin(extra)), None => Ok(Self { proxy: KnotProxy::new( KnotProxyConfig { allow_private_hosts: true, require_https: false, failure_threshold, cooldown, }, MIRROR_HTTP, clock, hasher, )?, host, }), } } pub fn host(&self) -> &KnotHost { &self.host } pub async fn forward_raw( &self, nsid: &Nsid, query: &[(&str, &str)], headers: HeaderMap, ) -> Result { self.proxy.forward(&self.host, nsid, query, headers).await } } fn beyond_origin(url: &Url) -> Option { [ (!matches!(url.path(), "" | "/")).then(|| format!("the path {}", url.path())), url.query().map(|query| format!("the query {query}")), url.fragment() .map(|fragment| format!("the fragment {fragment}")), (!url.username().is_empty()).then(|| format!("the username {}", url.username())), url.password().map(|_| "a password".to_owned()), ] .into_iter() .flatten() .next() } #[cfg(test)] mod tests { use super::*; use bobbin_runtime::SystemClock; fn mirror_at(raw: &str) -> Result { MirrorProxy::new( &Url::parse(raw).unwrap(), Arc::new(SystemClock::new()), RuntimeHasher::default(), ) } #[test] fn a_mirror_url_with_more_than_an_origin_is_refused_without_echoing_a_password() { [ "https://nel.pet/api", "https://nel.pet/?a=1", "https://nel.pet/#x", "https://nel@nel.pet/", "https://nel:hunter2@nel.pet/", "https://:hunter2@nel.pet/", ] .iter() .for_each(|raw| { let Err(err) = mirror_at(raw) else { panic!("{raw} must error out, since KnotHost::parse would truncate it"); }; assert!( matches!(err, MirrorProxyError::NotAnOrigin(_)), "{raw} gave {err}", ); assert!( !err.to_string().contains("hunter2"), "an operator's password must stay out of the log, got {err}", ); }); assert!( mirror_at("https://nel.pet/").is_ok(), "a bare origin is what an operator will configure", ); } #[test] fn a_mirror_dials_where_the_knot_policy_refuses() { let mirror = mirror_at("http://127.0.0.1:9/").expect("an operator picks the mirror"); assert!( mirror.proxy.allows_private_hosts() && !mirror.proxy.requires_https(), "the knot policy doesn't bind an operator-configured mirror", ); } }