Skip to main content

bestool_canopy/
reqwest_transport.rs

1//! The default [`CanopyTransport`]: a [`reqwest`] client that picks canopy's
2//! auth path (tailscale or mTLS) and routes calls accordingly.
3
4use 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
30/// Base URL for the tailscale-internal canopy endpoint.
31///
32/// On hosts that share the canopy tailnet, posting to this URL works without
33/// mTLS — the tailscale identity is the auth.
34pub const TAILSCALE_URL: &str = "https://canopy.tail53aef.ts.net";
35
36/// Bare hostname used for `resolve_to_addrs` overrides.
37const TAILSCALE_HOST: &str = "canopy.tail53aef.ts.net";
38
39/// Hardcoded tailscale IPs for canopy, used when tailscale DNS
40/// (100.100.100.100) is unreachable but the tailnet otherwise is.
41const 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
45/// How long renewed canopy certs are valid for.
46///
47/// Set well above [`CERT_RENEW_AFTER`] so a renewal failure doesn't immediately
48/// strand the client.
49const CERT_VALIDITY_DAYS: i64 = 6;
50
51/// How long to wait between scheduled cert renewals.
52///
53/// Renewal runs in a background task in the daemon; the legacy single-shot
54/// alerts command builds the client once and exits well within this window.
55pub const CERT_RENEW_AFTER: Duration = Duration::from_secs(5 * 24 * 60 * 60);
56
57/// Timeout for the tailscale availability probe.
58const TAILSCALE_PROBE_TIMEOUT: Duration = Duration::from_secs(5);
59
60/// Timeout for the tailscale DNS lookup (against 100.100.100.100).
61///
62/// Bounds the lookup so a wedged tailscale DNS server can't stall discovery;
63/// on timeout we fall back to the hardcoded IPs, which are probed concurrently
64/// anyway.
65const DNS_LOOKUP_TIMEOUT: Duration = Duration::from_secs(2);
66
67/// How long a tailnet-reachability discovery is trusted before re-probing.
68///
69/// Short enough that tailscale coming up or going down is picked up promptly,
70/// long enough that a burst of client constructions in one process shares a
71/// single discovery instead of each paying the probe cost.
72const PROBE_CACHE_TTL: Duration = Duration::from_secs(60);
73
74/// Factory producing the base [`reqwest::ClientBuilder`] for canopy's clients.
75///
76/// The caller supplies this so it owns cross-cutting client config
77/// (`SSLKEYLOGFILE`, proxies, …). Canopy invokes it whenever it needs to build or
78/// rebuild a client — at probe time, on mTLS cert renewal, and on reload — then
79/// layers its own concerns (its [`user_agent`], mTLS identity, DNS overrides,
80/// timeouts) on top.
81pub type ClientBuilderFactory = Arc<dyn Fn() -> reqwest::ClientBuilder + Send + Sync>;
82
83/// User-agent set on every canopy request, e.g.
84/// `bestool-canopy/0.5.0 (Linux 7.0.9 Arch Linux; x86_64)`.
85///
86/// Identifies this client crate and its version; the OS comment is detected at
87/// runtime and cached. The transport sets this itself on top of the caller's
88/// [`ClientBuilderFactory`], so canopy traffic identifies the client library
89/// regardless of the calling binary.
90fn 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
104/// Probe the canopy tailnet endpoint, returning a client routed to it if
105/// reachable.
106///
107/// The returned client carries the same DNS / hardcoded-IP resolution override
108/// the reporting client uses and presents **no** client certificate — callers
109/// reaching canopy this way authenticate by tailnet identity. Returns `None`
110/// when the tailnet endpoint isn't reachable, so callers can fall back to
111/// public mTLS.
112pub 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
119/// The default canopy transport: HTTP with auth configured for talking to a
120/// canopy server.
121///
122/// Tries two auth paths in order of preference:
123/// 1. **Tailscale**: if the canopy tailnet endpoint is reachable, plain HTTPS
124///    works (auth is implicit via tailscale identity).
125/// 2. **mTLS**: a fresh self-signed cert from the device key, short-lived
126///    ([`CERT_VALIDITY_DAYS`]); for long-running daemons, [`Self::renew`]
127///    should tick on [`CERT_RENEW_AFTER`] to swap in a fresh cert before expiry.
128///
129/// [`Self::refresh`] re-probes tailscale and swaps modes on reload.
130///
131/// [`connect`](crate::connect) and [`connect_to`](crate::connect_to) build one
132/// of these, so callers on the default transport never need to name it.
133pub struct ReqwestTransport {
134	/// Base URL for the mTLS path (canopy's public API, from the registration's
135	/// `api_url`). Used only on the mTLS path. Fixed for the transport's lifetime.
136	base_url: Url,
137	/// Base URL for the tailscale path (defaults to [`TAILSCALE_URL`]). Used only
138	/// on the tailscale path. Fixed for the transport's lifetime.
139	tailscale_url: Url,
140	device_key: Option<Redacted<String>>,
141	/// Produces the base client builder; see [`ClientBuilderFactory`].
142	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	/// Build a transport against explicit endpoints.
171	///
172	/// `base_url` is canopy's public API URL (the registration's `api_url`), used
173	/// on the mTLS path; `tailscale_url` is the tailnet endpoint used on the
174	/// tailscale path. Both are fixed for the transport's lifetime.
175	///
176	/// Probes the tailscale endpoint first; if reachable, uses it. Otherwise, if
177	/// a device key PEM is provided, builds an mTLS client. Returns `Ok(None)` if
178	/// neither path is available.
179	///
180	/// `make_builder` supplies the base [`reqwest::ClientBuilder`] — see
181	/// [`ClientBuilderFactory`].
182	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	/// An mTLS-state transport against `base`, built without a network probe.
218	#[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	/// Returns true if the transport is currently using the tailscale path.
233	pub async fn is_tailscale(&self) -> bool {
234		self.state.read().await.is_tailscale()
235	}
236
237	/// Re-probe tailscale and swap modes if the picture has changed.
238	///
239	/// Intended to be called when the daemon receives a reload signal.
240	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	/// Rebuild the underlying HTTP client with a fresh certificate.
265	///
266	/// No-op in tailscale mode (no cert to rotate). In mTLS mode, atomically
267	/// replaces the live client; in-flight requests continue with the old
268	/// client until they complete.
269	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	/// Resolve the HTTP client + URL for `path` on the current auth path.
282	///
283	/// `path` is the mTLS-mode path (e.g. `/backup-target`); over tailscale the
284	/// same endpoint is mounted under `/public`, so this prepends it.
285	///
286	/// A join failure is reported as [`Error::transport`]: the request never
287	/// reaches canopy, which is what that variant means.
288	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	/// GET a path, routed via tailscale when available, returning the raw response.
301	///
302	/// Escape hatch behind the generated endpoint methods; needs the `raw-requests`
303	/// feature. In tailscale mode the request goes to `{tailscale_url}{tailscale_path}`
304	/// (typically `/public/...`); in mTLS mode to `{base_url}{mtls_path}`.
305	#[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	/// Start a request to an arbitrary canopy endpoint on the current auth path.
333	///
334	/// Escape hatch behind the generated endpoint methods; needs the `raw-requests`
335	/// feature. `path` is the mTLS-mode path; over tailscale it's routed under
336	/// `/public`, the same convention the generated methods follow.
337	#[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
377/// Probe the tailscale canopy endpoint, returning a configured `reqwest::Client`
378/// routed to it if reachable and `None` otherwise (so callers fall back to mTLS).
379///
380/// For canopy's own tailnet endpoint the work is short-circuited and shared:
381/// 1. **Gate** — if no tailscale interface is present on this host
382///    ([`tailscale_present`]), the tailnet is unreachable by definition, so
383///    skip all network I/O and return `None` immediately.
384/// 2. **Cache** — when `use_cache` is set, a discovery from the last
385///    [`PROBE_CACHE_TTL`] is reused instead of re-probing. `refresh` passes
386///    `false` to force a fresh discovery on reload.
387/// 3. **Discovery** — the tailscale-DNS-resolved probe and the hardcoded-IP
388///    probe run *concurrently* ([`discover_tailnet`]); the first success wins.
389///
390/// `GET /public/servers` is the probe target because:
391/// - it lives under `/public/...`, the only mount that accepts tagged-device
392///   tailscale callers (everything else 403s with `tagged-device-not-allowed`);
393/// - it's a `GET` with no body, no `VersionHeader` requirement, and no auth;
394/// - it's read-only, so probing it has no side effects.
395async 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	// The gate, cache, and hardcoded-IP discovery below are specific to canopy's
403	// own tailnet endpoint; probe any other tailscale URL with plain resolution.
404	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
424/// Discover a reachable route to the canopy tailnet endpoint, or `None`.
425///
426/// Returns the addresses that worked alongside the client built for them, so
427/// the caller can both cache the route and reuse the client without rebuilding.
428async 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
461/// Resolve `canopy` via the tailscale DNS server (100.100.100.100), bounded by
462/// [`DNS_LOOKUP_TIMEOUT`]. Returns an empty vec on timeout or lookup failure.
463async 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
477/// Build the probe client for `host`, resolving it to `addrs` when non-empty
478/// (the tailnet-discovery override); otherwise plain DNS is used.
479fn 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
493/// Build a client for `addrs` and confirm `GET {tailscale_url}/public/servers`
494/// responds 2xx; return the client on success, `None` on any other outcome.
495async 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
516/// Await two probes concurrently, resolving to the first that yields `Some`.
517///
518/// If the first to finish yields `None`, the other is awaited to completion.
519async 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
535/// Whether any local interface holds a tailscale-assigned address.
536///
537/// Tailscale hands out IPv4 from the `100.64.0.0/10` CGNAT range and IPv6 from
538/// its `fd7a:115c:a1e0::/48` ULA prefix. When neither is present the host isn't
539/// on the tailnet, so probing canopy's tailnet endpoint can only ever time out
540/// — the check lets us skip it and go straight to mTLS. A host that reaches the
541/// tailnet purely through a subnet router (no address of its own) is treated as
542/// absent and falls back to mTLS, which still works.
543fn 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/// Outcome of a tailnet-reachability discovery, cached for [`PROBE_CACHE_TTL`].
564#[derive(Clone)]
565enum TailnetOutcome {
566	/// Reachable via these addresses (empty = plain DNS resolution worked).
567	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
581/// The cached outcome if one was stored within the last [`PROBE_CACHE_TTL`].
582fn 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
612/// Build a short-lived self-signed client certificate from a P-256 device key
613/// PEM and wrap it as a reqwest mTLS [`Identity`].
614///
615/// Canopy identifies a device by its certificate's public key (SPKI), not by a
616/// CA chain, so a fresh self-signed cert from the device key is all that's
617/// needed. The same device key drives both the long-running canopy client here
618/// and the one-shot `canopy register` enrollment handshake, so they present the
619/// same identity to canopy.
620///
621/// [`Identity`]: reqwest::Identity
622pub 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		// A generated call over the real transport: it resolves the mTLS path and
678		// stamps the canopy user-agent, whatever binary is calling.
679		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		// Direct mTLS-path build, bypassing the async constructor / tailscale probe.
703		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		// Tailscale-state transport with no device key — renew is a no-op.
764		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		// Just outside the 100.64.0.0/10 range on either side.
811		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		// A plain public/private v4 must not read as tailscale.
818		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		// Different ULA prefix — not tailscale.
829		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		// A stale entry (stored before the TTL window) reads as a miss.
855		// Guard the subtraction: a freshly started process may not have enough
856		// monotonic headroom to represent an instant a full TTL in the past.
857		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}