1use std::{
2 fmt,
3 future::Future,
4 io::Write,
5 net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr},
6 sync::{Arc, Mutex, OnceLock},
7 time::{Duration, Instant},
8};
9
10use flate2::{Compression, write::GzEncoder};
11use hickory_resolver::{
12 ConnectionProvider, Resolver,
13 config::{ConnectionConfig, NameServerConfig, ResolverConfig},
14 net::runtime::TokioRuntimeProvider,
15};
16use miette::{IntoDiagnostic, Result, WrapErr};
17use rcgen::{CertificateParams, DistinguishedName, DnType, KeyPair};
18use reqwest::Url;
19use time::{Duration as TimeDuration, OffsetDateTime};
20use tokio::sync::RwLock;
21use tracing::debug;
22
23use crate::Redacted;
24
25pub const DEFAULT_CANOPY_URL: &str = "https://meta.tamanu.app";
26
27pub const TAILSCALE_URL: &str = "https://canopy.tail53aef.ts.net";
32
33const TAILSCALE_HOST: &str = "canopy.tail53aef.ts.net";
35
36const CANOPY_HARDCODED_V4: Ipv4Addr = Ipv4Addr::new(100, 99, 98, 97);
39const CANOPY_HARDCODED_V6: Ipv6Addr =
40 Ipv6Addr::new(0xfd7a, 0x115c, 0xa1e0, 0, 0, 0, 0x9337, 0xfb52);
41
42const CERT_VALIDITY_DAYS: i64 = 6;
47
48pub const CERT_RENEW_AFTER: Duration = Duration::from_secs(5 * 24 * 60 * 60);
53
54const TAILSCALE_PROBE_TIMEOUT: Duration = Duration::from_secs(5);
56
57const DNS_LOOKUP_TIMEOUT: Duration = Duration::from_secs(2);
63
64const PROBE_CACHE_TTL: Duration = Duration::from_secs(60);
70
71pub type ClientBuilderFactory = Arc<dyn Fn() -> reqwest::ClientBuilder + Send + Sync>;
79
80#[derive(Debug, Clone)]
87pub struct CanopyHttpError {
88 pub status: reqwest::StatusCode,
90 pub path: String,
92 pub body: String,
94}
95
96impl fmt::Display for CanopyHttpError {
97 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
98 write!(
99 f,
100 "canopy {} returned {}: {}",
101 self.path, self.status, self.body
102 )
103 }
104}
105
106impl std::error::Error for CanopyHttpError {}
107impl miette::Diagnostic for CanopyHttpError {}
108
109fn user_agent() -> &'static str {
117 static UA: OnceLock<String> = OnceLock::new();
118 UA.get_or_init(|| {
119 let os = sysinfo::System::long_os_version()
120 .or_else(sysinfo::System::name)
121 .unwrap_or_else(|| std::env::consts::OS.to_owned());
122 format!(
123 "bestool-canopy/{} ({os}; {})",
124 env!("CARGO_PKG_VERSION"),
125 sysinfo::System::cpu_arch(),
126 )
127 })
128}
129
130pub async fn tailscale_client(make_builder: &ClientBuilderFactory) -> Option<reqwest::Client> {
139 let tailscale_url = TAILSCALE_URL
140 .parse()
141 .expect("default tailscale URL is valid");
142 probe_tailscale(&tailscale_url, make_builder, true).await
143}
144
145pub struct CanopyClient {
156 base_url: Url,
159 tailscale_url: Url,
162 device_key: Option<Redacted<String>>,
163 make_builder: ClientBuilderFactory,
165 state: RwLock<State>,
166}
167
168enum State {
169 Tailscale(reqwest::Client),
170 Mtls(reqwest::Client),
171}
172
173impl State {
174 fn is_tailscale(&self) -> bool {
175 matches!(self, State::Tailscale(_))
176 }
177
178 fn http(&self) -> reqwest::Client {
179 match self {
180 State::Tailscale(http) | State::Mtls(http) => http.clone(),
181 }
182 }
183}
184
185impl fmt::Debug for CanopyClient {
186 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
187 f.debug_struct("CanopyClient").finish_non_exhaustive()
188 }
189}
190
191impl CanopyClient {
192 pub async fn new(
203 device_key_pem: Option<&str>,
204 make_builder: impl Fn() -> reqwest::ClientBuilder + Send + Sync + 'static,
205 ) -> Result<Option<Self>> {
206 Self::with_urls(
207 DEFAULT_CANOPY_URL
208 .parse()
209 .expect("default canopy URL is valid"),
210 TAILSCALE_URL
211 .parse()
212 .expect("default tailscale URL is valid"),
213 device_key_pem,
214 make_builder,
215 )
216 .await
217 }
218
219 pub async fn with_urls(
226 base_url: Url,
227 tailscale_url: Url,
228 device_key_pem: Option<&str>,
229 make_builder: impl Fn() -> reqwest::ClientBuilder + Send + Sync + 'static,
230 ) -> Result<Option<Self>> {
231 let device_key = device_key_pem.map(|s| Redacted(s.to_owned()));
232 let make_builder: ClientBuilderFactory = Arc::new(make_builder);
233
234 if let Some(http) = probe_tailscale(&tailscale_url, &make_builder, true).await {
235 debug!("canopy: tailscale endpoint reachable, preferring it");
236 return Ok(Some(Self {
237 base_url,
238 tailscale_url,
239 device_key,
240 make_builder,
241 state: RwLock::new(State::Tailscale(http)),
242 }));
243 }
244
245 if let Some(pem) = device_key_pem {
246 debug!("canopy: tailscale unreachable, falling back to mTLS");
247 let http = build_mtls_http(&make_builder, pem)?;
248 return Ok(Some(Self {
249 base_url,
250 tailscale_url,
251 device_key,
252 make_builder,
253 state: RwLock::new(State::Mtls(http)),
254 }));
255 }
256
257 Ok(None)
258 }
259
260 pub async fn is_tailscale(&self) -> bool {
262 self.state.read().await.is_tailscale()
263 }
264
265 pub async fn refresh(&self) -> Result<()> {
269 if let Some(http) = probe_tailscale(&self.tailscale_url, &self.make_builder, false).await {
270 let mut state = self.state.write().await;
271 if !state.is_tailscale() {
272 debug!("canopy refresh: switching to tailscale path");
273 }
274 *state = State::Tailscale(http);
275 return Ok(());
276 }
277
278 if let Some(pem) = &self.device_key {
279 let http = build_mtls_http(&self.make_builder, &pem.0)?;
280 let mut state = self.state.write().await;
281 if state.is_tailscale() {
282 debug!("canopy refresh: tailscale dropped, falling back to mTLS");
283 }
284 *state = State::Mtls(http);
285 return Ok(());
286 }
287
288 debug!("canopy refresh: no auth path available, keeping current state");
289 Ok(())
290 }
291
292 pub async fn renew(&self) -> Result<()> {
298 let Some(pem) = &self.device_key else {
299 return Ok(());
300 };
301 let mut state = self.state.write().await;
302 if state.is_tailscale() {
303 return Ok(());
304 }
305 *state = State::Mtls(build_mtls_http(&self.make_builder, &pem.0)?);
306 Ok(())
307 }
308
309 async fn endpoint_url(&self, path: &str) -> Result<(reqwest::Client, Url)> {
314 let state = self.state.read().await;
315 let url = match &*state {
316 State::Tailscale(_) => self
317 .tailscale_url
318 .join(&format!("/public{path}"))
319 .into_diagnostic()
320 .wrap_err_with(|| format!("building tailscale /public{path} URL"))?,
321 State::Mtls(_) => self
322 .base_url
323 .join(path)
324 .into_diagnostic()
325 .wrap_err_with(|| format!("building {path} URL"))?,
326 };
327 Ok((state.http(), url))
328 }
329
330 async fn send_call<B: serde::Serialize + ?Sized>(
337 &self,
338 method: reqwest::Method,
339 path: &str,
340 body: Option<&B>,
341 ) -> Result<reqwest::Response> {
342 let (http, url) = self.endpoint_url(path).await?;
343 debug!(%url, %method, "canopy request");
344 let mut req = http.request(method, url);
345 if let Some(body) = body {
346 let raw = serde_json::to_vec(body)
347 .into_diagnostic()
348 .wrap_err_with(|| format!("serialising canopy {path} body"))?;
349 let compressed = gzip_bytes(&raw)
350 .into_diagnostic()
351 .wrap_err_with(|| format!("gzipping canopy {path} body"))?;
352 req = req
353 .header(reqwest::header::CONTENT_TYPE, "application/json")
354 .header(reqwest::header::CONTENT_ENCODING, "gzip")
355 .body(compressed);
356 }
357
358 let response = req
359 .send()
360 .await
361 .into_diagnostic()
362 .wrap_err_with(|| format!("calling canopy {path}"))?;
363
364 let status = response.status();
365 if !status.is_success() {
366 let body = response.text().await.unwrap_or_default();
367 return Err(miette::Report::new(CanopyHttpError {
368 status,
369 path: path.to_owned(),
370 body,
371 }));
372 }
373 Ok(response)
374 }
375
376 pub(crate) async fn call_json<B, R>(
378 &self,
379 method: reqwest::Method,
380 path: &str,
381 body: Option<&B>,
382 ) -> Result<R>
383 where
384 B: serde::Serialize + ?Sized,
385 R: serde::de::DeserializeOwned,
386 {
387 let response = self.send_call(method, path, body).await?;
388 response
389 .json::<R>()
390 .await
391 .into_diagnostic()
392 .wrap_err_with(|| format!("parsing canopy {path} response"))
393 }
394
395 pub(crate) async fn call_empty<B: serde::Serialize + ?Sized>(
397 &self,
398 method: reqwest::Method,
399 path: &str,
400 body: Option<&B>,
401 ) -> Result<()> {
402 self.send_call(method, path, body).await.map(drop)
403 }
404
405 #[cfg(feature = "raw-requests")]
411 pub async fn get(&self, tailscale_path: &str, mtls_path: &str) -> Result<reqwest::Response> {
412 let (http, url) = {
413 let state = self.state.read().await;
414 let url = match &*state {
415 State::Tailscale(_) => self
416 .tailscale_url
417 .join(tailscale_path)
418 .into_diagnostic()
419 .wrap_err("building tailscale GET URL")?,
420 State::Mtls(_) => self
421 .base_url
422 .join(mtls_path)
423 .into_diagnostic()
424 .wrap_err("building mTLS GET URL")?,
425 };
426 (state.http(), url)
427 };
428
429 debug!(%url, "GET via canopy");
430 http.get(url)
431 .send()
432 .await
433 .into_diagnostic()
434 .wrap_err("GET via canopy")
435 }
436
437 #[cfg(feature = "raw-requests")]
443 pub async fn request(
444 &self,
445 method: reqwest::Method,
446 path: &str,
447 ) -> Result<reqwest::RequestBuilder> {
448 let (http, url) = self.endpoint_url(path).await?;
449 debug!(%url, %method, "arbitrary canopy request");
450 Ok(http.request(method, url))
451 }
452
453 #[cfg(feature = "raw-requests")]
460 pub async fn request_json<Res: serde::de::DeserializeOwned>(
461 &self,
462 method: reqwest::Method,
463 path: &str,
464 body: Option<&(impl serde::Serialize + ?Sized)>,
465 ) -> Result<Res> {
466 self.call_json(method, path, body).await
467 }
468}
469
470async fn probe_tailscale(
489 tailscale_url: &Url,
490 make_builder: &ClientBuilderFactory,
491 use_cache: bool,
492) -> Option<reqwest::Client> {
493 let host = tailscale_url.host_str()?;
494
495 if host != TAILSCALE_HOST {
498 return probe_once(tailscale_url, host, &[], make_builder).await;
499 }
500
501 if use_cache && let Some(outcome) = cached_outcome() {
502 debug!("canopy: reusing cached tailnet reachability");
503 return match outcome {
504 TailnetOutcome::Unreachable => None,
505 TailnetOutcome::Reachable(addrs) => build_probe_client(host, &addrs, make_builder),
506 };
507 }
508
509 let discovered = discover_tailnet(tailscale_url, host, make_builder).await;
510 store_outcome(match &discovered {
511 Some((addrs, _)) => TailnetOutcome::Reachable(addrs.clone()),
512 None => TailnetOutcome::Unreachable,
513 });
514 discovered.map(|(_, client)| client)
515}
516
517async fn discover_tailnet(
522 tailscale_url: &Url,
523 host: &str,
524 make_builder: &ClientBuilderFactory,
525) -> Option<(Vec<SocketAddr>, reqwest::Client)> {
526 if !tailscale_present() {
527 debug!("canopy: no tailscale interface on this host; skipping tailnet probe");
528 return None;
529 }
530
531 let via_dns = async {
532 let addrs = resolve_via_tailscale_dns().await;
533 if addrs.is_empty() {
534 return None;
535 }
536 probe_once(tailscale_url, host, &addrs, make_builder)
537 .await
538 .map(|client| (addrs, client))
539 };
540
541 let via_hardcoded = async {
542 let addrs = vec![
543 SocketAddr::new(IpAddr::V4(CANOPY_HARDCODED_V4), 443),
544 SocketAddr::new(IpAddr::V6(CANOPY_HARDCODED_V6), 443),
545 ];
546 probe_once(tailscale_url, host, &addrs, make_builder)
547 .await
548 .map(|client| (addrs, client))
549 };
550
551 race_first_some(via_dns, via_hardcoded).await
552}
553
554async fn resolve_via_tailscale_dns() -> Vec<SocketAddr> {
557 match tokio::time::timeout(DNS_LOOKUP_TIMEOUT, tailscale_resolver().lookup_ip("canopy")).await {
558 Ok(Ok(addrs)) => addrs.iter().map(|ip| SocketAddr::new(ip, 443)).collect(),
559 Ok(Err(err)) => {
560 debug!("canopy tailscale DNS lookup failed: {err}");
561 Vec::new()
562 }
563 Err(_) => {
564 debug!("canopy tailscale DNS lookup timed out");
565 Vec::new()
566 }
567 }
568}
569
570fn build_probe_client(
573 host: &str,
574 addrs: &[SocketAddr],
575 make_builder: &ClientBuilderFactory,
576) -> Option<reqwest::Client> {
577 let mut builder = make_builder()
578 .user_agent(user_agent())
579 .timeout(TAILSCALE_PROBE_TIMEOUT);
580 if !addrs.is_empty() {
581 builder = builder.resolve_to_addrs(host, addrs);
582 }
583 builder.build().ok()
584}
585
586async fn probe_once(
589 tailscale_url: &Url,
590 host: &str,
591 addrs: &[SocketAddr],
592 make_builder: &ClientBuilderFactory,
593) -> Option<reqwest::Client> {
594 let client = build_probe_client(host, addrs, make_builder)?;
595 let url = tailscale_url.join("/public/servers").ok()?;
596 match client.get(url).send().await {
597 Ok(resp) if resp.status().is_success() => Some(client),
598 Ok(resp) => {
599 debug!(status = %resp.status(), ?addrs, "canopy tailscale probe: unexpected status");
600 None
601 }
602 Err(err) => {
603 debug!(?addrs, "canopy tailscale probe failed: {err}");
604 None
605 }
606 }
607}
608
609async fn race_first_some<T>(
613 a: impl Future<Output = Option<T>>,
614 b: impl Future<Output = Option<T>>,
615) -> Option<T> {
616 use futures::future::{Either, select};
617
618 let a = std::pin::pin!(a);
619 let b = std::pin::pin!(b);
620 match select(a, b).await {
621 Either::Left((Some(v), _)) => Some(v),
622 Either::Right((Some(v), _)) => Some(v),
623 Either::Left((None, rest)) => rest.await,
624 Either::Right((None, rest)) => rest.await,
625 }
626}
627
628fn tailscale_present() -> bool {
637 sysinfo::Networks::new_with_refreshed_list()
638 .values()
639 .flat_map(|net| net.ip_networks())
640 .any(|net| is_tailscale_addr(&net.addr))
641}
642
643fn is_tailscale_addr(addr: &IpAddr) -> bool {
644 match addr {
645 IpAddr::V4(v4) => {
646 let o = v4.octets();
647 o[0] == 100 && (64..=127).contains(&o[1])
648 }
649 IpAddr::V6(v6) => {
650 let s = v6.segments();
651 s[0] == 0xfd7a && s[1] == 0x115c && s[2] == 0xa1e0
652 }
653 }
654}
655
656#[derive(Clone)]
658enum TailnetOutcome {
659 Reachable(Vec<SocketAddr>),
661 Unreachable,
662}
663
664struct CachedProbe {
665 stored_at: Instant,
666 outcome: TailnetOutcome,
667}
668
669fn probe_cache() -> &'static Mutex<Option<CachedProbe>> {
670 static CACHE: OnceLock<Mutex<Option<CachedProbe>>> = OnceLock::new();
671 CACHE.get_or_init(|| Mutex::new(None))
672}
673
674fn cached_outcome() -> Option<TailnetOutcome> {
676 let guard = probe_cache().lock().expect("canopy probe cache poisoned");
677 let entry = guard.as_ref()?;
678 (entry.stored_at.elapsed() < PROBE_CACHE_TTL).then(|| entry.outcome.clone())
679}
680
681fn store_outcome(outcome: TailnetOutcome) {
682 *probe_cache().lock().expect("canopy probe cache poisoned") = Some(CachedProbe {
683 stored_at: Instant::now(),
684 outcome,
685 });
686}
687
688fn tailscale_resolver() -> Resolver<impl ConnectionProvider> {
689 Resolver::builder_with_config(
690 ResolverConfig::from_parts(
691 None,
692 vec!["tail53aef.ts.net.".parse().unwrap()],
693 vec![NameServerConfig::new(
694 "100.100.100.100".parse().unwrap(),
695 true,
696 vec![ConnectionConfig::udp()],
697 )],
698 ),
699 TokioRuntimeProvider::default(),
700 )
701 .build()
702 .expect("tailscale resolver config is hardcoded and cannot fail to build")
703}
704
705fn gzip_bytes(bytes: &[u8]) -> std::io::Result<Vec<u8>> {
706 let mut encoder = GzEncoder::new(Vec::with_capacity(bytes.len() / 2), Compression::default());
707 encoder.write_all(bytes)?;
708 encoder.finish()
709}
710
711pub fn device_identity(device_key_pem: &str) -> Result<reqwest::Identity> {
720 let key_pair = KeyPair::from_pem(device_key_pem)
721 .into_diagnostic()
722 .wrap_err("parsing device key PEM")?;
723
724 let mut params = CertificateParams::new(vec!["device.local".into()])
725 .into_diagnostic()
726 .wrap_err("building certificate params")?;
727 params.distinguished_name = DistinguishedName::new();
728 params
729 .distinguished_name
730 .push(DnType::CommonName, "device.local");
731
732 let now = OffsetDateTime::now_utc();
733 params.not_before = now - TimeDuration::minutes(1);
734 params.not_after = now + TimeDuration::days(CERT_VALIDITY_DAYS);
735
736 let cert = params
737 .self_signed(&key_pair)
738 .into_diagnostic()
739 .wrap_err("self-signing certificate")?;
740
741 let mut combined = cert.pem();
742 combined.push('\n');
743 combined.push_str(&key_pair.serialize_pem());
744
745 reqwest::Identity::from_pem(combined.as_bytes())
746 .into_diagnostic()
747 .wrap_err("building reqwest TLS identity")
748}
749
750fn build_mtls_http(
751 make_builder: &ClientBuilderFactory,
752 device_key_pem: &str,
753) -> Result<reqwest::Client> {
754 let identity = device_identity(device_key_pem)?;
755
756 make_builder()
757 .user_agent(user_agent())
758 .identity(identity)
759 .use_rustls_tls()
760 .timeout(Duration::from_secs(30))
761 .build()
762 .into_diagnostic()
763 .wrap_err("building canopy HTTP client")
764}
765
766#[cfg(test)]
767mod tests {
768 use super::*;
769
770 const TEST_DEVICE_KEY: &str = "\
771-----BEGIN PRIVATE KEY-----
772MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgVvhzsYiidp38GYn1
773KxD5Wipc/h8lglVsy1UFZq/SZbGhRANCAAT2EsEq7xjeWVnim9XwdYXga/LBbppm
774fXLgamTYOa/w9n/Ta64fiYWmN54kEd0DgnflJDLtID321Zz6xswvK/VN
775-----END PRIVATE KEY-----";
776
777 fn test_factory() -> ClientBuilderFactory {
778 Arc::new(reqwest::Client::builder)
779 }
780
781 #[test]
782 fn build_mtls_http_from_p256_key() {
783 let result = build_mtls_http(&test_factory(), TEST_DEVICE_KEY);
785 assert!(result.is_ok(), "{:?}", result.err());
786 }
787
788 #[test]
789 fn build_mtls_http_fails_on_garbage_key() {
790 assert!(build_mtls_http(&test_factory(), "not a real PEM").is_err());
791 }
792
793 fn closed_url() -> String {
795 let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
796 let addr = listener.local_addr().unwrap();
797 drop(listener);
798 format!("http://{addr}")
799 }
800
801 #[tokio::test]
802 async fn no_device_key_still_builds_over_tailscale() {
803 let (tailnet, _server) = serve_once("HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\n[]");
804 let client = CanopyClient::with_urls(
805 DEFAULT_CANOPY_URL.parse().unwrap(),
806 tailnet.parse().unwrap(),
807 None,
808 reqwest::Client::builder,
809 )
810 .await
811 .expect("keyless build should not error")
812 .expect("a reachable tailnet is an auth path in its own right");
813 assert!(client.is_tailscale().await);
814 }
815
816 #[tokio::test]
817 async fn no_device_key_and_no_tailnet_leaves_no_auth_path() {
818 let client = CanopyClient::with_urls(
819 DEFAULT_CANOPY_URL.parse().unwrap(),
820 closed_url().parse().unwrap(),
821 None,
822 reqwest::Client::builder,
823 )
824 .await
825 .expect("keyless build should not error");
826 assert!(client.is_none());
827 }
828
829 #[tokio::test]
830 async fn device_key_carries_the_call_when_the_tailnet_is_unreachable() {
831 let client = CanopyClient::with_urls(
832 DEFAULT_CANOPY_URL.parse().unwrap(),
833 closed_url().parse().unwrap(),
834 Some(TEST_DEVICE_KEY),
835 reqwest::Client::builder,
836 )
837 .await
838 .expect("mTLS build should not error")
839 .expect("a device key is an auth path when the tailnet is out of reach");
840 assert!(!client.is_tailscale().await);
841 }
842
843 #[tokio::test]
844 async fn renew_with_mtls_state_swaps_in_fresh_client() {
845 let http = build_mtls_http(&test_factory(), TEST_DEVICE_KEY).unwrap();
847 let client = CanopyClient {
848 base_url: DEFAULT_CANOPY_URL.parse().unwrap(),
849 tailscale_url: TAILSCALE_URL.parse().unwrap(),
850 device_key: Some(Redacted(TEST_DEVICE_KEY.to_owned())),
851 make_builder: test_factory(),
852 state: RwLock::new(State::Mtls(http)),
853 };
854 client.renew().await.expect("renew should succeed");
855 assert!(!client.is_tailscale().await);
856 }
857
858 #[tokio::test]
859 async fn renew_is_noop_in_tailscale_mode() {
860 let http = reqwest::Client::new();
862 let client = CanopyClient {
863 base_url: DEFAULT_CANOPY_URL.parse().unwrap(),
864 tailscale_url: TAILSCALE_URL.parse().unwrap(),
865 device_key: None,
866 make_builder: test_factory(),
867 state: RwLock::new(State::Tailscale(http)),
868 };
869 client.renew().await.expect("renew should be a no-op");
870 assert!(client.is_tailscale().await);
871 }
872
873 fn mtls_client_against(base: &str) -> CanopyClient {
874 let http = build_mtls_http(&test_factory(), TEST_DEVICE_KEY).unwrap();
875 CanopyClient {
876 base_url: base.parse().unwrap(),
877 tailscale_url: TAILSCALE_URL.parse().unwrap(),
878 device_key: Some(Redacted(TEST_DEVICE_KEY.to_owned())),
879 make_builder: test_factory(),
880 state: RwLock::new(State::Mtls(http)),
881 }
882 }
883
884 struct Captured {
885 request_line: String,
886 headers: String,
887 body: Vec<u8>,
888 }
889
890 fn serve_once(response: &'static str) -> (String, std::thread::JoinHandle<Captured>) {
893 use std::io::{Read, Write};
894 use std::net::TcpListener;
895
896 let listener = TcpListener::bind("127.0.0.1:0").unwrap();
897 let base = format!("http://{}", listener.local_addr().unwrap());
898 let handle = std::thread::spawn(move || {
899 let (mut stream, _) = listener.accept().unwrap();
900 let mut buf = Vec::new();
901 let mut chunk = [0u8; 1024];
902 let header_end = loop {
903 if let Some(pos) = buf.windows(4).position(|w| w == b"\r\n\r\n") {
904 break pos + 4;
905 }
906 let n = stream.read(&mut chunk).unwrap();
907 if n == 0 {
908 panic!("connection closed before headers were complete");
909 }
910 buf.extend_from_slice(&chunk[..n]);
911 };
912
913 let head = String::from_utf8_lossy(&buf[..header_end]).into_owned();
914 let content_length = head
915 .lines()
916 .find_map(|line| {
917 let (name, value) = line.split_once(':')?;
918 name.trim()
919 .eq_ignore_ascii_case("content-length")
920 .then(|| value.trim().parse::<usize>().ok())
921 .flatten()
922 })
923 .unwrap_or(0);
924
925 let mut body = buf[header_end..].to_vec();
926 while body.len() < content_length {
927 let n = stream.read(&mut chunk).unwrap();
928 if n == 0 {
929 break;
930 }
931 body.extend_from_slice(&chunk[..n]);
932 }
933
934 stream.write_all(response.as_bytes()).unwrap();
935 stream.flush().unwrap();
936
937 let mut lines = head.lines();
938 let request_line = lines.next().unwrap_or_default().to_owned();
939 let headers = lines.collect::<Vec<_>>().join("\n");
940 Captured {
941 request_line,
942 headers,
943 body,
944 }
945 });
946 (base, handle)
947 }
948
949 #[derive(Debug, serde::Deserialize, PartialEq)]
950 struct Echo {
951 ok: bool,
952 who: String,
953 }
954
955 #[tokio::test]
956 async fn call_json_gzips_body_sets_user_agent_and_parses_response() {
957 let (base, handle) = serve_once(
958 "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: 26\r\n\r\n{\"ok\":true,\"who\":\"device\"}",
959 );
960 let client = mtls_client_against(&base);
961
962 let payload = serde_json::json!({ "hello": "world" });
963 let got: Echo = client
964 .call_json(reqwest::Method::POST, "/thing", Some(&payload))
965 .await
966 .expect("call_json should succeed");
967
968 assert_eq!(
969 got,
970 Echo {
971 ok: true,
972 who: "device".into()
973 }
974 );
975
976 let captured = handle.join().unwrap();
977 assert!(
978 captured.request_line.starts_with("POST /thing "),
979 "unexpected request line: {}",
980 captured.request_line
981 );
982 let headers = captured.headers.to_ascii_lowercase();
983 assert!(
984 headers.contains("user-agent: bestool-canopy/"),
985 "missing canopy user-agent in:\n{}",
986 captured.headers
987 );
988 assert!(
989 headers.contains("content-encoding: gzip"),
990 "body should be gzipped:\n{}",
991 captured.headers
992 );
993 use flate2::read::GzDecoder;
995 use std::io::Read as _;
996 let mut decoder = GzDecoder::new(&captured.body[..]);
997 let mut raw = Vec::new();
998 decoder
999 .read_to_end(&mut raw)
1000 .expect("body should be valid gzip");
1001 let sent: serde_json::Value = serde_json::from_slice(&raw).unwrap();
1002 assert_eq!(sent, payload);
1003 }
1004
1005 #[tokio::test]
1006 async fn call_json_errors_on_non_success_with_body() {
1007 let (base, handle) =
1008 serve_once("HTTP/1.1 418 I'm a teapot\r\nContent-Length: 14\r\n\r\nno coffee here");
1009 let client = mtls_client_against(&base);
1010
1011 let err = client
1012 .call_json::<(), serde_json::Value>(reqwest::Method::GET, "/brew", None::<&()>)
1013 .await
1014 .expect_err("non-2xx should error");
1015 let msg = err.to_string();
1016 assert!(msg.contains("/brew"), "expected path in error: {msg}");
1017 assert!(msg.contains("418"), "expected status in error: {msg}");
1018 assert!(
1019 msg.contains("no coffee here"),
1020 "expected body text in error: {msg}"
1021 );
1022
1023 handle.join().unwrap();
1024 }
1025
1026 #[test]
1027 fn user_agent_identifies_the_crate_with_os_comment() {
1028 let ua = user_agent();
1029 assert!(
1030 ua.starts_with(concat!("bestool-canopy/", env!("CARGO_PKG_VERSION"), " ")),
1031 "unexpected user-agent: {ua}"
1032 );
1033 assert!(ua.contains('('), "expected OS comment in: {ua}");
1034 assert!(ua.ends_with(')'), "expected OS comment in: {ua}");
1035 assert!(
1036 ua.contains(sysinfo::System::cpu_arch().as_str()),
1037 "expected arch in: {ua}"
1038 );
1039 }
1040
1041 #[test]
1042 fn tailscale_addr_classifies_cgnat_v4() {
1043 assert!(is_tailscale_addr(&IpAddr::V4(Ipv4Addr::new(100, 64, 0, 1))));
1044 assert!(is_tailscale_addr(&IpAddr::V4(Ipv4Addr::new(
1045 100, 127, 255, 255
1046 ))));
1047 assert!(is_tailscale_addr(&IpAddr::V4(CANOPY_HARDCODED_V4)));
1048 assert!(!is_tailscale_addr(&IpAddr::V4(Ipv4Addr::new(
1050 100, 63, 255, 255
1051 ))));
1052 assert!(!is_tailscale_addr(&IpAddr::V4(Ipv4Addr::new(
1053 100, 128, 0, 0
1054 ))));
1055 assert!(!is_tailscale_addr(&IpAddr::V4(Ipv4Addr::new(10, 0, 0, 1))));
1057 assert!(!is_tailscale_addr(&IpAddr::V4(Ipv4Addr::new(100, 0, 0, 1))));
1058 }
1059
1060 #[test]
1061 fn tailscale_addr_classifies_ula_v6() {
1062 assert!(is_tailscale_addr(&IpAddr::V6(CANOPY_HARDCODED_V6)));
1063 assert!(is_tailscale_addr(&IpAddr::V6(Ipv6Addr::new(
1064 0xfd7a, 0x115c, 0xa1e0, 0, 0, 0, 0, 1
1065 ))));
1066 assert!(!is_tailscale_addr(&IpAddr::V6(Ipv6Addr::new(
1068 0xfd00, 0x115c, 0xa1e0, 0, 0, 0, 0, 1
1069 ))));
1070 assert!(!is_tailscale_addr(&IpAddr::V6(Ipv6Addr::LOCALHOST)));
1071 }
1072
1073 #[test]
1074 fn probe_cache_roundtrips_and_expires() {
1075 store_outcome(TailnetOutcome::Reachable(vec![SocketAddr::new(
1076 IpAddr::V4(CANOPY_HARDCODED_V4),
1077 443,
1078 )]));
1079 match cached_outcome() {
1080 Some(TailnetOutcome::Reachable(addrs)) => {
1081 assert_eq!(
1082 addrs,
1083 vec![SocketAddr::new(IpAddr::V4(CANOPY_HARDCODED_V4), 443)]
1084 );
1085 }
1086 other => panic!(
1087 "expected freshly stored Reachable, got {:?}",
1088 other.is_some()
1089 ),
1090 }
1091
1092 if let Some(stale) = Instant::now().checked_sub(PROBE_CACHE_TTL + Duration::from_secs(1)) {
1096 *probe_cache().lock().unwrap() = Some(CachedProbe {
1097 stored_at: stale,
1098 outcome: TailnetOutcome::Unreachable,
1099 });
1100 assert!(cached_outcome().is_none());
1101 }
1102 }
1103
1104 #[test]
1105 fn gzip_bytes_roundtrips() {
1106 use flate2::read::GzDecoder;
1107 use std::io::Read;
1108
1109 let original = br#"{"health":[{"check":"x","result":"passed"}]}"#;
1110 let compressed = gzip_bytes(original).expect("gzip should succeed");
1111 assert!(
1112 compressed.starts_with(&[0x1f, 0x8b]),
1113 "expected gzip magic bytes"
1114 );
1115 let mut decoder = GzDecoder::new(&compressed[..]);
1116 let mut decompressed = Vec::new();
1117 decoder.read_to_end(&mut decompressed).unwrap();
1118 assert_eq!(decompressed, original);
1119 }
1120}