1use std::{
5 fmt,
6 future::Future,
7 net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr},
8 sync::{Arc, Mutex, OnceLock},
9 time::{Duration, Instant},
10};
11
12use hickory_resolver::{
13 ConnectionProvider, Resolver,
14 config::{ConnectionConfig, NameServerConfig, ResolverConfig},
15 net::runtime::TokioRuntimeProvider,
16};
17use miette::{IntoDiagnostic, Result, WrapErr};
18use rcgen::{CertificateParams, DistinguishedName, DnType, KeyPair};
19use reqwest::Url;
20use time::{Duration as TimeDuration, OffsetDateTime};
21use tokio::sync::RwLock;
22use tracing::debug;
23
24use crate::{
25 Redacted,
26 transport::{CanopyRequest, CanopyResponse, CanopyTransport},
27};
28
29pub const DEFAULT_CANOPY_URL: &str = "https://meta.tamanu.app";
30
31pub const TAILSCALE_URL: &str = "https://canopy.tail53aef.ts.net";
36
37const TAILSCALE_HOST: &str = "canopy.tail53aef.ts.net";
39
40const CANOPY_HARDCODED_V4: Ipv4Addr = Ipv4Addr::new(100, 99, 98, 97);
43const CANOPY_HARDCODED_V6: Ipv6Addr =
44 Ipv6Addr::new(0xfd7a, 0x115c, 0xa1e0, 0, 0, 0, 0x9337, 0xfb52);
45
46const CERT_VALIDITY_DAYS: i64 = 6;
51
52pub const CERT_RENEW_AFTER: Duration = Duration::from_secs(5 * 24 * 60 * 60);
57
58const TAILSCALE_PROBE_TIMEOUT: Duration = Duration::from_secs(5);
60
61const DNS_LOOKUP_TIMEOUT: Duration = Duration::from_secs(2);
67
68const PROBE_CACHE_TTL: Duration = Duration::from_secs(60);
74
75pub type ClientBuilderFactory = Arc<dyn Fn() -> reqwest::ClientBuilder + Send + Sync>;
83
84fn user_agent() -> &'static str {
92 static UA: OnceLock<String> = OnceLock::new();
93 UA.get_or_init(|| {
94 let os = sysinfo::System::long_os_version()
95 .or_else(sysinfo::System::name)
96 .unwrap_or_else(|| std::env::consts::OS.to_owned());
97 format!(
98 "bestool-canopy/{} ({os}; {})",
99 env!("CARGO_PKG_VERSION"),
100 sysinfo::System::cpu_arch(),
101 )
102 })
103}
104
105pub async fn tailscale_client(make_builder: &ClientBuilderFactory) -> Option<reqwest::Client> {
114 let tailscale_url = TAILSCALE_URL
115 .parse()
116 .expect("default tailscale URL is valid");
117 probe_tailscale(&tailscale_url, make_builder, true).await
118}
119
120pub struct ReqwestTransport {
136 base_url: Url,
139 tailscale_url: Url,
142 device_key: Option<Redacted<String>>,
143 make_builder: ClientBuilderFactory,
145 state: RwLock<State>,
146}
147
148enum State {
149 Tailscale(reqwest::Client),
150 Mtls(reqwest::Client),
151}
152
153impl State {
154 fn is_tailscale(&self) -> bool {
155 matches!(self, State::Tailscale(_))
156 }
157
158 fn http(&self) -> reqwest::Client {
159 match self {
160 State::Tailscale(http) | State::Mtls(http) => http.clone(),
161 }
162 }
163}
164
165impl fmt::Debug for ReqwestTransport {
166 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
167 f.debug_struct("ReqwestTransport").finish_non_exhaustive()
168 }
169}
170
171impl ReqwestTransport {
172 pub async fn new(
185 base_url: Url,
186 tailscale_url: Url,
187 device_key_pem: Option<&str>,
188 make_builder: impl Fn() -> reqwest::ClientBuilder + Send + Sync + 'static,
189 ) -> Result<Option<Self>> {
190 let device_key = device_key_pem.map(|s| Redacted(s.to_owned()));
191 let make_builder: ClientBuilderFactory = Arc::new(make_builder);
192
193 if let Some(http) = probe_tailscale(&tailscale_url, &make_builder, true).await {
194 debug!("canopy: tailscale endpoint reachable, preferring it");
195 return Ok(Some(Self {
196 base_url,
197 tailscale_url,
198 device_key,
199 make_builder,
200 state: RwLock::new(State::Tailscale(http)),
201 }));
202 }
203
204 if let Some(pem) = device_key_pem {
205 debug!("canopy: tailscale unreachable, falling back to mTLS");
206 let http = build_mtls_http(&make_builder, pem)?;
207 return Ok(Some(Self {
208 base_url,
209 tailscale_url,
210 device_key,
211 make_builder,
212 state: RwLock::new(State::Mtls(http)),
213 }));
214 }
215
216 Ok(None)
217 }
218
219 #[cfg(test)]
221 pub(crate) fn mtls_for_tests(base: &str) -> Self {
222 use crate::test_support::{TEST_DEVICE_KEY, test_factory};
223
224 let http = build_mtls_http(&test_factory(), TEST_DEVICE_KEY).unwrap();
225 Self {
226 base_url: base.parse().unwrap(),
227 tailscale_url: TAILSCALE_URL.parse().unwrap(),
228 device_key: Some(Redacted(TEST_DEVICE_KEY.to_owned())),
229 make_builder: test_factory(),
230 state: RwLock::new(State::Mtls(http)),
231 }
232 }
233
234 pub async fn is_tailscale(&self) -> bool {
236 self.state.read().await.is_tailscale()
237 }
238
239 pub async fn refresh(&self) -> Result<()> {
243 if let Some(http) = probe_tailscale(&self.tailscale_url, &self.make_builder, false).await {
244 let mut state = self.state.write().await;
245 if !state.is_tailscale() {
246 debug!("canopy refresh: switching to tailscale path");
247 }
248 *state = State::Tailscale(http);
249 return Ok(());
250 }
251
252 if let Some(pem) = &self.device_key {
253 let http = build_mtls_http(&self.make_builder, &pem.0)?;
254 let mut state = self.state.write().await;
255 if state.is_tailscale() {
256 debug!("canopy refresh: tailscale dropped, falling back to mTLS");
257 }
258 *state = State::Mtls(http);
259 return Ok(());
260 }
261
262 debug!("canopy refresh: no auth path available, keeping current state");
263 Ok(())
264 }
265
266 pub async fn renew(&self) -> Result<()> {
272 let Some(pem) = &self.device_key else {
273 return Ok(());
274 };
275 let mut state = self.state.write().await;
276 if state.is_tailscale() {
277 return Ok(());
278 }
279 *state = State::Mtls(build_mtls_http(&self.make_builder, &pem.0)?);
280 Ok(())
281 }
282
283 async fn endpoint_url(&self, path: &str) -> Result<(reqwest::Client, Url)> {
288 let state = self.state.read().await;
289 let url = match &*state {
290 State::Tailscale(_) => self
291 .tailscale_url
292 .join(&format!("/public{path}"))
293 .into_diagnostic()
294 .wrap_err_with(|| format!("building tailscale /public{path} URL"))?,
295 State::Mtls(_) => self
296 .base_url
297 .join(path)
298 .into_diagnostic()
299 .wrap_err_with(|| format!("building {path} URL"))?,
300 };
301 Ok((state.http(), url))
302 }
303
304 #[cfg(feature = "raw-requests")]
310 pub async fn get(&self, tailscale_path: &str, mtls_path: &str) -> Result<reqwest::Response> {
311 let (http, url) = {
312 let state = self.state.read().await;
313 let url = match &*state {
314 State::Tailscale(_) => self
315 .tailscale_url
316 .join(tailscale_path)
317 .into_diagnostic()
318 .wrap_err("building tailscale GET URL")?,
319 State::Mtls(_) => self
320 .base_url
321 .join(mtls_path)
322 .into_diagnostic()
323 .wrap_err("building mTLS GET URL")?,
324 };
325 (state.http(), url)
326 };
327
328 debug!(%url, "GET via canopy");
329 http.get(url)
330 .send()
331 .await
332 .into_diagnostic()
333 .wrap_err("GET via canopy")
334 }
335
336 #[cfg(feature = "raw-requests")]
342 pub async fn request(
343 &self,
344 method: reqwest::Method,
345 path: &str,
346 ) -> Result<reqwest::RequestBuilder> {
347 let (http, url) = self.endpoint_url(path).await?;
348 debug!(%url, %method, "arbitrary canopy request");
349 Ok(http.request(method, url))
350 }
351}
352
353#[async_trait::async_trait]
354impl CanopyTransport for ReqwestTransport {
355 async fn call(&self, request: CanopyRequest) -> Result<CanopyResponse> {
356 let (parts, body) = request.into_parts();
357 let path = parts.uri.to_string();
358 let (http, url) = self.endpoint_url(&path).await?;
359 debug!(%url, method = %parts.method, "canopy request");
360
361 let mut req = http.request(parts.method, url).headers(parts.headers);
362 if !body.is_empty() {
363 req = req.body(body);
364 }
365
366 let response = req
367 .send()
368 .await
369 .into_diagnostic()
370 .wrap_err("sending canopy request")?;
371
372 let status = response.status();
373 let version = response.version();
374 let headers = response.headers().clone();
375 let body = response
376 .bytes()
377 .await
378 .into_diagnostic()
379 .wrap_err("reading canopy response body")?;
380
381 let mut out = http::Response::new(body);
382 *out.status_mut() = status;
383 *out.version_mut() = version;
384 *out.headers_mut() = headers;
385 Ok(out)
386 }
387}
388
389async fn probe_tailscale(
408 tailscale_url: &Url,
409 make_builder: &ClientBuilderFactory,
410 use_cache: bool,
411) -> Option<reqwest::Client> {
412 let host = tailscale_url.host_str()?;
413
414 if host != TAILSCALE_HOST {
417 return probe_once(tailscale_url, host, &[], make_builder).await;
418 }
419
420 if use_cache && let Some(outcome) = cached_outcome() {
421 debug!("canopy: reusing cached tailnet reachability");
422 return match outcome {
423 TailnetOutcome::Unreachable => None,
424 TailnetOutcome::Reachable(addrs) => build_probe_client(host, &addrs, make_builder),
425 };
426 }
427
428 let discovered = discover_tailnet(tailscale_url, host, make_builder).await;
429 store_outcome(match &discovered {
430 Some((addrs, _)) => TailnetOutcome::Reachable(addrs.clone()),
431 None => TailnetOutcome::Unreachable,
432 });
433 discovered.map(|(_, client)| client)
434}
435
436async fn discover_tailnet(
441 tailscale_url: &Url,
442 host: &str,
443 make_builder: &ClientBuilderFactory,
444) -> Option<(Vec<SocketAddr>, reqwest::Client)> {
445 if !tailscale_present() {
446 debug!("canopy: no tailscale interface on this host; skipping tailnet probe");
447 return None;
448 }
449
450 let via_dns = async {
451 let addrs = resolve_via_tailscale_dns().await;
452 if addrs.is_empty() {
453 return None;
454 }
455 probe_once(tailscale_url, host, &addrs, make_builder)
456 .await
457 .map(|client| (addrs, client))
458 };
459
460 let via_hardcoded = async {
461 let addrs = vec![
462 SocketAddr::new(IpAddr::V4(CANOPY_HARDCODED_V4), 443),
463 SocketAddr::new(IpAddr::V6(CANOPY_HARDCODED_V6), 443),
464 ];
465 probe_once(tailscale_url, host, &addrs, make_builder)
466 .await
467 .map(|client| (addrs, client))
468 };
469
470 race_first_some(via_dns, via_hardcoded).await
471}
472
473async fn resolve_via_tailscale_dns() -> Vec<SocketAddr> {
476 match tokio::time::timeout(DNS_LOOKUP_TIMEOUT, tailscale_resolver().lookup_ip("canopy")).await {
477 Ok(Ok(addrs)) => addrs.iter().map(|ip| SocketAddr::new(ip, 443)).collect(),
478 Ok(Err(err)) => {
479 debug!("canopy tailscale DNS lookup failed: {err}");
480 Vec::new()
481 }
482 Err(_) => {
483 debug!("canopy tailscale DNS lookup timed out");
484 Vec::new()
485 }
486 }
487}
488
489fn build_probe_client(
492 host: &str,
493 addrs: &[SocketAddr],
494 make_builder: &ClientBuilderFactory,
495) -> Option<reqwest::Client> {
496 let mut builder = make_builder()
497 .user_agent(user_agent())
498 .timeout(TAILSCALE_PROBE_TIMEOUT);
499 if !addrs.is_empty() {
500 builder = builder.resolve_to_addrs(host, addrs);
501 }
502 builder.build().ok()
503}
504
505async fn probe_once(
508 tailscale_url: &Url,
509 host: &str,
510 addrs: &[SocketAddr],
511 make_builder: &ClientBuilderFactory,
512) -> Option<reqwest::Client> {
513 let client = build_probe_client(host, addrs, make_builder)?;
514 let url = tailscale_url.join("/public/servers").ok()?;
515 match client.get(url).send().await {
516 Ok(resp) if resp.status().is_success() => Some(client),
517 Ok(resp) => {
518 debug!(status = %resp.status(), ?addrs, "canopy tailscale probe: unexpected status");
519 None
520 }
521 Err(err) => {
522 debug!(?addrs, "canopy tailscale probe failed: {err}");
523 None
524 }
525 }
526}
527
528async fn race_first_some<T>(
532 a: impl Future<Output = Option<T>>,
533 b: impl Future<Output = Option<T>>,
534) -> Option<T> {
535 use futures::future::{Either, select};
536
537 let a = std::pin::pin!(a);
538 let b = std::pin::pin!(b);
539 match select(a, b).await {
540 Either::Left((Some(v), _)) => Some(v),
541 Either::Right((Some(v), _)) => Some(v),
542 Either::Left((None, rest)) => rest.await,
543 Either::Right((None, rest)) => rest.await,
544 }
545}
546
547fn tailscale_present() -> bool {
556 sysinfo::Networks::new_with_refreshed_list()
557 .values()
558 .flat_map(|net| net.ip_networks())
559 .any(|net| is_tailscale_addr(&net.addr))
560}
561
562fn is_tailscale_addr(addr: &IpAddr) -> bool {
563 match addr {
564 IpAddr::V4(v4) => {
565 let o = v4.octets();
566 o[0] == 100 && (64..=127).contains(&o[1])
567 }
568 IpAddr::V6(v6) => {
569 let s = v6.segments();
570 s[0] == 0xfd7a && s[1] == 0x115c && s[2] == 0xa1e0
571 }
572 }
573}
574
575#[derive(Clone)]
577enum TailnetOutcome {
578 Reachable(Vec<SocketAddr>),
580 Unreachable,
581}
582
583struct CachedProbe {
584 stored_at: Instant,
585 outcome: TailnetOutcome,
586}
587
588fn probe_cache() -> &'static Mutex<Option<CachedProbe>> {
589 static CACHE: OnceLock<Mutex<Option<CachedProbe>>> = OnceLock::new();
590 CACHE.get_or_init(|| Mutex::new(None))
591}
592
593fn cached_outcome() -> Option<TailnetOutcome> {
595 let guard = probe_cache().lock().expect("canopy probe cache poisoned");
596 let entry = guard.as_ref()?;
597 (entry.stored_at.elapsed() < PROBE_CACHE_TTL).then(|| entry.outcome.clone())
598}
599
600fn store_outcome(outcome: TailnetOutcome) {
601 *probe_cache().lock().expect("canopy probe cache poisoned") = Some(CachedProbe {
602 stored_at: Instant::now(),
603 outcome,
604 });
605}
606
607fn tailscale_resolver() -> Resolver<impl ConnectionProvider> {
608 Resolver::builder_with_config(
609 ResolverConfig::from_parts(
610 None,
611 vec!["tail53aef.ts.net.".parse().unwrap()],
612 vec![NameServerConfig::new(
613 "100.100.100.100".parse().unwrap(),
614 true,
615 vec![ConnectionConfig::udp()],
616 )],
617 ),
618 TokioRuntimeProvider::default(),
619 )
620 .build()
621 .expect("tailscale resolver config is hardcoded and cannot fail to build")
622}
623
624pub fn device_identity(device_key_pem: &str) -> Result<reqwest::Identity> {
635 let key_pair = KeyPair::from_pem(device_key_pem)
636 .into_diagnostic()
637 .wrap_err("parsing device key PEM")?;
638
639 let mut params = CertificateParams::new(vec!["device.local".into()])
640 .into_diagnostic()
641 .wrap_err("building certificate params")?;
642 params.distinguished_name = DistinguishedName::new();
643 params
644 .distinguished_name
645 .push(DnType::CommonName, "device.local");
646
647 let now = OffsetDateTime::now_utc();
648 params.not_before = now - TimeDuration::minutes(1);
649 params.not_after = now + TimeDuration::days(CERT_VALIDITY_DAYS);
650
651 let cert = params
652 .self_signed(&key_pair)
653 .into_diagnostic()
654 .wrap_err("self-signing certificate")?;
655
656 let mut combined = cert.pem();
657 combined.push('\n');
658 combined.push_str(&key_pair.serialize_pem());
659
660 reqwest::Identity::from_pem(combined.as_bytes())
661 .into_diagnostic()
662 .wrap_err("building reqwest TLS identity")
663}
664
665fn build_mtls_http(
666 make_builder: &ClientBuilderFactory,
667 device_key_pem: &str,
668) -> Result<reqwest::Client> {
669 let identity = device_identity(device_key_pem)?;
670
671 make_builder()
672 .user_agent(user_agent())
673 .identity(identity)
674 .use_rustls_tls()
675 .timeout(Duration::from_secs(30))
676 .build()
677 .into_diagnostic()
678 .wrap_err("building canopy HTTP client")
679}
680
681#[cfg(test)]
682mod tests {
683 use crate::test_support::{TEST_DEVICE_KEY, closed_url, serve_once, test_factory};
684
685 use super::*;
686
687 #[test]
688 fn build_mtls_http_from_p256_key() {
689 let result = build_mtls_http(&test_factory(), TEST_DEVICE_KEY);
691 assert!(result.is_ok(), "{:?}", result.err());
692 }
693
694 #[test]
695 fn build_mtls_http_fails_on_garbage_key() {
696 assert!(build_mtls_http(&test_factory(), "not a real PEM").is_err());
697 }
698
699 #[tokio::test]
700 async fn no_device_key_still_builds_over_tailscale() {
701 let (tailnet, _server) = serve_once("HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\n[]");
702 let transport = ReqwestTransport::new(
703 DEFAULT_CANOPY_URL.parse().unwrap(),
704 tailnet.parse().unwrap(),
705 None,
706 reqwest::Client::builder,
707 )
708 .await
709 .expect("keyless build should not error")
710 .expect("a reachable tailnet is an auth path in its own right");
711 assert!(transport.is_tailscale().await);
712 }
713
714 #[tokio::test]
715 async fn no_device_key_and_no_tailnet_leaves_no_auth_path() {
716 let transport = ReqwestTransport::new(
717 DEFAULT_CANOPY_URL.parse().unwrap(),
718 closed_url().parse().unwrap(),
719 None,
720 reqwest::Client::builder,
721 )
722 .await
723 .expect("keyless build should not error");
724 assert!(transport.is_none());
725 }
726
727 #[tokio::test]
728 async fn device_key_carries_the_call_when_the_tailnet_is_unreachable() {
729 let transport = ReqwestTransport::new(
730 DEFAULT_CANOPY_URL.parse().unwrap(),
731 closed_url().parse().unwrap(),
732 Some(TEST_DEVICE_KEY),
733 reqwest::Client::builder,
734 )
735 .await
736 .expect("mTLS build should not error")
737 .expect("a device key is an auth path when the tailnet is out of reach");
738 assert!(!transport.is_tailscale().await);
739 }
740
741 #[tokio::test]
742 async fn renew_with_mtls_state_swaps_in_fresh_client() {
743 let transport = ReqwestTransport::mtls_for_tests(DEFAULT_CANOPY_URL);
744 transport.renew().await.expect("renew should succeed");
745 assert!(!transport.is_tailscale().await);
746 }
747
748 #[tokio::test]
749 async fn renew_is_noop_in_tailscale_mode() {
750 let transport = ReqwestTransport {
752 base_url: DEFAULT_CANOPY_URL.parse().unwrap(),
753 tailscale_url: TAILSCALE_URL.parse().unwrap(),
754 device_key: None,
755 make_builder: test_factory(),
756 state: RwLock::new(State::Tailscale(reqwest::Client::new())),
757 };
758 transport.renew().await.expect("renew should be a no-op");
759 assert!(transport.is_tailscale().await);
760 }
761
762 #[tokio::test]
763 async fn tailscale_state_routes_under_public() {
764 let transport = ReqwestTransport {
765 base_url: DEFAULT_CANOPY_URL.parse().unwrap(),
766 tailscale_url: "https://tailnet.example".parse().unwrap(),
767 device_key: None,
768 make_builder: test_factory(),
769 state: RwLock::new(State::Tailscale(reqwest::Client::new())),
770 };
771 let (_, url) = transport.endpoint_url("/backup-target").await.unwrap();
772 assert_eq!(url.as_str(), "https://tailnet.example/public/backup-target");
773 }
774
775 #[test]
776 fn user_agent_identifies_the_crate_with_os_comment() {
777 let ua = user_agent();
778 assert!(
779 ua.starts_with(concat!("bestool-canopy/", env!("CARGO_PKG_VERSION"), " ")),
780 "unexpected user-agent: {ua}"
781 );
782 assert!(ua.contains('('), "expected OS comment in: {ua}");
783 assert!(ua.ends_with(')'), "expected OS comment in: {ua}");
784 assert!(
785 ua.contains(sysinfo::System::cpu_arch().as_str()),
786 "expected arch in: {ua}"
787 );
788 }
789
790 #[test]
791 fn tailscale_addr_classifies_cgnat_v4() {
792 assert!(is_tailscale_addr(&IpAddr::V4(Ipv4Addr::new(100, 64, 0, 1))));
793 assert!(is_tailscale_addr(&IpAddr::V4(Ipv4Addr::new(
794 100, 127, 255, 255
795 ))));
796 assert!(is_tailscale_addr(&IpAddr::V4(CANOPY_HARDCODED_V4)));
797 assert!(!is_tailscale_addr(&IpAddr::V4(Ipv4Addr::new(
799 100, 63, 255, 255
800 ))));
801 assert!(!is_tailscale_addr(&IpAddr::V4(Ipv4Addr::new(
802 100, 128, 0, 0
803 ))));
804 assert!(!is_tailscale_addr(&IpAddr::V4(Ipv4Addr::new(10, 0, 0, 1))));
806 assert!(!is_tailscale_addr(&IpAddr::V4(Ipv4Addr::new(100, 0, 0, 1))));
807 }
808
809 #[test]
810 fn tailscale_addr_classifies_ula_v6() {
811 assert!(is_tailscale_addr(&IpAddr::V6(CANOPY_HARDCODED_V6)));
812 assert!(is_tailscale_addr(&IpAddr::V6(Ipv6Addr::new(
813 0xfd7a, 0x115c, 0xa1e0, 0, 0, 0, 0, 1
814 ))));
815 assert!(!is_tailscale_addr(&IpAddr::V6(Ipv6Addr::new(
817 0xfd00, 0x115c, 0xa1e0, 0, 0, 0, 0, 1
818 ))));
819 assert!(!is_tailscale_addr(&IpAddr::V6(Ipv6Addr::LOCALHOST)));
820 }
821
822 #[test]
823 fn probe_cache_roundtrips_and_expires() {
824 store_outcome(TailnetOutcome::Reachable(vec![SocketAddr::new(
825 IpAddr::V4(CANOPY_HARDCODED_V4),
826 443,
827 )]));
828 match cached_outcome() {
829 Some(TailnetOutcome::Reachable(addrs)) => {
830 assert_eq!(
831 addrs,
832 vec![SocketAddr::new(IpAddr::V4(CANOPY_HARDCODED_V4), 443)]
833 );
834 }
835 other => panic!(
836 "expected freshly stored Reachable, got {:?}",
837 other.is_some()
838 ),
839 }
840
841 if let Some(stale) = Instant::now().checked_sub(PROBE_CACHE_TTL + Duration::from_secs(1)) {
845 *probe_cache().lock().unwrap() = Some(CachedProbe {
846 stored_at: stale,
847 outcome: TailnetOutcome::Unreachable,
848 });
849 assert!(cached_outcome().is_none());
850 }
851 }
852}