1use std::{fmt, io::Write, time::Duration};
2
3use flate2::{Compression, write::GzEncoder};
4use jiff::Timestamp;
5use miette::{IntoDiagnostic, Result, WrapErr};
6use rcgen::{CertificateParams, DistinguishedName, DnType, KeyPair};
7use reqwest::Url;
8use serde::{Deserialize, Serialize};
9use time::{Duration as TimeDuration, OffsetDateTime};
10use tokio::sync::RwLock;
11use tracing::debug;
12
13use crate::Redacted;
14
15pub const DEFAULT_CANOPY_URL: &str = "https://meta.tamanu.app";
16
17pub const TAILSCALE_URL: &str = "https://canopy.tail53aef.ts.net";
22
23const CERT_VALIDITY_DAYS: i64 = 6;
28
29pub const CERT_RENEW_AFTER: Duration = Duration::from_secs(5 * 24 * 60 * 60);
34
35const TAILSCALE_PROBE_TIMEOUT: Duration = Duration::from_secs(5);
37
38#[derive(Copy, Clone, Debug, Eq, PartialEq, Deserialize, Serialize)]
40#[serde(rename_all = "lowercase")]
41pub enum Severity {
42 Emergency,
43 Alert,
44 Critical,
45 Error,
46 Warning,
47 Notice,
48 Info,
49 Debug,
50}
51
52#[derive(Debug, Clone, Serialize)]
54pub struct NewEvent<'a> {
55 pub source: &'a str,
56 #[serde(rename = "ref")]
57 pub r#ref: &'a str,
58 pub message: &'a str,
59 #[serde(skip_serializing_if = "Option::is_none")]
60 pub description: Option<&'a str>,
61 #[serde(skip_serializing_if = "Option::is_none")]
62 pub severity: Option<Severity>,
63 #[serde(rename = "occurredAt", skip_serializing_if = "Option::is_none")]
64 pub occurred_at: Option<Timestamp>,
65 #[serde(skip_serializing_if = "Option::is_none")]
66 pub active: Option<bool>,
67}
68
69pub struct CanopyClient {
80 device_key: Option<Redacted<String>>,
81 tamanu_version: String,
86 state: RwLock<State>,
87}
88
89enum State {
90 Tailscale(reqwest::Client),
91 Mtls(reqwest::Client),
92}
93
94impl State {
95 fn is_tailscale(&self) -> bool {
96 matches!(self, State::Tailscale(_))
97 }
98
99 fn http(&self) -> reqwest::Client {
100 match self {
101 State::Tailscale(http) | State::Mtls(http) => http.clone(),
102 }
103 }
104}
105
106impl fmt::Debug for CanopyClient {
107 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
108 f.debug_struct("CanopyClient").finish_non_exhaustive()
109 }
110}
111
112impl CanopyClient {
113 pub async fn new(
122 tamanu_version: impl Into<String>,
123 device_key_pem: Option<&str>,
124 ) -> Result<Option<Self>> {
125 let tamanu_version = tamanu_version.into();
126 let device_key = device_key_pem.map(|s| Redacted(s.to_owned()));
127
128 if let Some(http) = probe_tailscale().await {
129 debug!("canopy: tailscale endpoint reachable, preferring it");
130 return Ok(Some(Self {
131 device_key,
132 tamanu_version,
133 state: RwLock::new(State::Tailscale(http)),
134 }));
135 }
136
137 if let Some(pem) = device_key_pem {
138 debug!("canopy: tailscale unreachable, falling back to mTLS");
139 let http = build_mtls_http(pem)?;
140 return Ok(Some(Self {
141 device_key,
142 tamanu_version,
143 state: RwLock::new(State::Mtls(http)),
144 }));
145 }
146
147 Ok(None)
148 }
149
150 pub async fn is_tailscale(&self) -> bool {
152 self.state.read().await.is_tailscale()
153 }
154
155 pub async fn refresh(&self) -> Result<()> {
159 if let Some(http) = probe_tailscale().await {
160 let mut state = self.state.write().await;
161 if !state.is_tailscale() {
162 debug!("canopy refresh: switching to tailscale path");
163 }
164 *state = State::Tailscale(http);
165 return Ok(());
166 }
167
168 if let Some(pem) = &self.device_key {
169 let http = build_mtls_http(&pem.0)?;
170 let mut state = self.state.write().await;
171 if state.is_tailscale() {
172 debug!("canopy refresh: tailscale dropped, falling back to mTLS");
173 }
174 *state = State::Mtls(http);
175 return Ok(());
176 }
177
178 debug!("canopy refresh: no auth path available, keeping current state");
179 Ok(())
180 }
181
182 pub async fn renew(&self) -> Result<()> {
188 let Some(pem) = &self.device_key else {
189 return Ok(());
190 };
191 let mut state = self.state.write().await;
192 if state.is_tailscale() {
193 return Ok(());
194 }
195 *state = State::Mtls(build_mtls_http(&pem.0)?);
196 Ok(())
197 }
198
199 pub async fn post_status(
208 &self,
209 base_url: &Url,
210 server_id: &str,
211 payload: &serde_json::Value,
212 ) -> Result<()> {
213 let (http, url) = {
214 let state = self.state.read().await;
215 let url = match &*state {
216 State::Tailscale(_) => format!("{TAILSCALE_URL}/public/status/{server_id}")
217 .parse::<Url>()
218 .into_diagnostic()
219 .wrap_err("building tailscale /public/status URL")?,
220 State::Mtls(_) => base_url
221 .join(&format!("/status/{server_id}"))
222 .into_diagnostic()
223 .wrap_err("building /status URL")?,
224 };
225 (state.http(), url)
226 };
227
228 let raw = serde_json::to_vec(payload)
229 .into_diagnostic()
230 .wrap_err("serialising canopy /status payload")?;
231 let compressed = gzip_bytes(&raw)
232 .into_diagnostic()
233 .wrap_err("gzipping canopy /status payload")?;
234
235 debug!(
236 %url,
237 raw_bytes = raw.len(),
238 gzip_bytes = compressed.len(),
239 "posting status snapshot to canopy",
240 );
241
242 let response = http
243 .post(url)
244 .header("X-Version", &self.tamanu_version)
245 .header(reqwest::header::CONTENT_TYPE, "application/json")
246 .header(reqwest::header::CONTENT_ENCODING, "gzip")
247 .body(compressed)
248 .send()
249 .await
250 .into_diagnostic()
251 .wrap_err("posting status to canopy")?;
252
253 let status = response.status();
254 if !status.is_success() {
255 let body = response.text().await.unwrap_or_default();
256 return Err(miette::miette!("canopy /status returned {status}: {body}"));
257 }
258
259 Ok(())
260 }
261
262 pub async fn post_event(&self, base_url: &Url, event: NewEvent<'_>) -> Result<()> {
267 let (http, url) = {
268 let state = self.state.read().await;
269 let url = match &*state {
270 State::Tailscale(_) => format!("{TAILSCALE_URL}/public/events")
271 .parse::<Url>()
272 .into_diagnostic()
273 .wrap_err("building tailscale /public/events URL")?,
274 State::Mtls(_) => base_url
275 .join("/events")
276 .into_diagnostic()
277 .wrap_err("building /events URL")?,
278 };
279 (state.http(), url)
280 };
281
282 debug!(
283 %url,
284 source = event.source,
285 r#ref = event.r#ref,
286 active = ?event.active,
287 "posting event to canopy"
288 );
289
290 let response = http
291 .post(url)
292 .header("X-Version", &self.tamanu_version)
293 .json(&event)
294 .send()
295 .await
296 .into_diagnostic()
297 .wrap_err("posting event to canopy")?;
298
299 let status = response.status();
300 if !status.is_success() {
301 let body = response.text().await.unwrap_or_default();
302 return Err(miette::miette!("canopy /events returned {status}: {body}"));
303 }
304
305 Ok(())
306 }
307}
308
309async fn probe_tailscale() -> Option<reqwest::Client> {
321 let url = format!("{TAILSCALE_URL}/public/servers");
322
323 let client = reqwest::Client::builder()
324 .timeout(TAILSCALE_PROBE_TIMEOUT)
325 .build()
326 .ok()?;
327
328 match client.get(&url).send().await {
329 Ok(resp) if resp.status().is_success() => Some(client),
330 Ok(resp) => {
331 debug!(status = %resp.status(), "canopy tailscale probe: unexpected status");
332 None
333 }
334 Err(err) => {
335 debug!("canopy tailscale probe failed: {err}");
336 None
337 }
338 }
339}
340
341fn gzip_bytes(bytes: &[u8]) -> std::io::Result<Vec<u8>> {
342 let mut encoder = GzEncoder::new(Vec::with_capacity(bytes.len() / 2), Compression::default());
343 encoder.write_all(bytes)?;
344 encoder.finish()
345}
346
347fn build_mtls_http(device_key_pem: &str) -> Result<reqwest::Client> {
348 let key_pair = KeyPair::from_pem(device_key_pem)
349 .into_diagnostic()
350 .wrap_err("parsing device key PEM")?;
351
352 let mut params = CertificateParams::new(vec!["device.local".into()])
353 .into_diagnostic()
354 .wrap_err("building certificate params")?;
355 params.distinguished_name = DistinguishedName::new();
356 params
357 .distinguished_name
358 .push(DnType::CommonName, "device.local");
359
360 let now = OffsetDateTime::now_utc();
361 params.not_before = now - TimeDuration::minutes(1);
362 params.not_after = now + TimeDuration::days(CERT_VALIDITY_DAYS);
363
364 let cert = params
365 .self_signed(&key_pair)
366 .into_diagnostic()
367 .wrap_err("self-signing certificate")?;
368
369 let mut combined = cert.pem();
370 combined.push('\n');
371 combined.push_str(&key_pair.serialize_pem());
372
373 let identity = reqwest::Identity::from_pem(combined.as_bytes())
374 .into_diagnostic()
375 .wrap_err("building reqwest TLS identity")?;
376
377 reqwest::Client::builder()
378 .identity(identity)
379 .use_rustls_tls()
380 .timeout(Duration::from_secs(30))
381 .build()
382 .into_diagnostic()
383 .wrap_err("building canopy HTTP client")
384}
385
386#[cfg(test)]
387mod tests {
388 use super::*;
389
390 const TEST_DEVICE_KEY: &str = "\
391-----BEGIN PRIVATE KEY-----
392MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgVvhzsYiidp38GYn1
393KxD5Wipc/h8lglVsy1UFZq/SZbGhRANCAAT2EsEq7xjeWVnim9XwdYXga/LBbppm
394fXLgamTYOa/w9n/Ta64fiYWmN54kEd0DgnflJDLtID321Zz6xswvK/VN
395-----END PRIVATE KEY-----";
396
397 #[test]
398 fn build_mtls_http_from_p256_key() {
399 let result = build_mtls_http(TEST_DEVICE_KEY);
401 assert!(result.is_ok(), "{:?}", result.err());
402 }
403
404 #[test]
405 fn build_mtls_http_fails_on_garbage_key() {
406 assert!(build_mtls_http("not a real PEM").is_err());
407 }
408
409 #[tokio::test]
410 async fn renew_with_mtls_state_swaps_in_fresh_client() {
411 let http = build_mtls_http(TEST_DEVICE_KEY).unwrap();
413 let client = CanopyClient {
414 device_key: Some(Redacted(TEST_DEVICE_KEY.to_owned())),
415 tamanu_version: "2.54.2".into(),
416 state: RwLock::new(State::Mtls(http)),
417 };
418 client.renew().await.expect("renew should succeed");
419 assert!(!client.is_tailscale().await);
420 }
421
422 #[tokio::test]
423 async fn renew_is_noop_in_tailscale_mode() {
424 let http = reqwest::Client::new();
426 let client = CanopyClient {
427 device_key: None,
428 tamanu_version: "2.54.2".into(),
429 state: RwLock::new(State::Tailscale(http)),
430 };
431 client.renew().await.expect("renew should be a no-op");
432 assert!(client.is_tailscale().await);
433 }
434
435 #[test]
436 fn gzip_bytes_roundtrips() {
437 use flate2::read::GzDecoder;
438 use std::io::Read;
439
440 let original = br#"{"healthy":true,"health":[{"check":"x","healthy":true}]}"#;
441 let compressed = gzip_bytes(original).expect("gzip should succeed");
442 assert!(
443 compressed.starts_with(&[0x1f, 0x8b]),
444 "expected gzip magic bytes"
445 );
446 let mut decoder = GzDecoder::new(&compressed[..]);
447 let mut decompressed = Vec::new();
448 decoder.read_to_end(&mut decompressed).unwrap();
449 assert_eq!(decompressed, original);
450 }
451
452 #[test]
453 fn severity_serialises_lowercase() {
454 assert_eq!(
455 serde_json::to_string(&Severity::Warning).unwrap(),
456 "\"warning\""
457 );
458 assert_eq!(
459 serde_json::to_string(&Severity::Emergency).unwrap(),
460 "\"emergency\""
461 );
462 }
463
464 #[test]
465 fn new_event_omits_optional_fields() {
466 let evt = NewEvent {
467 source: "src",
468 r#ref: "host/alert:tgt",
469 message: "msg",
470 description: None,
471 severity: None,
472 occurred_at: None,
473 active: None,
474 };
475 let json = serde_json::to_string(&evt).unwrap();
476 assert!(json.contains("\"source\":\"src\""));
477 assert!(json.contains("\"ref\":\"host/alert:tgt\""));
478 assert!(json.contains("\"message\":\"msg\""));
479 assert!(!json.contains("description"));
480 assert!(!json.contains("severity"));
481 assert!(!json.contains("occurredAt"));
482 assert!(!json.contains("active"));
483 }
484
485 #[test]
486 fn new_event_serialises_occurred_at_as_camel_case() {
487 let evt = NewEvent {
488 source: "src",
489 r#ref: "ref",
490 message: "msg",
491 description: Some("desc"),
492 severity: Some(Severity::Warning),
493 occurred_at: Some("2025-01-01T00:00:00Z".parse().unwrap()),
494 active: Some(true),
495 };
496 let json = serde_json::to_string(&evt).unwrap();
497 assert!(json.contains("\"occurredAt\":"));
498 assert!(json.contains("\"description\":\"desc\""));
499 assert!(json.contains("\"severity\":\"warning\""));
500 assert!(json.contains("\"active\":true"));
501 }
502}