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
89pub async fn tailscale_client(make_builder: &ClientBuilderFactory) -> Option<reqwest::Client> {
98 probe_tailscale(make_builder).await
99}
100
101#[derive(Copy, Clone, Debug, Eq, PartialEq, Deserialize, Serialize)]
107#[serde(rename_all = "lowercase")]
108pub enum Severity {
109 Critical,
110 Error,
111 Warning,
112 Info,
113 Debug,
114}
115
116#[derive(Debug, Clone, Serialize)]
118pub struct NewEvent<'a> {
119 pub source: &'a str,
120 #[serde(rename = "ref")]
121 pub r#ref: &'a str,
122 pub message: &'a str,
123 #[serde(skip_serializing_if = "Option::is_none")]
124 pub description: Option<&'a str>,
125 #[serde(skip_serializing_if = "Option::is_none")]
126 pub severity: Option<Severity>,
127 #[serde(rename = "occurredAt", skip_serializing_if = "Option::is_none")]
128 pub occurred_at: Option<Timestamp>,
129 #[serde(skip_serializing_if = "Option::is_none")]
130 pub active: Option<bool>,
131}
132
133pub struct CanopyClient {
144 device_key: Option<Redacted<String>>,
145 tamanu_version: String,
150 make_builder: ClientBuilderFactory,
152 state: RwLock<State>,
153}
154
155enum State {
156 Tailscale(reqwest::Client),
157 Mtls(reqwest::Client),
158}
159
160impl State {
161 fn is_tailscale(&self) -> bool {
162 matches!(self, State::Tailscale(_))
163 }
164
165 fn http(&self) -> reqwest::Client {
166 match self {
167 State::Tailscale(http) | State::Mtls(http) => http.clone(),
168 }
169 }
170}
171
172impl fmt::Debug for CanopyClient {
173 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
174 f.debug_struct("CanopyClient").finish_non_exhaustive()
175 }
176}
177
178impl CanopyClient {
179 pub async fn new(
191 tamanu_version: impl Into<String>,
192 device_key_pem: Option<&str>,
193 make_builder: impl Fn() -> reqwest::ClientBuilder + Send + Sync + 'static,
194 ) -> Result<Option<Self>> {
195 let tamanu_version = tamanu_version.into();
196 let device_key = device_key_pem.map(|s| Redacted(s.to_owned()));
197 let make_builder: ClientBuilderFactory = Arc::new(make_builder);
198
199 if let Some(http) = probe_tailscale(&make_builder).await {
200 debug!("canopy: tailscale endpoint reachable, preferring it");
201 return Ok(Some(Self {
202 device_key,
203 tamanu_version,
204 make_builder,
205 state: RwLock::new(State::Tailscale(http)),
206 }));
207 }
208
209 if let Some(pem) = device_key_pem {
210 debug!("canopy: tailscale unreachable, falling back to mTLS");
211 let http = build_mtls_http(&make_builder, pem)?;
212 return Ok(Some(Self {
213 device_key,
214 tamanu_version,
215 make_builder,
216 state: RwLock::new(State::Mtls(http)),
217 }));
218 }
219
220 Ok(None)
221 }
222
223 pub async fn is_tailscale(&self) -> bool {
225 self.state.read().await.is_tailscale()
226 }
227
228 pub async fn refresh(&self) -> Result<()> {
232 if let Some(http) = probe_tailscale(&self.make_builder).await {
233 let mut state = self.state.write().await;
234 if !state.is_tailscale() {
235 debug!("canopy refresh: switching to tailscale path");
236 }
237 *state = State::Tailscale(http);
238 return Ok(());
239 }
240
241 if let Some(pem) = &self.device_key {
242 let http = build_mtls_http(&self.make_builder, &pem.0)?;
243 let mut state = self.state.write().await;
244 if state.is_tailscale() {
245 debug!("canopy refresh: tailscale dropped, falling back to mTLS");
246 }
247 *state = State::Mtls(http);
248 return Ok(());
249 }
250
251 debug!("canopy refresh: no auth path available, keeping current state");
252 Ok(())
253 }
254
255 pub async fn renew(&self) -> Result<()> {
261 let Some(pem) = &self.device_key else {
262 return Ok(());
263 };
264 let mut state = self.state.write().await;
265 if state.is_tailscale() {
266 return Ok(());
267 }
268 *state = State::Mtls(build_mtls_http(&self.make_builder, &pem.0)?);
269 Ok(())
270 }
271
272 pub async fn post_status(
281 &self,
282 base_url: &Url,
283 server_id: &str,
284 payload: &serde_json::Value,
285 ) -> Result<()> {
286 let (http, url) = {
287 let state = self.state.read().await;
288 let url = match &*state {
289 State::Tailscale(_) => format!("{TAILSCALE_URL}/public/status/{server_id}")
290 .parse::<Url>()
291 .into_diagnostic()
292 .wrap_err("building tailscale /public/status URL")?,
293 State::Mtls(_) => base_url
294 .join(&format!("/status/{server_id}"))
295 .into_diagnostic()
296 .wrap_err("building /status URL")?,
297 };
298 (state.http(), url)
299 };
300
301 let raw = serde_json::to_vec(payload)
302 .into_diagnostic()
303 .wrap_err("serialising canopy /status payload")?;
304 let compressed = gzip_bytes(&raw)
305 .into_diagnostic()
306 .wrap_err("gzipping canopy /status payload")?;
307
308 debug!(
309 %url,
310 raw_bytes = raw.len(),
311 gzip_bytes = compressed.len(),
312 "posting status snapshot to canopy",
313 );
314
315 let response = http
316 .post(url)
317 .header("X-Version", &self.tamanu_version)
318 .header(reqwest::header::CONTENT_TYPE, "application/json")
319 .header(reqwest::header::CONTENT_ENCODING, "gzip")
320 .body(compressed)
321 .send()
322 .await
323 .into_diagnostic()
324 .wrap_err("posting status to canopy")?;
325
326 let status = response.status();
327 if !status.is_success() {
328 let body = response.text().await.unwrap_or_default();
329 return Err(miette::miette!("canopy /status returned {status}: {body}"));
330 }
331
332 Ok(())
333 }
334
335 pub async fn get(
345 &self,
346 base_url: &Url,
347 tailscale_path: &str,
348 mtls_path: &str,
349 ) -> Result<reqwest::Response> {
350 let (http, url) = {
351 let state = self.state.read().await;
352 let url = match &*state {
353 State::Tailscale(_) => format!("{TAILSCALE_URL}{tailscale_path}")
354 .parse::<Url>()
355 .into_diagnostic()
356 .wrap_err("building tailscale GET URL")?,
357 State::Mtls(_) => base_url
358 .join(mtls_path)
359 .into_diagnostic()
360 .wrap_err("building mTLS GET URL")?,
361 };
362 (state.http(), url)
363 };
364
365 debug!(%url, "GET via canopy");
366 http.get(url)
367 .header("X-Version", &self.tamanu_version)
368 .send()
369 .await
370 .into_diagnostic()
371 .wrap_err("GET via canopy")
372 }
373
374 pub async fn post_event(&self, base_url: &Url, event: NewEvent<'_>) -> Result<()> {
379 let (http, url) = {
380 let state = self.state.read().await;
381 let url = match &*state {
382 State::Tailscale(_) => format!("{TAILSCALE_URL}/public/events")
383 .parse::<Url>()
384 .into_diagnostic()
385 .wrap_err("building tailscale /public/events URL")?,
386 State::Mtls(_) => base_url
387 .join("/events")
388 .into_diagnostic()
389 .wrap_err("building /events URL")?,
390 };
391 (state.http(), url)
392 };
393
394 debug!(
395 %url,
396 source = event.source,
397 r#ref = event.r#ref,
398 active = ?event.active,
399 "posting event to canopy"
400 );
401
402 let response = http
403 .post(url)
404 .header("X-Version", &self.tamanu_version)
405 .json(&event)
406 .send()
407 .await
408 .into_diagnostic()
409 .wrap_err("posting event to canopy")?;
410
411 let status = response.status();
412 if !status.is_success() {
413 let body = response.text().await.unwrap_or_default();
414 return Err(miette::miette!("canopy /events returned {status}: {body}"));
415 }
416
417 Ok(())
418 }
419}
420
421async fn probe_tailscale(make_builder: &ClientBuilderFactory) -> Option<reqwest::Client> {
438 let dns_addrs: Vec<SocketAddr> = tailscale_resolver()
439 .lookup_ip("canopy")
440 .await
441 .ok()
442 .map(|addrs| addrs.iter().map(|ip| SocketAddr::new(ip, 443)).collect())
443 .unwrap_or_default();
444 if !dns_addrs.is_empty()
445 && let Some(client) = try_probe(&dns_addrs, make_builder).await
446 {
447 return Some(client);
448 }
449
450 let hardcoded = [
451 SocketAddr::new(IpAddr::V4(CANOPY_HARDCODED_V4), 443),
452 SocketAddr::new(IpAddr::V6(CANOPY_HARDCODED_V6), 443),
453 ];
454 debug!(
455 ?hardcoded,
456 "canopy tailscale DNS lookup empty or probe failed, trying hardcoded IPs"
457 );
458 try_probe(&hardcoded, make_builder).await
459}
460
461async fn try_probe(
462 addrs: &[SocketAddr],
463 make_builder: &ClientBuilderFactory,
464) -> Option<reqwest::Client> {
465 let client = make_builder()
466 .timeout(TAILSCALE_PROBE_TIMEOUT)
467 .resolve_to_addrs(TAILSCALE_HOST, addrs)
468 .build()
469 .ok()?;
470
471 let url = format!("{TAILSCALE_URL}/public/servers");
472 match client.get(&url).send().await {
473 Ok(resp) if resp.status().is_success() => Some(client),
474 Ok(resp) => {
475 debug!(status = %resp.status(), ?addrs, "canopy tailscale probe: unexpected status");
476 None
477 }
478 Err(err) => {
479 debug!(?addrs, "canopy tailscale probe failed: {err}");
480 None
481 }
482 }
483}
484
485fn tailscale_resolver() -> Resolver<impl ConnectionProvider> {
486 Resolver::builder_with_config(
487 ResolverConfig::from_parts(
488 None,
489 vec!["tail53aef.ts.net.".parse().unwrap()],
490 vec![NameServerConfig::new(
491 "100.100.100.100".parse().unwrap(),
492 true,
493 vec![ConnectionConfig::udp()],
494 )],
495 ),
496 TokioRuntimeProvider::default(),
497 )
498 .build()
499 .expect("tailscale resolver config is hardcoded and cannot fail to build")
500}
501
502fn gzip_bytes(bytes: &[u8]) -> std::io::Result<Vec<u8>> {
503 let mut encoder = GzEncoder::new(Vec::with_capacity(bytes.len() / 2), Compression::default());
504 encoder.write_all(bytes)?;
505 encoder.finish()
506}
507
508pub fn device_identity(device_key_pem: &str) -> Result<reqwest::Identity> {
517 let key_pair = KeyPair::from_pem(device_key_pem)
518 .into_diagnostic()
519 .wrap_err("parsing device key PEM")?;
520
521 let mut params = CertificateParams::new(vec!["device.local".into()])
522 .into_diagnostic()
523 .wrap_err("building certificate params")?;
524 params.distinguished_name = DistinguishedName::new();
525 params
526 .distinguished_name
527 .push(DnType::CommonName, "device.local");
528
529 let now = OffsetDateTime::now_utc();
530 params.not_before = now - TimeDuration::minutes(1);
531 params.not_after = now + TimeDuration::days(CERT_VALIDITY_DAYS);
532
533 let cert = params
534 .self_signed(&key_pair)
535 .into_diagnostic()
536 .wrap_err("self-signing certificate")?;
537
538 let mut combined = cert.pem();
539 combined.push('\n');
540 combined.push_str(&key_pair.serialize_pem());
541
542 reqwest::Identity::from_pem(combined.as_bytes())
543 .into_diagnostic()
544 .wrap_err("building reqwest TLS identity")
545}
546
547fn build_mtls_http(
548 make_builder: &ClientBuilderFactory,
549 device_key_pem: &str,
550) -> Result<reqwest::Client> {
551 let identity = device_identity(device_key_pem)?;
552
553 make_builder()
554 .identity(identity)
555 .use_rustls_tls()
556 .timeout(Duration::from_secs(30))
557 .build()
558 .into_diagnostic()
559 .wrap_err("building canopy HTTP client")
560}
561
562#[cfg(test)]
563mod tests {
564 use super::*;
565
566 const TEST_DEVICE_KEY: &str = "\
567-----BEGIN PRIVATE KEY-----
568MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgVvhzsYiidp38GYn1
569KxD5Wipc/h8lglVsy1UFZq/SZbGhRANCAAT2EsEq7xjeWVnim9XwdYXga/LBbppm
570fXLgamTYOa/w9n/Ta64fiYWmN54kEd0DgnflJDLtID321Zz6xswvK/VN
571-----END PRIVATE KEY-----";
572
573 fn test_factory() -> ClientBuilderFactory {
574 Arc::new(reqwest::Client::builder)
575 }
576
577 #[test]
578 fn build_mtls_http_from_p256_key() {
579 let result = build_mtls_http(&test_factory(), TEST_DEVICE_KEY);
581 assert!(result.is_ok(), "{:?}", result.err());
582 }
583
584 #[test]
585 fn build_mtls_http_fails_on_garbage_key() {
586 assert!(build_mtls_http(&test_factory(), "not a real PEM").is_err());
587 }
588
589 #[tokio::test]
590 async fn renew_with_mtls_state_swaps_in_fresh_client() {
591 let http = build_mtls_http(&test_factory(), TEST_DEVICE_KEY).unwrap();
593 let client = CanopyClient {
594 device_key: Some(Redacted(TEST_DEVICE_KEY.to_owned())),
595 tamanu_version: "2.54.2".into(),
596 make_builder: test_factory(),
597 state: RwLock::new(State::Mtls(http)),
598 };
599 client.renew().await.expect("renew should succeed");
600 assert!(!client.is_tailscale().await);
601 }
602
603 #[tokio::test]
604 async fn renew_is_noop_in_tailscale_mode() {
605 let http = reqwest::Client::new();
607 let client = CanopyClient {
608 device_key: None,
609 tamanu_version: "2.54.2".into(),
610 make_builder: test_factory(),
611 state: RwLock::new(State::Tailscale(http)),
612 };
613 client.renew().await.expect("renew should be a no-op");
614 assert!(client.is_tailscale().await);
615 }
616
617 #[test]
618 fn user_agent_has_product_and_os_comment() {
619 let ua = user_agent("bestool", "1.2.3");
620 assert!(
621 ua.starts_with("bestool/1.2.3 "),
622 "unexpected user-agent: {ua}"
623 );
624 assert!(ua.contains('('), "expected OS comment in: {ua}");
625 assert!(ua.ends_with(')'), "expected OS comment in: {ua}");
626 assert!(
627 ua.contains(sysinfo::System::cpu_arch().as_str()),
628 "expected arch in: {ua}"
629 );
630 }
631
632 #[test]
633 fn gzip_bytes_roundtrips() {
634 use flate2::read::GzDecoder;
635 use std::io::Read;
636
637 let original = br#"{"healthy":true,"health":[{"check":"x","healthy":true}]}"#;
638 let compressed = gzip_bytes(original).expect("gzip should succeed");
639 assert!(
640 compressed.starts_with(&[0x1f, 0x8b]),
641 "expected gzip magic bytes"
642 );
643 let mut decoder = GzDecoder::new(&compressed[..]);
644 let mut decompressed = Vec::new();
645 decoder.read_to_end(&mut decompressed).unwrap();
646 assert_eq!(decompressed, original);
647 }
648
649 #[test]
650 fn severity_serialises_lowercase() {
651 assert_eq!(
652 serde_json::to_string(&Severity::Warning).unwrap(),
653 "\"warning\""
654 );
655 assert_eq!(
656 serde_json::to_string(&Severity::Critical).unwrap(),
657 "\"critical\""
658 );
659 }
660
661 #[test]
662 fn new_event_omits_optional_fields() {
663 let evt = NewEvent {
664 source: "src",
665 r#ref: "host/alert:tgt",
666 message: "msg",
667 description: None,
668 severity: None,
669 occurred_at: None,
670 active: None,
671 };
672 let json = serde_json::to_string(&evt).unwrap();
673 assert!(json.contains("\"source\":\"src\""));
674 assert!(json.contains("\"ref\":\"host/alert:tgt\""));
675 assert!(json.contains("\"message\":\"msg\""));
676 assert!(!json.contains("description"));
677 assert!(!json.contains("severity"));
678 assert!(!json.contains("occurredAt"));
679 assert!(!json.contains("active"));
680 }
681
682 #[test]
683 fn new_event_serialises_occurred_at_as_camel_case() {
684 let evt = NewEvent {
685 source: "src",
686 r#ref: "ref",
687 message: "msg",
688 description: Some("desc"),
689 severity: Some(Severity::Warning),
690 occurred_at: Some("2025-01-01T00:00:00Z".parse().unwrap()),
691 active: Some(true),
692 };
693 let json = serde_json::to_string(&evt).unwrap();
694 assert!(json.contains("\"occurredAt\":"));
695 assert!(json.contains("\"description\":\"desc\""));
696 assert!(json.contains("\"severity\":\"warning\""));
697 assert!(json.contains("\"active\":true"));
698 }
699}