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