1use std::{
2 fmt,
3 io::Write,
4 net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr},
5 time::Duration,
6};
7
8use flate2::{Compression, write::GzEncoder};
9use hickory_resolver::{
10 ConnectionProvider, Resolver,
11 config::{ConnectionConfig, NameServerConfig, ResolverConfig},
12 net::runtime::TokioRuntimeProvider,
13};
14use jiff::Timestamp;
15use miette::{IntoDiagnostic, Result, WrapErr};
16use rcgen::{CertificateParams, DistinguishedName, DnType, KeyPair};
17use reqwest::Url;
18use serde::{Deserialize, Serialize};
19use time::{Duration as TimeDuration, OffsetDateTime};
20use tokio::sync::RwLock;
21use tracing::debug;
22
23use crate::Redacted;
24
25pub const DEFAULT_CANOPY_URL: &str = "https://meta.tamanu.app";
26
27pub const TAILSCALE_URL: &str = "https://canopy.tail53aef.ts.net";
32
33const TAILSCALE_HOST: &str = "canopy.tail53aef.ts.net";
35
36const CANOPY_HARDCODED_V4: Ipv4Addr = Ipv4Addr::new(100, 99, 98, 97);
39const CANOPY_HARDCODED_V6: Ipv6Addr =
40 Ipv6Addr::new(0xfd7a, 0x115c, 0xa1e0, 0, 0, 0, 0x9337, 0xfb52);
41
42const CERT_VALIDITY_DAYS: i64 = 6;
47
48pub const CERT_RENEW_AFTER: Duration = Duration::from_secs(5 * 24 * 60 * 60);
53
54const TAILSCALE_PROBE_TIMEOUT: Duration = Duration::from_secs(5);
56
57#[derive(Copy, Clone, Debug, Eq, PartialEq, Deserialize, Serialize)]
59#[serde(rename_all = "lowercase")]
60pub enum Severity {
61 Emergency,
62 Alert,
63 Critical,
64 Error,
65 Warning,
66 Notice,
67 Info,
68 Debug,
69}
70
71#[derive(Debug, Clone, Serialize)]
73pub struct NewEvent<'a> {
74 pub source: &'a str,
75 #[serde(rename = "ref")]
76 pub r#ref: &'a str,
77 pub message: &'a str,
78 #[serde(skip_serializing_if = "Option::is_none")]
79 pub description: Option<&'a str>,
80 #[serde(skip_serializing_if = "Option::is_none")]
81 pub severity: Option<Severity>,
82 #[serde(rename = "occurredAt", skip_serializing_if = "Option::is_none")]
83 pub occurred_at: Option<Timestamp>,
84 #[serde(skip_serializing_if = "Option::is_none")]
85 pub active: Option<bool>,
86}
87
88pub struct CanopyClient {
99 device_key: Option<Redacted<String>>,
100 tamanu_version: String,
105 state: RwLock<State>,
106}
107
108enum State {
109 Tailscale(reqwest::Client),
110 Mtls(reqwest::Client),
111}
112
113impl State {
114 fn is_tailscale(&self) -> bool {
115 matches!(self, State::Tailscale(_))
116 }
117
118 fn http(&self) -> reqwest::Client {
119 match self {
120 State::Tailscale(http) | State::Mtls(http) => http.clone(),
121 }
122 }
123}
124
125impl fmt::Debug for CanopyClient {
126 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
127 f.debug_struct("CanopyClient").finish_non_exhaustive()
128 }
129}
130
131impl CanopyClient {
132 pub async fn new(
141 tamanu_version: impl Into<String>,
142 device_key_pem: Option<&str>,
143 ) -> Result<Option<Self>> {
144 let tamanu_version = tamanu_version.into();
145 let device_key = device_key_pem.map(|s| Redacted(s.to_owned()));
146
147 if let Some(http) = probe_tailscale().await {
148 debug!("canopy: tailscale endpoint reachable, preferring it");
149 return Ok(Some(Self {
150 device_key,
151 tamanu_version,
152 state: RwLock::new(State::Tailscale(http)),
153 }));
154 }
155
156 if let Some(pem) = device_key_pem {
157 debug!("canopy: tailscale unreachable, falling back to mTLS");
158 let http = build_mtls_http(pem)?;
159 return Ok(Some(Self {
160 device_key,
161 tamanu_version,
162 state: RwLock::new(State::Mtls(http)),
163 }));
164 }
165
166 Ok(None)
167 }
168
169 pub async fn is_tailscale(&self) -> bool {
171 self.state.read().await.is_tailscale()
172 }
173
174 pub async fn refresh(&self) -> Result<()> {
178 if let Some(http) = probe_tailscale().await {
179 let mut state = self.state.write().await;
180 if !state.is_tailscale() {
181 debug!("canopy refresh: switching to tailscale path");
182 }
183 *state = State::Tailscale(http);
184 return Ok(());
185 }
186
187 if let Some(pem) = &self.device_key {
188 let http = build_mtls_http(&pem.0)?;
189 let mut state = self.state.write().await;
190 if state.is_tailscale() {
191 debug!("canopy refresh: tailscale dropped, falling back to mTLS");
192 }
193 *state = State::Mtls(http);
194 return Ok(());
195 }
196
197 debug!("canopy refresh: no auth path available, keeping current state");
198 Ok(())
199 }
200
201 pub async fn renew(&self) -> Result<()> {
207 let Some(pem) = &self.device_key else {
208 return Ok(());
209 };
210 let mut state = self.state.write().await;
211 if state.is_tailscale() {
212 return Ok(());
213 }
214 *state = State::Mtls(build_mtls_http(&pem.0)?);
215 Ok(())
216 }
217
218 pub async fn post_status(
227 &self,
228 base_url: &Url,
229 server_id: &str,
230 payload: &serde_json::Value,
231 ) -> Result<()> {
232 let (http, url) = {
233 let state = self.state.read().await;
234 let url = match &*state {
235 State::Tailscale(_) => format!("{TAILSCALE_URL}/public/status/{server_id}")
236 .parse::<Url>()
237 .into_diagnostic()
238 .wrap_err("building tailscale /public/status URL")?,
239 State::Mtls(_) => base_url
240 .join(&format!("/status/{server_id}"))
241 .into_diagnostic()
242 .wrap_err("building /status URL")?,
243 };
244 (state.http(), url)
245 };
246
247 let raw = serde_json::to_vec(payload)
248 .into_diagnostic()
249 .wrap_err("serialising canopy /status payload")?;
250 let compressed = gzip_bytes(&raw)
251 .into_diagnostic()
252 .wrap_err("gzipping canopy /status payload")?;
253
254 debug!(
255 %url,
256 raw_bytes = raw.len(),
257 gzip_bytes = compressed.len(),
258 "posting status snapshot to canopy",
259 );
260
261 let response = http
262 .post(url)
263 .header("X-Version", &self.tamanu_version)
264 .header(reqwest::header::CONTENT_TYPE, "application/json")
265 .header(reqwest::header::CONTENT_ENCODING, "gzip")
266 .body(compressed)
267 .send()
268 .await
269 .into_diagnostic()
270 .wrap_err("posting status to canopy")?;
271
272 let status = response.status();
273 if !status.is_success() {
274 let body = response.text().await.unwrap_or_default();
275 return Err(miette::miette!("canopy /status returned {status}: {body}"));
276 }
277
278 Ok(())
279 }
280
281 pub async fn post_event(&self, base_url: &Url, event: NewEvent<'_>) -> Result<()> {
286 let (http, url) = {
287 let state = self.state.read().await;
288 let url = match &*state {
289 State::Tailscale(_) => format!("{TAILSCALE_URL}/public/events")
290 .parse::<Url>()
291 .into_diagnostic()
292 .wrap_err("building tailscale /public/events URL")?,
293 State::Mtls(_) => base_url
294 .join("/events")
295 .into_diagnostic()
296 .wrap_err("building /events URL")?,
297 };
298 (state.http(), url)
299 };
300
301 debug!(
302 %url,
303 source = event.source,
304 r#ref = event.r#ref,
305 active = ?event.active,
306 "posting event to canopy"
307 );
308
309 let response = http
310 .post(url)
311 .header("X-Version", &self.tamanu_version)
312 .json(&event)
313 .send()
314 .await
315 .into_diagnostic()
316 .wrap_err("posting event to canopy")?;
317
318 let status = response.status();
319 if !status.is_success() {
320 let body = response.text().await.unwrap_or_default();
321 return Err(miette::miette!("canopy /events returned {status}: {body}"));
322 }
323
324 Ok(())
325 }
326}
327
328async fn probe_tailscale() -> Option<reqwest::Client> {
345 let dns_addrs: Vec<SocketAddr> = tailscale_resolver()
346 .lookup_ip("canopy")
347 .await
348 .ok()
349 .map(|addrs| addrs.iter().map(|ip| SocketAddr::new(ip, 443)).collect())
350 .unwrap_or_default();
351 if !dns_addrs.is_empty()
352 && let Some(client) = try_probe(&dns_addrs).await
353 {
354 return Some(client);
355 }
356
357 let hardcoded = [
358 SocketAddr::new(IpAddr::V4(CANOPY_HARDCODED_V4), 443),
359 SocketAddr::new(IpAddr::V6(CANOPY_HARDCODED_V6), 443),
360 ];
361 debug!(
362 ?hardcoded,
363 "canopy tailscale DNS lookup empty or probe failed, trying hardcoded IPs"
364 );
365 try_probe(&hardcoded).await
366}
367
368async fn try_probe(addrs: &[SocketAddr]) -> Option<reqwest::Client> {
369 let client = reqwest::Client::builder()
370 .timeout(TAILSCALE_PROBE_TIMEOUT)
371 .resolve_to_addrs(TAILSCALE_HOST, addrs)
372 .build()
373 .ok()?;
374
375 let url = format!("{TAILSCALE_URL}/public/servers");
376 match client.get(&url).send().await {
377 Ok(resp) if resp.status().is_success() => Some(client),
378 Ok(resp) => {
379 debug!(status = %resp.status(), ?addrs, "canopy tailscale probe: unexpected status");
380 None
381 }
382 Err(err) => {
383 debug!(?addrs, "canopy tailscale probe failed: {err}");
384 None
385 }
386 }
387}
388
389fn tailscale_resolver() -> Resolver<impl ConnectionProvider> {
390 Resolver::builder_with_config(
391 ResolverConfig::from_parts(
392 None,
393 vec!["tail53aef.ts.net.".parse().unwrap()],
394 vec![NameServerConfig::new(
395 "100.100.100.100".parse().unwrap(),
396 true,
397 vec![ConnectionConfig::udp()],
398 )],
399 ),
400 TokioRuntimeProvider::default(),
401 )
402 .build()
403 .expect("tailscale resolver config is hardcoded and cannot fail to build")
404}
405
406fn gzip_bytes(bytes: &[u8]) -> std::io::Result<Vec<u8>> {
407 let mut encoder = GzEncoder::new(Vec::with_capacity(bytes.len() / 2), Compression::default());
408 encoder.write_all(bytes)?;
409 encoder.finish()
410}
411
412fn build_mtls_http(device_key_pem: &str) -> Result<reqwest::Client> {
413 let key_pair = KeyPair::from_pem(device_key_pem)
414 .into_diagnostic()
415 .wrap_err("parsing device key PEM")?;
416
417 let mut params = CertificateParams::new(vec!["device.local".into()])
418 .into_diagnostic()
419 .wrap_err("building certificate params")?;
420 params.distinguished_name = DistinguishedName::new();
421 params
422 .distinguished_name
423 .push(DnType::CommonName, "device.local");
424
425 let now = OffsetDateTime::now_utc();
426 params.not_before = now - TimeDuration::minutes(1);
427 params.not_after = now + TimeDuration::days(CERT_VALIDITY_DAYS);
428
429 let cert = params
430 .self_signed(&key_pair)
431 .into_diagnostic()
432 .wrap_err("self-signing certificate")?;
433
434 let mut combined = cert.pem();
435 combined.push('\n');
436 combined.push_str(&key_pair.serialize_pem());
437
438 let identity = reqwest::Identity::from_pem(combined.as_bytes())
439 .into_diagnostic()
440 .wrap_err("building reqwest TLS identity")?;
441
442 reqwest::Client::builder()
443 .identity(identity)
444 .use_rustls_tls()
445 .timeout(Duration::from_secs(30))
446 .build()
447 .into_diagnostic()
448 .wrap_err("building canopy HTTP client")
449}
450
451#[cfg(test)]
452mod tests {
453 use super::*;
454
455 const TEST_DEVICE_KEY: &str = "\
456-----BEGIN PRIVATE KEY-----
457MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgVvhzsYiidp38GYn1
458KxD5Wipc/h8lglVsy1UFZq/SZbGhRANCAAT2EsEq7xjeWVnim9XwdYXga/LBbppm
459fXLgamTYOa/w9n/Ta64fiYWmN54kEd0DgnflJDLtID321Zz6xswvK/VN
460-----END PRIVATE KEY-----";
461
462 #[test]
463 fn build_mtls_http_from_p256_key() {
464 let result = build_mtls_http(TEST_DEVICE_KEY);
466 assert!(result.is_ok(), "{:?}", result.err());
467 }
468
469 #[test]
470 fn build_mtls_http_fails_on_garbage_key() {
471 assert!(build_mtls_http("not a real PEM").is_err());
472 }
473
474 #[tokio::test]
475 async fn renew_with_mtls_state_swaps_in_fresh_client() {
476 let http = build_mtls_http(TEST_DEVICE_KEY).unwrap();
478 let client = CanopyClient {
479 device_key: Some(Redacted(TEST_DEVICE_KEY.to_owned())),
480 tamanu_version: "2.54.2".into(),
481 state: RwLock::new(State::Mtls(http)),
482 };
483 client.renew().await.expect("renew should succeed");
484 assert!(!client.is_tailscale().await);
485 }
486
487 #[tokio::test]
488 async fn renew_is_noop_in_tailscale_mode() {
489 let http = reqwest::Client::new();
491 let client = CanopyClient {
492 device_key: None,
493 tamanu_version: "2.54.2".into(),
494 state: RwLock::new(State::Tailscale(http)),
495 };
496 client.renew().await.expect("renew should be a no-op");
497 assert!(client.is_tailscale().await);
498 }
499
500 #[test]
501 fn gzip_bytes_roundtrips() {
502 use flate2::read::GzDecoder;
503 use std::io::Read;
504
505 let original = br#"{"healthy":true,"health":[{"check":"x","healthy":true}]}"#;
506 let compressed = gzip_bytes(original).expect("gzip should succeed");
507 assert!(
508 compressed.starts_with(&[0x1f, 0x8b]),
509 "expected gzip magic bytes"
510 );
511 let mut decoder = GzDecoder::new(&compressed[..]);
512 let mut decompressed = Vec::new();
513 decoder.read_to_end(&mut decompressed).unwrap();
514 assert_eq!(decompressed, original);
515 }
516
517 #[test]
518 fn severity_serialises_lowercase() {
519 assert_eq!(
520 serde_json::to_string(&Severity::Warning).unwrap(),
521 "\"warning\""
522 );
523 assert_eq!(
524 serde_json::to_string(&Severity::Emergency).unwrap(),
525 "\"emergency\""
526 );
527 }
528
529 #[test]
530 fn new_event_omits_optional_fields() {
531 let evt = NewEvent {
532 source: "src",
533 r#ref: "host/alert:tgt",
534 message: "msg",
535 description: None,
536 severity: None,
537 occurred_at: None,
538 active: None,
539 };
540 let json = serde_json::to_string(&evt).unwrap();
541 assert!(json.contains("\"source\":\"src\""));
542 assert!(json.contains("\"ref\":\"host/alert:tgt\""));
543 assert!(json.contains("\"message\":\"msg\""));
544 assert!(!json.contains("description"));
545 assert!(!json.contains("severity"));
546 assert!(!json.contains("occurredAt"));
547 assert!(!json.contains("active"));
548 }
549
550 #[test]
551 fn new_event_serialises_occurred_at_as_camel_case() {
552 let evt = NewEvent {
553 source: "src",
554 r#ref: "ref",
555 message: "msg",
556 description: Some("desc"),
557 severity: Some(Severity::Warning),
558 occurred_at: Some("2025-01-01T00:00:00Z".parse().unwrap()),
559 active: Some(true),
560 };
561 let json = serde_json::to_string(&evt).unwrap();
562 assert!(json.contains("\"occurredAt\":"));
563 assert!(json.contains("\"description\":\"desc\""));
564 assert!(json.contains("\"severity\":\"warning\""));
565 assert!(json.contains("\"active\":true"));
566 }
567}