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 crate::{
25	Redacted,
26	transport::{CanopyRequest, CanopyResponse, CanopyTransport},
27};
28
29pub const DEFAULT_CANOPY_URL: &str = "https://meta.tamanu.app";
30
31/// Base URL for the tailscale-internal canopy endpoint.
32///
33/// On hosts that share the canopy tailnet, posting to this URL works without
34/// mTLS — the tailscale identity is the auth.
35pub const TAILSCALE_URL: &str = "https://canopy.tail53aef.ts.net";
36
37/// Bare hostname used for `resolve_to_addrs` overrides.
38const TAILSCALE_HOST: &str = "canopy.tail53aef.ts.net";
39
40/// Hardcoded tailscale IPs for canopy, used when tailscale DNS
41/// (100.100.100.100) is unreachable but the tailnet otherwise is.
42const 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
46/// How long renewed canopy certs are valid for.
47///
48/// Set well above [`CERT_RENEW_AFTER`] so a renewal failure doesn't immediately
49/// strand the client.
50const CERT_VALIDITY_DAYS: i64 = 6;
51
52/// How long to wait between scheduled cert renewals.
53///
54/// Renewal runs in a background task in the daemon; the legacy single-shot
55/// alerts command builds the client once and exits well within this window.
56pub const CERT_RENEW_AFTER: Duration = Duration::from_secs(5 * 24 * 60 * 60);
57
58/// Timeout for the tailscale availability probe.
59const TAILSCALE_PROBE_TIMEOUT: Duration = Duration::from_secs(5);
60
61/// Timeout for the tailscale DNS lookup (against 100.100.100.100).
62///
63/// Bounds the lookup so a wedged tailscale DNS server can't stall discovery;
64/// on timeout we fall back to the hardcoded IPs, which are probed concurrently
65/// anyway.
66const DNS_LOOKUP_TIMEOUT: Duration = Duration::from_secs(2);
67
68/// How long a tailnet-reachability discovery is trusted before re-probing.
69///
70/// Short enough that tailscale coming up or going down is picked up promptly,
71/// long enough that a burst of client constructions in one process shares a
72/// single discovery instead of each paying the probe cost.
73const PROBE_CACHE_TTL: Duration = Duration::from_secs(60);
74
75/// Factory producing the base [`reqwest::ClientBuilder`] for canopy's clients.
76///
77/// The caller supplies this so it owns cross-cutting client config
78/// (`SSLKEYLOGFILE`, proxies, …). Canopy invokes it whenever it needs to build or
79/// rebuild a client — at probe time, on mTLS cert renewal, and on reload — then
80/// layers its own concerns (its [`user_agent`], mTLS identity, DNS overrides,
81/// timeouts) on top.
82pub type ClientBuilderFactory = Arc<dyn Fn() -> reqwest::ClientBuilder + Send + Sync>;
83
84/// User-agent set on every canopy request, e.g.
85/// `bestool-canopy/0.5.0 (Linux 7.0.9 Arch Linux; x86_64)`.
86///
87/// Identifies this client crate and its version; the OS comment is detected at
88/// runtime and cached. The transport sets this itself on top of the caller's
89/// [`ClientBuilderFactory`], so canopy traffic identifies the client library
90/// regardless of the calling binary.
91fn 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
105/// Probe the canopy tailnet endpoint, returning a client routed to it if
106/// reachable.
107///
108/// The returned client carries the same DNS / hardcoded-IP resolution override
109/// the reporting client uses and presents **no** client certificate — callers
110/// reaching canopy this way authenticate by tailnet identity. Returns `None`
111/// when the tailnet endpoint isn't reachable, so callers can fall back to
112/// public mTLS.
113pub 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
120/// The default canopy transport: HTTP with auth configured for talking to a
121/// canopy server.
122///
123/// Tries two auth paths in order of preference:
124/// 1. **Tailscale**: if the canopy tailnet endpoint is reachable, plain HTTPS
125///    works (auth is implicit via tailscale identity).
126/// 2. **mTLS**: a fresh self-signed cert from the device key, short-lived
127///    ([`CERT_VALIDITY_DAYS`]); for long-running daemons, [`Self::renew`]
128///    should tick on [`CERT_RENEW_AFTER`] to swap in a fresh cert before expiry.
129///
130/// [`Self::refresh`] re-probes tailscale and swaps modes on reload.
131///
132/// [`CanopyClient::new`](crate::CanopyClient::new) and
133/// [`with_urls`](crate::CanopyClient::with_urls) build one of these, so callers
134/// on the default transport never need to name it.
135pub struct ReqwestTransport {
136	/// Base URL for the mTLS path (canopy's public API, from the registration's
137	/// `api_url`). Used only on the mTLS path. Fixed for the transport's lifetime.
138	base_url: Url,
139	/// Base URL for the tailscale path (defaults to [`TAILSCALE_URL`]). Used only
140	/// on the tailscale path. Fixed for the transport's lifetime.
141	tailscale_url: Url,
142	device_key: Option<Redacted<String>>,
143	/// Produces the base client builder; see [`ClientBuilderFactory`].
144	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	/// Build a transport against explicit endpoints.
173	///
174	/// `base_url` is canopy's public API URL (the registration's `api_url`), used
175	/// on the mTLS path; `tailscale_url` is the tailnet endpoint used on the
176	/// tailscale path. Both are fixed for the transport's lifetime.
177	///
178	/// Probes the tailscale endpoint first; if reachable, uses it. Otherwise, if
179	/// a device key PEM is provided, builds an mTLS client. Returns `Ok(None)` if
180	/// neither path is available.
181	///
182	/// `make_builder` supplies the base [`reqwest::ClientBuilder`] — see
183	/// [`ClientBuilderFactory`].
184	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	/// An mTLS-state transport against `base`, built without a network probe.
220	#[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	/// Returns true if the transport is currently using the tailscale path.
235	pub async fn is_tailscale(&self) -> bool {
236		self.state.read().await.is_tailscale()
237	}
238
239	/// Re-probe tailscale and swap modes if the picture has changed.
240	///
241	/// Intended to be called when the daemon receives a reload signal.
242	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	/// Rebuild the underlying HTTP client with a fresh certificate.
267	///
268	/// No-op in tailscale mode (no cert to rotate). In mTLS mode, atomically
269	/// replaces the live client; in-flight requests continue with the old
270	/// client until they complete.
271	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	/// Resolve the HTTP client + URL for `path` on the current auth path.
284	///
285	/// `path` is the mTLS-mode path (e.g. `/backup-target`); over tailscale the
286	/// same endpoint is mounted under `/public`, so this prepends it.
287	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	/// GET a path, routed via tailscale when available, returning the raw response.
305	///
306	/// Escape hatch behind the generated endpoint methods; needs the `raw-requests`
307	/// feature. In tailscale mode the request goes to `{tailscale_url}{tailscale_path}`
308	/// (typically `/public/...`); in mTLS mode to `{base_url}{mtls_path}`.
309	#[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	/// Start a request to an arbitrary canopy endpoint on the current auth path.
337	///
338	/// Escape hatch behind the generated endpoint methods; needs the `raw-requests`
339	/// feature. `path` is the mTLS-mode path; over tailscale it's routed under
340	/// `/public`, the same convention the generated methods follow.
341	#[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
389/// Probe the tailscale canopy endpoint, returning a configured `reqwest::Client`
390/// routed to it if reachable and `None` otherwise (so callers fall back to mTLS).
391///
392/// For canopy's own tailnet endpoint the work is short-circuited and shared:
393/// 1. **Gate** — if no tailscale interface is present on this host
394///    ([`tailscale_present`]), the tailnet is unreachable by definition, so
395///    skip all network I/O and return `None` immediately.
396/// 2. **Cache** — when `use_cache` is set, a discovery from the last
397///    [`PROBE_CACHE_TTL`] is reused instead of re-probing. `refresh` passes
398///    `false` to force a fresh discovery on reload.
399/// 3. **Discovery** — the tailscale-DNS-resolved probe and the hardcoded-IP
400///    probe run *concurrently* ([`discover_tailnet`]); the first success wins.
401///
402/// `GET /public/servers` is the probe target because:
403/// - it lives under `/public/...`, the only mount that accepts tagged-device
404///   tailscale callers (everything else 403s with `tagged-device-not-allowed`);
405/// - it's a `GET` with no body, no `VersionHeader` requirement, and no auth;
406/// - it's read-only, so probing it has no side effects.
407async 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	// The gate, cache, and hardcoded-IP discovery below are specific to canopy's
415	// own tailnet endpoint; probe any other tailscale URL with plain resolution.
416	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
436/// Discover a reachable route to the canopy tailnet endpoint, or `None`.
437///
438/// Returns the addresses that worked alongside the client built for them, so
439/// the caller can both cache the route and reuse the client without rebuilding.
440async 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
473/// Resolve `canopy` via the tailscale DNS server (100.100.100.100), bounded by
474/// [`DNS_LOOKUP_TIMEOUT`]. Returns an empty vec on timeout or lookup failure.
475async 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
489/// Build the probe client for `host`, resolving it to `addrs` when non-empty
490/// (the tailnet-discovery override); otherwise plain DNS is used.
491fn 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
505/// Build a client for `addrs` and confirm `GET {tailscale_url}/public/servers`
506/// responds 2xx; return the client on success, `None` on any other outcome.
507async 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
528/// Await two probes concurrently, resolving to the first that yields `Some`.
529///
530/// If the first to finish yields `None`, the other is awaited to completion.
531async 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
547/// Whether any local interface holds a tailscale-assigned address.
548///
549/// Tailscale hands out IPv4 from the `100.64.0.0/10` CGNAT range and IPv6 from
550/// its `fd7a:115c:a1e0::/48` ULA prefix. When neither is present the host isn't
551/// on the tailnet, so probing canopy's tailnet endpoint can only ever time out
552/// — the check lets us skip it and go straight to mTLS. A host that reaches the
553/// tailnet purely through a subnet router (no address of its own) is treated as
554/// absent and falls back to mTLS, which still works.
555fn 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/// Outcome of a tailnet-reachability discovery, cached for [`PROBE_CACHE_TTL`].
576#[derive(Clone)]
577enum TailnetOutcome {
578	/// Reachable via these addresses (empty = plain DNS resolution worked).
579	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
593/// The cached outcome if one was stored within the last [`PROBE_CACHE_TTL`].
594fn 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
624/// Build a short-lived self-signed client certificate from a P-256 device key
625/// PEM and wrap it as a reqwest mTLS [`Identity`].
626///
627/// Canopy identifies a device by its certificate's public key (SPKI), not by a
628/// CA chain, so a fresh self-signed cert from the device key is all that's
629/// needed. The same device key drives both the long-running canopy client here
630/// and the one-shot `canopy register` enrollment handshake, so they present the
631/// same identity to canopy.
632///
633/// [`Identity`]: reqwest::Identity
634pub 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		// Direct mTLS-path build, bypassing the async constructor / tailscale probe.
690		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		// Tailscale-state transport with no device key — renew is a no-op.
751		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		// Just outside the 100.64.0.0/10 range on either side.
798		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		// A plain public/private v4 must not read as tailscale.
805		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		// Different ULA prefix — not tailscale.
816		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		// A stale entry (stored before the TTL window) reads as a miss.
842		// Guard the subtraction: a freshly started process may not have enough
843		// monotonic headroom to represent an instant a full TTL in the past.
844		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}