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