This repository has no description
0

Configure Feed

Select the types of activity you want to include in your feed.

core / bobbin / crates / knot-proxy / src / mirror.rs
7.1 kB 237 lines
1use std::sync::Arc; 2use std::time::Duration; 3 4use bobbin_runtime::{Clock, RuntimeHasher}; 5use http::HeaderMap; 6use jacquard_common::types::did::Did; 7use jacquard_common::types::nsid::Nsid; 8use jacquard_common::{BosStr, DefaultStr}; 9use thiserror::Error; 10use url::Url; 11 12use crate::breaker::FailureThreshold; 13use crate::host::{KnotHost, KnotHostError}; 14use crate::{KnotHttpConfig, KnotProxy, KnotProxyConfig, KnotProxyError, ProxyResponse}; 15 16const REPO_PARAM: &str = "repo"; 17const PATH_PARAM: &str = "path"; 18 19const MIRROR_HTTP: KnotHttpConfig = KnotHttpConfig { 20 connect_timeout: Duration::from_secs(3), 21 read_timeout: Duration::from_secs(5), 22}; 23 24#[derive(Debug, Error)] 25pub enum MirrorProxyError { 26 #[error("mirror url: {0}")] 27 Host(#[from] KnotHostError), 28 #[error("mirror http client: {0}")] 29 Http(#[from] reqwest::Error), 30 #[error("mirror url must be a bare origin, got {0}")] 31 NotAnOrigin(String), 32} 33 34struct MirrorRoute { 35 knot: &'static str, 36 mirror: &'static str, 37 eligible: fn(&[(&str, &str)]) -> bool, 38} 39 40const ROUTES: &[MirrorRoute] = &[ 41 MirrorRoute { 42 knot: "sh.tangled.repo.branches", 43 mirror: "sh.tangled.git.temp.listBranches", 44 eligible: any_query, 45 }, 46 MirrorRoute { 47 knot: "sh.tangled.repo.log", 48 mirror: "sh.tangled.git.temp.listCommits", 49 eligible: no_path_filter, 50 }, 51 MirrorRoute { 52 knot: "sh.tangled.repo.tag", 53 mirror: "sh.tangled.git.temp.getTag", 54 eligible: any_query, 55 }, 56 MirrorRoute { 57 knot: "sh.tangled.repo.tags", 58 mirror: "sh.tangled.git.temp.listTags", 59 eligible: any_query, 60 }, 61 MirrorRoute { 62 knot: "sh.tangled.repo.tree", 63 mirror: "sh.tangled.git.temp.getTree", 64 eligible: any_query, 65 }, 66]; 67 68fn any_query(_: &[(&str, &str)]) -> bool { 69 true 70} 71 72fn no_path_filter(query: &[(&str, &str)]) -> bool { 73 !query.iter().any(|(k, v)| *k == PATH_PARAM && !v.is_empty()) 74} 75 76#[derive(Debug)] 77pub struct MirrorNsid(Nsid<DefaultStr>); 78 79impl MirrorNsid { 80 pub fn route(knot_nsid: &str, query: &[(&str, &str)]) -> Option<Self> { 81 let route = ROUTES.iter().find(|r| r.knot == knot_nsid)?; 82 (route.eligible)(query).then(|| { 83 Self(Nsid::new_static(route.mirror).expect("every nsid in ROUTES is a valid literal")) 84 }) 85 } 86 87 pub fn as_str(&self) -> &str { 88 self.0.as_ref() 89 } 90} 91 92pub struct MirrorProxy { 93 proxy: KnotProxy, 94 host: KnotHost, 95} 96 97impl MirrorProxy { 98 pub fn new( 99 url: &Url, 100 clock: Arc<dyn Clock>, 101 hasher: RuntimeHasher, 102 ) -> Result<Self, MirrorProxyError> { 103 let host = KnotHost::parse(url.as_str())?; 104 match beyond_origin(url) { 105 Some(extra) => Err(MirrorProxyError::NotAnOrigin(extra)), 106 None => Ok(Self { 107 proxy: KnotProxy::new( 108 KnotProxyConfig { 109 allow_private_hosts: true, 110 require_https: false, 111 failure_threshold: FailureThreshold::new(3).expect("nonzero literal"), 112 ..KnotProxyConfig::default() 113 }, 114 MIRROR_HTTP, 115 clock, 116 hasher, 117 )?, 118 host, 119 }), 120 } 121 } 122 123 pub fn host(&self) -> &KnotHost { 124 &self.host 125 } 126 127 pub async fn forward<S: BosStr + AsRef<str>>( 128 &self, 129 nsid: &MirrorNsid, 130 repo: &Did<S>, 131 query: &[(&str, &str)], 132 headers: HeaderMap, 133 ) -> Result<ProxyResponse, KnotProxyError> { 134 let keyed: Vec<(&str, &str)> = query 135 .iter() 136 .copied() 137 .filter(|(k, _)| *k != REPO_PARAM) 138 .chain(std::iter::once((REPO_PARAM, repo.as_ref()))) 139 .collect(); 140 self.proxy 141 .forward(&self.host, &nsid.0, &keyed, headers) 142 .await 143 } 144} 145 146fn beyond_origin(url: &Url) -> Option<String> { 147 [ 148 (!matches!(url.path(), "" | "/")).then(|| format!("the path {}", url.path())), 149 url.query().map(|query| format!("the query {query}")), 150 url.fragment() 151 .map(|fragment| format!("the fragment {fragment}")), 152 (!url.username().is_empty()).then(|| format!("the username {}", url.username())), 153 url.password().map(|_| "a password".to_owned()), 154 ] 155 .into_iter() 156 .flatten() 157 .next() 158} 159 160#[cfg(test)] 161mod tests { 162 use super::*; 163 use bobbin_runtime::SystemClock; 164 use wiremock::matchers::{method, path, query_param}; 165 use wiremock::{Mock, MockServer, ResponseTemplate}; 166 167 fn mirror_at(raw: &str) -> Result<MirrorProxy, MirrorProxyError> { 168 MirrorProxy::new( 169 &Url::parse(raw).unwrap(), 170 Arc::new(SystemClock::new()), 171 RuntimeHasher::default(), 172 ) 173 } 174 175 #[test] 176 fn a_mirror_url_with_more_than_an_origin_is_refused_without_echoing_a_password() { 177 [ 178 "https://nel.pet/api", 179 "https://nel.pet/?a=1", 180 "https://nel.pet/#x", 181 "https://nel@nel.pet/", 182 "https://nel:hunter2@nel.pet/", 183 "https://:hunter2@nel.pet/", 184 ] 185 .iter() 186 .for_each(|raw| { 187 let Err(err) = mirror_at(raw) else { 188 panic!("{raw} must error out, since KnotHost::parse would truncate it"); 189 }; 190 assert!( 191 matches!(err, MirrorProxyError::NotAnOrigin(_)), 192 "{raw} gave {err}", 193 ); 194 assert!( 195 !err.to_string().contains("hunter2"), 196 "an operator's password must stay out of the log, got {err}", 197 ); 198 }); 199 assert!( 200 mirror_at("https://nel.pet/").is_ok(), 201 "a bare origin is what an operator will configure", 202 ); 203 } 204 205 #[tokio::test] 206 async fn forward_replaces_a_slug_the_caller_left_in_the_query() { 207 let server = MockServer::start().await; 208 Mock::given(method("GET")) 209 .and(path("/xrpc/sh.tangled.git.temp.listTags")) 210 .and(query_param("repo", "did:plc:conch")) 211 .respond_with(ResponseTemplate::new(200).set_body_string("[]")) 212 .mount(&server) 213 .await; 214 215 let mirror = mirror_at(&server.uri()).unwrap(); 216 let nsid = MirrorNsid::route("sh.tangled.repo.tags", &[]).unwrap(); 217 let resp = mirror 218 .forward( 219 &nsid, 220 &Did::<DefaultStr>::new_static("did:plc:conch").unwrap(), 221 &[("repo", "did:plc:nel/scallop")], 222 HeaderMap::new(), 223 ) 224 .await 225 .expect("the mounted mirror answers"); 226 assert_eq!(resp.status(), 200); 227 } 228 229 #[test] 230 fn a_mirror_dials_where_the_knot_policy_refuses() { 231 let mirror = mirror_at("http://127.0.0.1:9/").expect("an operator picks the mirror"); 232 assert!( 233 mirror.proxy.allows_private_hosts() && !mirror.proxy.requires_https(), 234 "the knot policy doesn't bind an operator-configured mirror", 235 ); 236 } 237}