Skip to main content

bestool_canopy/
client.rs

1use std::{fmt, io::Write};
2
3use bytes::Bytes;
4use flate2::{Compression, write::GzEncoder};
5use miette::{IntoDiagnostic, Result, WrapErr};
6use reqwest::Url;
7
8use crate::{
9	reqwest_transport::ReqwestTransport,
10	transport::{CanopyResponse, CanopyTransport},
11};
12
13/// A non-2xx response from a canopy endpoint.
14///
15/// The generated endpoint methods return this (wrapped in a [`miette::Report`])
16/// on any non-success status; downcast the report to it to branch on the code,
17/// e.g. [`TargetOutcome::from_result`](crate::TargetOutcome::from_result) maps a
18/// backup-target `412`/`409` to a dormant device.
19#[derive(Debug, Clone)]
20pub struct CanopyHttpError {
21	/// HTTP status returned by canopy.
22	pub status: reqwest::StatusCode,
23	/// The endpoint path that was called (mTLS-mode form, e.g. `/backup-target`).
24	pub path: String,
25	/// Response body, best-effort (empty if it couldn't be read).
26	pub body: String,
27}
28
29impl fmt::Display for CanopyHttpError {
30	fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
31		write!(
32			f,
33			"canopy {} returned {}: {}",
34			self.path, self.status, self.body
35		)
36	}
37}
38
39impl std::error::Error for CanopyHttpError {}
40impl miette::Diagnostic for CanopyHttpError {}
41
42/// Typed client for canopy's API.
43///
44/// Carries one generated method per endpoint (see [`schema`](crate::schema)),
45/// taking and returning the wire types from canopy's OpenAPI document. Those
46/// methods handle the parts that don't vary by endpoint — gzipping the request
47/// body, mapping a non-2xx to [`CanopyHttpError`], parsing the response — and
48/// hand the actual HTTP over to a [`CanopyTransport`].
49///
50/// The transport defaults to [`ReqwestTransport`], which picks between canopy's
51/// tailscale and mTLS auth paths; [`Self::new`] and [`Self::with_urls`] build
52/// one, so the common case never names it. A caller that reaches canopy some
53/// other way — through a proxy that isn't a plain HTTP proxy, say — implements
54/// [`CanopyTransport`] itself and constructs the client with
55/// [`Self::with_transport`], keeping every generated method and wire type.
56pub struct CanopyClient<T = ReqwestTransport> {
57	transport: T,
58}
59
60impl<T> fmt::Debug for CanopyClient<T> {
61	fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
62		f.debug_struct("CanopyClient").finish_non_exhaustive()
63	}
64}
65
66impl CanopyClient<ReqwestTransport> {
67	/// Build a canopy client against the default public
68	/// ([`DEFAULT_CANOPY_URL`](crate::DEFAULT_CANOPY_URL)) and tailscale
69	/// ([`TAILSCALE_URL`](crate::TAILSCALE_URL)) endpoints. Use [`Self::with_urls`]
70	/// to override them.
71	///
72	/// Probes the tailscale endpoint first; if reachable, uses it. Otherwise, if
73	/// a device key PEM is provided, builds an mTLS client. Returns `Ok(None)` if
74	/// neither path is available.
75	///
76	/// `make_builder` supplies the base [`reqwest::ClientBuilder`] — see
77	/// [`ClientBuilderFactory`](crate::ClientBuilderFactory).
78	pub async fn new(
79		device_key_pem: Option<&str>,
80		make_builder: impl Fn() -> reqwest::ClientBuilder + Send + Sync + 'static,
81	) -> Result<Option<Self>> {
82		Self::with_urls(
83			crate::DEFAULT_CANOPY_URL
84				.parse()
85				.expect("default canopy URL is valid"),
86			crate::TAILSCALE_URL
87				.parse()
88				.expect("default tailscale URL is valid"),
89			device_key_pem,
90			make_builder,
91		)
92		.await
93	}
94
95	/// Build a canopy client against explicit endpoints.
96	///
97	/// `base_url` is canopy's public API URL (the registration's `api_url`),
98	/// used on the mTLS path; `tailscale_url` is the tailnet endpoint used on
99	/// the tailscale path. Both are fixed for the client's lifetime. See
100	/// [`Self::new`] for the other arguments and the default-endpoint form.
101	pub async fn with_urls(
102		base_url: Url,
103		tailscale_url: Url,
104		device_key_pem: Option<&str>,
105		make_builder: impl Fn() -> reqwest::ClientBuilder + Send + Sync + 'static,
106	) -> Result<Option<Self>> {
107		Ok(
108			ReqwestTransport::new(base_url, tailscale_url, device_key_pem, make_builder)
109				.await?
110				.map(Self::with_transport),
111		)
112	}
113
114	/// Returns true if the client is currently using the tailscale path.
115	pub async fn is_tailscale(&self) -> bool {
116		self.transport.is_tailscale().await
117	}
118
119	/// Re-probe tailscale and swap modes if the picture has changed.
120	///
121	/// Intended to be called when the daemon receives a reload signal.
122	pub async fn refresh(&self) -> Result<()> {
123		self.transport.refresh().await
124	}
125
126	/// Rebuild the underlying HTTP client with a fresh certificate.
127	///
128	/// No-op in tailscale mode (no cert to rotate). In mTLS mode, atomically
129	/// replaces the live client; in-flight requests continue with the old
130	/// client until they complete.
131	pub async fn renew(&self) -> Result<()> {
132		self.transport.renew().await
133	}
134
135	/// GET a path, routed via tailscale when available, returning the raw response.
136	///
137	/// Escape hatch behind the generated endpoint methods; needs the `raw-requests`
138	/// feature. In tailscale mode the request goes to `{tailscale_url}{tailscale_path}`
139	/// (typically `/public/...`); in mTLS mode to `{base_url}{mtls_path}`.
140	#[cfg(feature = "raw-requests")]
141	pub async fn get(&self, tailscale_path: &str, mtls_path: &str) -> Result<reqwest::Response> {
142		self.transport.get(tailscale_path, mtls_path).await
143	}
144
145	/// Start a request to an arbitrary canopy endpoint on the current auth path.
146	///
147	/// Escape hatch behind the generated endpoint methods; needs the `raw-requests`
148	/// feature. `path` is the mTLS-mode path; over tailscale it's routed under
149	/// `/public`, the same convention the generated methods follow.
150	#[cfg(feature = "raw-requests")]
151	pub async fn request(
152		&self,
153		method: reqwest::Method,
154		path: &str,
155	) -> Result<reqwest::RequestBuilder> {
156		self.transport.request(method, path).await
157	}
158}
159
160impl<T: CanopyTransport> CanopyClient<T> {
161	/// Build a canopy client over a caller-supplied transport.
162	///
163	/// Everything the client does above the HTTP layer — the generated endpoint
164	/// methods, the wire types, gzipping, error mapping — works the same on any
165	/// [`CanopyTransport`]. Use this to route canopy calls through something
166	/// other than the default [`ReqwestTransport`], e.g. a proxy that speaks a
167	/// dialect of its own, or a stub in tests.
168	pub fn with_transport(transport: T) -> Self {
169		Self { transport }
170	}
171
172	/// The transport this client sends through.
173	pub fn transport(&self) -> &T {
174		&self.transport
175	}
176
177	/// Send a request to `path` through the transport, gzipping the JSON body
178	/// when there is one.
179	///
180	/// A non-success status becomes a [`CanopyHttpError`] (downcast the returned
181	/// report to inspect the status — e.g. [`TargetOutcome::from_result`]). This
182	/// is the shared core behind the generated endpoint methods.
183	///
184	/// [`TargetOutcome::from_result`]: crate::TargetOutcome::from_result
185	async fn send_call<B: serde::Serialize + ?Sized>(
186		&self,
187		method: reqwest::Method,
188		path: &str,
189		body: Option<&B>,
190	) -> Result<CanopyResponse> {
191		let mut request = http::Request::builder().method(method).uri(path);
192		let body = match body {
193			Some(body) => {
194				let raw = serde_json::to_vec(body)
195					.into_diagnostic()
196					.wrap_err_with(|| format!("serialising canopy {path} body"))?;
197				let compressed = gzip_bytes(&raw)
198					.into_diagnostic()
199					.wrap_err_with(|| format!("gzipping canopy {path} body"))?;
200				request = request
201					.header(reqwest::header::CONTENT_TYPE, "application/json")
202					.header(reqwest::header::CONTENT_ENCODING, "gzip");
203				Bytes::from(compressed)
204			}
205			None => Bytes::new(),
206		};
207
208		let request = request
209			.body(body)
210			.into_diagnostic()
211			.wrap_err_with(|| format!("building canopy {path} request"))?;
212
213		let response = self
214			.transport
215			.call(request)
216			.await
217			.wrap_err_with(|| format!("calling canopy {path}"))?;
218
219		let status = response.status();
220		if !status.is_success() {
221			return Err(miette::Report::new(CanopyHttpError {
222				status,
223				path: path.to_owned(),
224				body: String::from_utf8_lossy(response.body()).into_owned(),
225			}));
226		}
227		Ok(response)
228	}
229
230	/// Call an endpoint and parse its JSON response. Backs the generated methods.
231	pub(crate) async fn call_json<B, R>(
232		&self,
233		method: reqwest::Method,
234		path: &str,
235		body: Option<&B>,
236	) -> Result<R>
237	where
238		B: serde::Serialize + ?Sized,
239		R: serde::de::DeserializeOwned,
240	{
241		let response = self.send_call(method, path, body).await?;
242		serde_json::from_slice(response.body())
243			.into_diagnostic()
244			.wrap_err_with(|| format!("parsing canopy {path} response"))
245	}
246
247	/// Call an endpoint that returns no body. Backs the generated methods.
248	pub(crate) async fn call_empty<B: serde::Serialize + ?Sized>(
249		&self,
250		method: reqwest::Method,
251		path: &str,
252		body: Option<&B>,
253	) -> Result<()> {
254		self.send_call(method, path, body).await.map(drop)
255	}
256
257	/// Call an arbitrary canopy endpoint and parse its JSON response.
258	///
259	/// Escape hatch behind the generated endpoint methods; needs the `raw-requests`
260	/// feature. Prefer a generated method where one exists. When passing no body,
261	/// pin the inference with a turbofish, e.g. `None::<&()>`. The body is gzipped,
262	/// like every canopy request.
263	#[cfg(feature = "raw-requests")]
264	pub async fn request_json<Res: serde::de::DeserializeOwned>(
265		&self,
266		method: reqwest::Method,
267		path: &str,
268		body: Option<&(impl serde::Serialize + ?Sized)>,
269	) -> Result<Res> {
270		self.call_json(method, path, body).await
271	}
272}
273
274fn gzip_bytes(bytes: &[u8]) -> std::io::Result<Vec<u8>> {
275	let mut encoder = GzEncoder::new(Vec::with_capacity(bytes.len() / 2), Compression::default());
276	encoder.write_all(bytes)?;
277	encoder.finish()
278}
279
280#[cfg(test)]
281mod tests {
282	use std::sync::Mutex;
283
284	use crate::{
285		DEFAULT_CANOPY_URL,
286		test_support::{closed_url, serve_once},
287		transport::CanopyRequest,
288	};
289
290	use super::*;
291
292	fn mtls_client_against(base: &str) -> CanopyClient {
293		CanopyClient::with_transport(ReqwestTransport::mtls_for_tests(base))
294	}
295
296	#[derive(Debug, serde::Deserialize, PartialEq)]
297	struct Echo {
298		ok: bool,
299		who: String,
300	}
301
302	/// The default-transport constructor and the auth-path methods that delegate
303	/// to it; what the transport itself decides is covered in `reqwest_transport`.
304	#[tokio::test]
305	async fn with_urls_builds_on_the_default_transport() {
306		let (tailnet, _server) = serve_once("HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\n[]");
307		let client = CanopyClient::with_urls(
308			DEFAULT_CANOPY_URL.parse().unwrap(),
309			tailnet.parse().unwrap(),
310			None,
311			reqwest::Client::builder,
312		)
313		.await
314		.expect("keyless build should not error")
315		.expect("a reachable tailnet is an auth path in its own right");
316		assert!(client.is_tailscale().await);
317		client.renew().await.expect("renew should be a no-op");
318	}
319
320	#[tokio::test]
321	async fn with_urls_yields_no_client_without_an_auth_path() {
322		let client = CanopyClient::with_urls(
323			DEFAULT_CANOPY_URL.parse().unwrap(),
324			closed_url().parse().unwrap(),
325			None,
326			reqwest::Client::builder,
327		)
328		.await
329		.expect("keyless build should not error");
330		assert!(client.is_none());
331	}
332
333	#[tokio::test]
334	async fn call_json_gzips_body_sets_user_agent_and_parses_response() {
335		let (base, handle) = serve_once(
336			"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: 26\r\n\r\n{\"ok\":true,\"who\":\"device\"}",
337		);
338		let client = mtls_client_against(&base);
339
340		let payload = serde_json::json!({ "hello": "world" });
341		let got: Echo = client
342			.call_json(reqwest::Method::POST, "/thing", Some(&payload))
343			.await
344			.expect("call_json should succeed");
345
346		assert_eq!(
347			got,
348			Echo {
349				ok: true,
350				who: "device".into()
351			}
352		);
353
354		let captured = handle.join().unwrap();
355		assert!(
356			captured.request_line.starts_with("POST /thing "),
357			"unexpected request line: {}",
358			captured.request_line
359		);
360		let headers = captured.headers.to_ascii_lowercase();
361		assert!(
362			headers.contains("user-agent: bestool-canopy/"),
363			"missing canopy user-agent in:\n{}",
364			captured.headers
365		);
366		assert!(
367			headers.contains("content-encoding: gzip"),
368			"body should be gzipped:\n{}",
369			captured.headers
370		);
371		// The body is gzipped on the wire; decompress before comparing.
372		let sent: serde_json::Value = serde_json::from_slice(&gunzip(&captured.body)).unwrap();
373		assert_eq!(sent, payload);
374	}
375
376	#[tokio::test]
377	async fn call_json_errors_on_non_success_with_body() {
378		let (base, handle) =
379			serve_once("HTTP/1.1 418 I'm a teapot\r\nContent-Length: 14\r\n\r\nno coffee here");
380		let client = mtls_client_against(&base);
381
382		let err = client
383			.call_json::<(), serde_json::Value>(reqwest::Method::GET, "/brew", None::<&()>)
384			.await
385			.expect_err("non-2xx should error");
386		let msg = err.to_string();
387		assert!(msg.contains("/brew"), "expected path in error: {msg}");
388		assert!(msg.contains("418"), "expected status in error: {msg}");
389		assert!(
390			msg.contains("no coffee here"),
391			"expected body text in error: {msg}"
392		);
393
394		handle.join().unwrap();
395	}
396
397	/// A transport that records what it was handed and replays a canned response,
398	/// standing in for a caller's own (proxying, in-process, …) implementation.
399	#[derive(Default)]
400	struct StubTransport {
401		seen: Mutex<Vec<CanopyRequest>>,
402		response: Option<CanopyResponse>,
403	}
404
405	impl StubTransport {
406		fn responding(status: u16, body: &str) -> Self {
407			Self {
408				seen: Mutex::default(),
409				response: Some(
410					http::Response::builder()
411						.status(status)
412						.body(Bytes::copy_from_slice(body.as_bytes()))
413						.unwrap(),
414				),
415			}
416		}
417
418		fn took(&self) -> Vec<CanopyRequest> {
419			std::mem::take(&mut *self.seen.lock().unwrap())
420		}
421	}
422
423	#[async_trait::async_trait]
424	impl CanopyTransport for StubTransport {
425		async fn call(&self, request: CanopyRequest) -> Result<CanopyResponse> {
426			self.seen.lock().unwrap().push(request);
427			match &self.response {
428				Some(response) => {
429					let mut clone = http::Response::new(response.body().clone());
430					*clone.status_mut() = response.status();
431					*clone.headers_mut() = response.headers().clone();
432					Ok(clone)
433				}
434				None => Err(miette::miette!("this transport cannot reach canopy")),
435			}
436		}
437	}
438
439	#[tokio::test]
440	async fn a_custom_transport_carries_the_typed_calls() {
441		let client = CanopyClient::with_transport(StubTransport::responding(
442			200,
443			r#"{"ok":true,"who":"stub"}"#,
444		));
445
446		let payload = serde_json::json!({ "hello": "world" });
447		let got: Echo = client
448			.call_json(reqwest::Method::POST, "/thing", Some(&payload))
449			.await
450			.expect("the typed machinery should run on any transport");
451		assert_eq!(
452			got,
453			Echo {
454				ok: true,
455				who: "stub".into()
456			}
457		);
458
459		let seen = client.transport().took();
460		let [request] = &seen[..] else {
461			panic!("expected exactly one request, got {}", seen.len());
462		};
463		assert_eq!(request.method(), reqwest::Method::POST);
464		// The transport resolves the path against whatever base it uses, so it's
465		// handed the endpoint path on its own.
466		assert_eq!(request.uri(), "/thing");
467		assert_eq!(
468			request.headers().get(reqwest::header::CONTENT_ENCODING),
469			Some(&reqwest::header::HeaderValue::from_static("gzip"))
470		);
471		assert_eq!(
472			serde_json::from_slice::<serde_json::Value>(&gunzip(request.body())).unwrap(),
473			payload
474		);
475	}
476
477	#[tokio::test]
478	async fn a_custom_transport_sees_generated_endpoint_methods() {
479		// A generated method (`GET /servers`) over a caller-supplied transport:
480		// path, verb, and the empty body all come from the OpenAPI document.
481		let client = CanopyClient::with_transport(StubTransport::responding(200, "[]"));
482		let servers = client
483			.servers()
484			.await
485			.expect("generated methods work on any transport");
486		assert!(servers.is_empty());
487
488		let seen = client.transport().took();
489		let [request] = &seen[..] else {
490			panic!("expected exactly one request, got {}", seen.len());
491		};
492		assert_eq!(request.method(), reqwest::Method::GET);
493		assert_eq!(request.uri(), "/servers");
494		assert!(request.body().is_empty(), "a GET should carry no body");
495		assert!(
496			request
497				.headers()
498				.get(reqwest::header::CONTENT_TYPE)
499				.is_none(),
500			"a bodyless request shouldn't claim a content type"
501		);
502	}
503
504	#[tokio::test]
505	async fn a_custom_transport_maps_non_success_to_canopy_http_error() {
506		let client =
507			CanopyClient::with_transport(StubTransport::responding(412, "device is dormant"));
508		let err = client
509			.backup_target()
510			.await
511			.expect_err("412 is not a success");
512		let http_err = err
513			.downcast_ref::<CanopyHttpError>()
514			.expect("non-2xx from any transport surfaces as CanopyHttpError");
515		assert_eq!(http_err.status, reqwest::StatusCode::PRECONDITION_FAILED);
516		assert_eq!(http_err.path, "/backup-target");
517		assert_eq!(http_err.body, "device is dormant");
518	}
519
520	#[tokio::test]
521	async fn a_custom_transport_error_is_reported_with_the_path() {
522		let client = CanopyClient::with_transport(StubTransport::default());
523		let err = client.tags().await.expect_err("the stub always fails");
524		let chain = format!("{err:?}");
525		assert!(chain.contains("/tags"), "expected path in report: {chain}");
526		assert!(
527			chain.contains("cannot reach canopy"),
528			"expected the transport's own error in report: {chain}"
529		);
530	}
531
532	#[tokio::test]
533	async fn a_boxed_transport_is_a_transport() {
534		// So a caller can pick a transport at runtime without naming its type.
535		let client: CanopyClient<Box<dyn CanopyTransport>> =
536			CanopyClient::with_transport(Box::new(StubTransport::responding(200, "[]")));
537		assert!(client.servers().await.unwrap().is_empty());
538	}
539
540	fn gunzip(bytes: &[u8]) -> Vec<u8> {
541		use flate2::read::GzDecoder;
542		use std::io::Read as _;
543
544		let mut out = Vec::new();
545		GzDecoder::new(bytes)
546			.read_to_end(&mut out)
547			.expect("body should be valid gzip");
548		out
549	}
550
551	#[test]
552	fn gzip_bytes_roundtrips() {
553		let original = br#"{"health":[{"check":"x","result":"passed"}]}"#;
554		let compressed = gzip_bytes(original).expect("gzip should succeed");
555		assert!(
556			compressed.starts_with(&[0x1f, 0x8b]),
557			"expected gzip magic bytes"
558		);
559		assert_eq!(gunzip(&compressed), original);
560	}
561}