1use std::{
2 fmt,
3 io::Write,
4 net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr},
5 sync::{Arc, OnceLock},
6 time::Duration,
7};
8
9use flate2::{Compression, write::GzEncoder};
10use hickory_resolver::{
11 ConnectionProvider, Resolver,
12 config::{ConnectionConfig, NameServerConfig, ResolverConfig},
13 net::runtime::TokioRuntimeProvider,
14};
15use jiff::Timestamp;
16use miette::{IntoDiagnostic, Result, WrapErr};
17use rcgen::{CertificateParams, DistinguishedName, DnType, KeyPair};
18use reqwest::Url;
19use serde::{Deserialize, Serialize};
20use time::{Duration as TimeDuration, OffsetDateTime};
21use tokio::sync::RwLock;
22use tracing::debug;
23
24use crate::Redacted;
25
26pub const DEFAULT_CANOPY_URL: &str = "https://meta.tamanu.app";
27
28pub const TAILSCALE_URL: &str = "https://canopy.tail53aef.ts.net";
33
34const TAILSCALE_HOST: &str = "canopy.tail53aef.ts.net";
36
37const CANOPY_HARDCODED_V4: Ipv4Addr = Ipv4Addr::new(100, 99, 98, 97);
40const CANOPY_HARDCODED_V6: Ipv6Addr =
41 Ipv6Addr::new(0xfd7a, 0x115c, 0xa1e0, 0, 0, 0, 0x9337, 0xfb52);
42
43const CERT_VALIDITY_DAYS: i64 = 6;
48
49pub const CERT_RENEW_AFTER: Duration = Duration::from_secs(5 * 24 * 60 * 60);
54
55const TAILSCALE_PROBE_TIMEOUT: Duration = Duration::from_secs(5);
57
58pub type ClientBuilderFactory = Arc<dyn Fn() -> reqwest::ClientBuilder + Send + Sync>;
65
66pub fn user_agent(product: &str, version: &str) -> String {
71 static OS_COMMENT: OnceLock<String> = OnceLock::new();
72 let os_comment = OS_COMMENT.get_or_init(|| {
73 let os = sysinfo::System::long_os_version()
74 .or_else(sysinfo::System::name)
75 .unwrap_or_else(|| std::env::consts::OS.to_owned());
76 format!("{os}; {}", sysinfo::System::cpu_arch())
77 });
78 format!("{product}/{version} ({os_comment})")
79}
80
81pub fn client_builder(version: &str) -> reqwest::ClientBuilder {
86 reqwest::Client::builder().user_agent(user_agent("bestool", version))
87}
88
89#[derive(Copy, Clone, Debug, Eq, PartialEq, Deserialize, Serialize)]
91#[serde(rename_all = "lowercase")]
92pub enum Severity {
93 Emergency,
94 Alert,
95 Critical,
96 Error,
97 Warning,
98 Notice,
99 Info,
100 Debug,
101}
102
103#[derive(Debug, Clone, Serialize)]
105pub struct NewEvent<'a> {
106 pub source: &'a str,
107 #[serde(rename = "ref")]
108 pub r#ref: &'a str,
109 pub message: &'a str,
110 #[serde(skip_serializing_if = "Option::is_none")]
111 pub description: Option<&'a str>,
112 #[serde(skip_serializing_if = "Option::is_none")]
113 pub severity: Option<Severity>,
114 #[serde(rename = "occurredAt", skip_serializing_if = "Option::is_none")]
115 pub occurred_at: Option<Timestamp>,
116 #[serde(skip_serializing_if = "Option::is_none")]
117 pub active: Option<bool>,
118}
119
120pub struct CanopyClient {
131 device_key: Option<Redacted<String>>,
132 tamanu_version: String,
137 make_builder: ClientBuilderFactory,
139 state: RwLock<State>,
140}
141
142enum State {
143 Tailscale(reqwest::Client),
144 Mtls(reqwest::Client),
145}
146
147impl State {
148 fn is_tailscale(&self) -> bool {
149 matches!(self, State::Tailscale(_))
150 }
151
152 fn http(&self) -> reqwest::Client {
153 match self {
154 State::Tailscale(http) | State::Mtls(http) => http.clone(),
155 }
156 }
157}
158
159impl fmt::Debug for CanopyClient {
160 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
161 f.debug_struct("CanopyClient").finish_non_exhaustive()
162 }
163}
164
165impl CanopyClient {
166 pub async fn new(
178 tamanu_version: impl Into<String>,
179 device_key_pem: Option<&str>,
180 make_builder: impl Fn() -> reqwest::ClientBuilder + Send + Sync + 'static,
181 ) -> Result<Option<Self>> {
182 let tamanu_version = tamanu_version.into();
183 let device_key = device_key_pem.map(|s| Redacted(s.to_owned()));
184 let make_builder: ClientBuilderFactory = Arc::new(make_builder);
185
186 if let Some(http) = probe_tailscale(&make_builder).await {
187 debug!("canopy: tailscale endpoint reachable, preferring it");
188 return Ok(Some(Self {
189 device_key,
190 tamanu_version,
191 make_builder,
192 state: RwLock::new(State::Tailscale(http)),
193 }));
194 }
195
196 if let Some(pem) = device_key_pem {
197 debug!("canopy: tailscale unreachable, falling back to mTLS");
198 let http = build_mtls_http(&make_builder, pem)?;
199 return Ok(Some(Self {
200 device_key,
201 tamanu_version,
202 make_builder,
203 state: RwLock::new(State::Mtls(http)),
204 }));
205 }
206
207 Ok(None)
208 }
209
210 pub async fn is_tailscale(&self) -> bool {
212 self.state.read().await.is_tailscale()
213 }
214
215 pub async fn refresh(&self) -> Result<()> {
219 if let Some(http) = probe_tailscale(&self.make_builder).await {
220 let mut state = self.state.write().await;
221 if !state.is_tailscale() {
222 debug!("canopy refresh: switching to tailscale path");
223 }
224 *state = State::Tailscale(http);
225 return Ok(());
226 }
227
228 if let Some(pem) = &self.device_key {
229 let http = build_mtls_http(&self.make_builder, &pem.0)?;
230 let mut state = self.state.write().await;
231 if state.is_tailscale() {
232 debug!("canopy refresh: tailscale dropped, falling back to mTLS");
233 }
234 *state = State::Mtls(http);
235 return Ok(());
236 }
237
238 debug!("canopy refresh: no auth path available, keeping current state");
239 Ok(())
240 }
241
242 pub async fn renew(&self) -> Result<()> {
248 let Some(pem) = &self.device_key else {
249 return Ok(());
250 };
251 let mut state = self.state.write().await;
252 if state.is_tailscale() {
253 return Ok(());
254 }
255 *state = State::Mtls(build_mtls_http(&self.make_builder, &pem.0)?);
256 Ok(())
257 }
258
259 pub async fn post_status(
268 &self,
269 base_url: &Url,
270 server_id: &str,
271 payload: &serde_json::Value,
272 ) -> Result<()> {
273 let (http, url) = {
274 let state = self.state.read().await;
275 let url = match &*state {
276 State::Tailscale(_) => format!("{TAILSCALE_URL}/public/status/{server_id}")
277 .parse::<Url>()
278 .into_diagnostic()
279 .wrap_err("building tailscale /public/status URL")?,
280 State::Mtls(_) => base_url
281 .join(&format!("/status/{server_id}"))
282 .into_diagnostic()
283 .wrap_err("building /status URL")?,
284 };
285 (state.http(), url)
286 };
287
288 let raw = serde_json::to_vec(payload)
289 .into_diagnostic()
290 .wrap_err("serialising canopy /status payload")?;
291 let compressed = gzip_bytes(&raw)
292 .into_diagnostic()
293 .wrap_err("gzipping canopy /status payload")?;
294
295 debug!(
296 %url,
297 raw_bytes = raw.len(),
298 gzip_bytes = compressed.len(),
299 "posting status snapshot to canopy",
300 );
301
302 let response = http
303 .post(url)
304 .header("X-Version", &self.tamanu_version)
305 .header(reqwest::header::CONTENT_TYPE, "application/json")
306 .header(reqwest::header::CONTENT_ENCODING, "gzip")
307 .body(compressed)
308 .send()
309 .await
310 .into_diagnostic()
311 .wrap_err("posting status to canopy")?;
312
313 let status = response.status();
314 if !status.is_success() {
315 let body = response.text().await.unwrap_or_default();
316 return Err(miette::miette!("canopy /status returned {status}: {body}"));
317 }
318
319 Ok(())
320 }
321
322 pub async fn get(
332 &self,
333 base_url: &Url,
334 tailscale_path: &str,
335 mtls_path: &str,
336 ) -> Result<reqwest::Response> {
337 let (http, url) = {
338 let state = self.state.read().await;
339 let url = match &*state {
340 State::Tailscale(_) => format!("{TAILSCALE_URL}{tailscale_path}")
341 .parse::<Url>()
342 .into_diagnostic()
343 .wrap_err("building tailscale GET URL")?,
344 State::Mtls(_) => base_url
345 .join(mtls_path)
346 .into_diagnostic()
347 .wrap_err("building mTLS GET URL")?,
348 };
349 (state.http(), url)
350 };
351
352 debug!(%url, "GET via canopy");
353 http.get(url)
354 .header("X-Version", &self.tamanu_version)
355 .send()
356 .await
357 .into_diagnostic()
358 .wrap_err("GET via canopy")
359 }
360
361 pub async fn post_event(&self, base_url: &Url, event: NewEvent<'_>) -> Result<()> {
366 let (http, url) = {
367 let state = self.state.read().await;
368 let url = match &*state {
369 State::Tailscale(_) => format!("{TAILSCALE_URL}/public/events")
370 .parse::<Url>()
371 .into_diagnostic()
372 .wrap_err("building tailscale /public/events URL")?,
373 State::Mtls(_) => base_url
374 .join("/events")
375 .into_diagnostic()
376 .wrap_err("building /events URL")?,
377 };
378 (state.http(), url)
379 };
380
381 debug!(
382 %url,
383 source = event.source,
384 r#ref = event.r#ref,
385 active = ?event.active,
386 "posting event to canopy"
387 );
388
389 let response = http
390 .post(url)
391 .header("X-Version", &self.tamanu_version)
392 .json(&event)
393 .send()
394 .await
395 .into_diagnostic()
396 .wrap_err("posting event to canopy")?;
397
398 let status = response.status();
399 if !status.is_success() {
400 let body = response.text().await.unwrap_or_default();
401 return Err(miette::miette!("canopy /events returned {status}: {body}"));
402 }
403
404 Ok(())
405 }
406}
407
408async fn probe_tailscale(make_builder: &ClientBuilderFactory) -> Option<reqwest::Client> {
425 let dns_addrs: Vec<SocketAddr> = tailscale_resolver()
426 .lookup_ip("canopy")
427 .await
428 .ok()
429 .map(|addrs| addrs.iter().map(|ip| SocketAddr::new(ip, 443)).collect())
430 .unwrap_or_default();
431 if !dns_addrs.is_empty()
432 && let Some(client) = try_probe(&dns_addrs, make_builder).await
433 {
434 return Some(client);
435 }
436
437 let hardcoded = [
438 SocketAddr::new(IpAddr::V4(CANOPY_HARDCODED_V4), 443),
439 SocketAddr::new(IpAddr::V6(CANOPY_HARDCODED_V6), 443),
440 ];
441 debug!(
442 ?hardcoded,
443 "canopy tailscale DNS lookup empty or probe failed, trying hardcoded IPs"
444 );
445 try_probe(&hardcoded, make_builder).await
446}
447
448async fn try_probe(
449 addrs: &[SocketAddr],
450 make_builder: &ClientBuilderFactory,
451) -> Option<reqwest::Client> {
452 let client = make_builder()
453 .timeout(TAILSCALE_PROBE_TIMEOUT)
454 .resolve_to_addrs(TAILSCALE_HOST, addrs)
455 .build()
456 .ok()?;
457
458 let url = format!("{TAILSCALE_URL}/public/servers");
459 match client.get(&url).send().await {
460 Ok(resp) if resp.status().is_success() => Some(client),
461 Ok(resp) => {
462 debug!(status = %resp.status(), ?addrs, "canopy tailscale probe: unexpected status");
463 None
464 }
465 Err(err) => {
466 debug!(?addrs, "canopy tailscale probe failed: {err}");
467 None
468 }
469 }
470}
471
472fn tailscale_resolver() -> Resolver<impl ConnectionProvider> {
473 Resolver::builder_with_config(
474 ResolverConfig::from_parts(
475 None,
476 vec!["tail53aef.ts.net.".parse().unwrap()],
477 vec![NameServerConfig::new(
478 "100.100.100.100".parse().unwrap(),
479 true,
480 vec![ConnectionConfig::udp()],
481 )],
482 ),
483 TokioRuntimeProvider::default(),
484 )
485 .build()
486 .expect("tailscale resolver config is hardcoded and cannot fail to build")
487}
488
489fn gzip_bytes(bytes: &[u8]) -> std::io::Result<Vec<u8>> {
490 let mut encoder = GzEncoder::new(Vec::with_capacity(bytes.len() / 2), Compression::default());
491 encoder.write_all(bytes)?;
492 encoder.finish()
493}
494
495fn build_mtls_http(
496 make_builder: &ClientBuilderFactory,
497 device_key_pem: &str,
498) -> Result<reqwest::Client> {
499 let key_pair = KeyPair::from_pem(device_key_pem)
500 .into_diagnostic()
501 .wrap_err("parsing device key PEM")?;
502
503 let mut params = CertificateParams::new(vec!["device.local".into()])
504 .into_diagnostic()
505 .wrap_err("building certificate params")?;
506 params.distinguished_name = DistinguishedName::new();
507 params
508 .distinguished_name
509 .push(DnType::CommonName, "device.local");
510
511 let now = OffsetDateTime::now_utc();
512 params.not_before = now - TimeDuration::minutes(1);
513 params.not_after = now + TimeDuration::days(CERT_VALIDITY_DAYS);
514
515 let cert = params
516 .self_signed(&key_pair)
517 .into_diagnostic()
518 .wrap_err("self-signing certificate")?;
519
520 let mut combined = cert.pem();
521 combined.push('\n');
522 combined.push_str(&key_pair.serialize_pem());
523
524 let identity = reqwest::Identity::from_pem(combined.as_bytes())
525 .into_diagnostic()
526 .wrap_err("building reqwest TLS identity")?;
527
528 make_builder()
529 .identity(identity)
530 .use_rustls_tls()
531 .timeout(Duration::from_secs(30))
532 .build()
533 .into_diagnostic()
534 .wrap_err("building canopy HTTP client")
535}
536
537#[cfg(test)]
538mod tests {
539 use super::*;
540
541 const TEST_DEVICE_KEY: &str = "\
542-----BEGIN PRIVATE KEY-----
543MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgVvhzsYiidp38GYn1
544KxD5Wipc/h8lglVsy1UFZq/SZbGhRANCAAT2EsEq7xjeWVnim9XwdYXga/LBbppm
545fXLgamTYOa/w9n/Ta64fiYWmN54kEd0DgnflJDLtID321Zz6xswvK/VN
546-----END PRIVATE KEY-----";
547
548 fn test_factory() -> ClientBuilderFactory {
549 Arc::new(reqwest::Client::builder)
550 }
551
552 #[test]
553 fn build_mtls_http_from_p256_key() {
554 let result = build_mtls_http(&test_factory(), TEST_DEVICE_KEY);
556 assert!(result.is_ok(), "{:?}", result.err());
557 }
558
559 #[test]
560 fn build_mtls_http_fails_on_garbage_key() {
561 assert!(build_mtls_http(&test_factory(), "not a real PEM").is_err());
562 }
563
564 #[tokio::test]
565 async fn renew_with_mtls_state_swaps_in_fresh_client() {
566 let http = build_mtls_http(&test_factory(), TEST_DEVICE_KEY).unwrap();
568 let client = CanopyClient {
569 device_key: Some(Redacted(TEST_DEVICE_KEY.to_owned())),
570 tamanu_version: "2.54.2".into(),
571 make_builder: test_factory(),
572 state: RwLock::new(State::Mtls(http)),
573 };
574 client.renew().await.expect("renew should succeed");
575 assert!(!client.is_tailscale().await);
576 }
577
578 #[tokio::test]
579 async fn renew_is_noop_in_tailscale_mode() {
580 let http = reqwest::Client::new();
582 let client = CanopyClient {
583 device_key: None,
584 tamanu_version: "2.54.2".into(),
585 make_builder: test_factory(),
586 state: RwLock::new(State::Tailscale(http)),
587 };
588 client.renew().await.expect("renew should be a no-op");
589 assert!(client.is_tailscale().await);
590 }
591
592 #[test]
593 fn user_agent_has_product_and_os_comment() {
594 let ua = user_agent("bestool", "1.2.3");
595 assert!(
596 ua.starts_with("bestool/1.2.3 "),
597 "unexpected user-agent: {ua}"
598 );
599 assert!(ua.contains('('), "expected OS comment in: {ua}");
600 assert!(ua.ends_with(')'), "expected OS comment in: {ua}");
601 assert!(
602 ua.contains(sysinfo::System::cpu_arch().as_str()),
603 "expected arch in: {ua}"
604 );
605 }
606
607 #[test]
608 fn gzip_bytes_roundtrips() {
609 use flate2::read::GzDecoder;
610 use std::io::Read;
611
612 let original = br#"{"healthy":true,"health":[{"check":"x","healthy":true}]}"#;
613 let compressed = gzip_bytes(original).expect("gzip should succeed");
614 assert!(
615 compressed.starts_with(&[0x1f, 0x8b]),
616 "expected gzip magic bytes"
617 );
618 let mut decoder = GzDecoder::new(&compressed[..]);
619 let mut decompressed = Vec::new();
620 decoder.read_to_end(&mut decompressed).unwrap();
621 assert_eq!(decompressed, original);
622 }
623
624 #[test]
625 fn severity_serialises_lowercase() {
626 assert_eq!(
627 serde_json::to_string(&Severity::Warning).unwrap(),
628 "\"warning\""
629 );
630 assert_eq!(
631 serde_json::to_string(&Severity::Emergency).unwrap(),
632 "\"emergency\""
633 );
634 }
635
636 #[test]
637 fn new_event_omits_optional_fields() {
638 let evt = NewEvent {
639 source: "src",
640 r#ref: "host/alert:tgt",
641 message: "msg",
642 description: None,
643 severity: None,
644 occurred_at: None,
645 active: None,
646 };
647 let json = serde_json::to_string(&evt).unwrap();
648 assert!(json.contains("\"source\":\"src\""));
649 assert!(json.contains("\"ref\":\"host/alert:tgt\""));
650 assert!(json.contains("\"message\":\"msg\""));
651 assert!(!json.contains("description"));
652 assert!(!json.contains("severity"));
653 assert!(!json.contains("occurredAt"));
654 assert!(!json.contains("active"));
655 }
656
657 #[test]
658 fn new_event_serialises_occurred_at_as_camel_case() {
659 let evt = NewEvent {
660 source: "src",
661 r#ref: "ref",
662 message: "msg",
663 description: Some("desc"),
664 severity: Some(Severity::Warning),
665 occurred_at: Some("2025-01-01T00:00:00Z".parse().unwrap()),
666 active: Some(true),
667 };
668 let json = serde_json::to_string(&evt).unwrap();
669 assert!(json.contains("\"occurredAt\":"));
670 assert!(json.contains("\"description\":\"desc\""));
671 assert!(json.contains("\"severity\":\"warning\""));
672 assert!(json.contains("\"active\":true"));
673 }
674}