Skip to main content

bestool_alertd/
canopy.rs

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
17/// Base URL for the tailscale-internal canopy endpoint.
18///
19/// On hosts that share the canopy tailnet, posting to this URL works without
20/// mTLS — the tailscale identity is the auth.
21pub const TAILSCALE_URL: &str = "https://canopy.tail53aef.ts.net";
22
23/// How long renewed canopy certs are valid for.
24///
25/// Set well above [`CERT_RENEW_AFTER`] so a renewal failure doesn't immediately
26/// strand the client.
27const CERT_VALIDITY_DAYS: i64 = 6;
28
29/// How long to wait between scheduled cert renewals.
30///
31/// Renewal runs in a background task in the daemon; the legacy single-shot
32/// alerts command builds the client once and exits well within this window.
33pub const CERT_RENEW_AFTER: Duration = Duration::from_secs(5 * 24 * 60 * 60);
34
35/// Timeout for the tailscale availability probe.
36const TAILSCALE_PROBE_TIMEOUT: Duration = Duration::from_secs(5);
37
38/// RFC 5424 syslog severities accepted by the canopy `/events` API.
39#[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/// Payload for posting to `POST /events` on a canopy server.
53#[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
69/// HTTP client with auth configured for talking to a canopy server.
70///
71/// Tries two auth paths in order of preference:
72/// 1. **Tailscale**: if the canopy tailnet endpoint is reachable, plain HTTPS
73///    works (auth is implicit via tailscale identity).
74/// 2. **mTLS**: a fresh self-signed cert from the device key, short-lived
75///    ([`CERT_VALIDITY_DAYS`]); for long-running daemons, [`Self::renew`]
76///    should tick on [`CERT_RENEW_AFTER`] to swap in a fresh cert before expiry.
77///
78/// [`Self::refresh`] re-probes tailscale and swaps modes on reload.
79pub struct CanopyClient {
80	device_key: Option<Redacted<String>>,
81	/// Tamanu version of the install this client speaks for. Sent verbatim in
82	/// the `X-Version` request header — canopy rejects events / status pushes
83	/// that don't carry one. Sourced from the running Tamanu install's
84	/// `package.json` (via `find_tamanu`); not the bestool / alertd version.
85	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	/// Build a canopy client, preferring tailscale and falling back to mTLS.
114	///
115	/// Probes the tailscale canopy endpoint first; if reachable, uses it.
116	/// Otherwise, if a device key PEM is provided, builds an mTLS client.
117	/// Returns `Ok(None)` if neither path is available.
118	///
119	/// `tamanu_version` is the version of the Tamanu install this client
120	/// speaks for; sent on every request via the `X-Version` header.
121	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	/// Returns true if the client is currently using the tailscale path.
151	pub async fn is_tailscale(&self) -> bool {
152		self.state.read().await.is_tailscale()
153	}
154
155	/// Re-probe tailscale and swap modes if the picture has changed.
156	///
157	/// Intended to be called when the daemon receives a reload signal.
158	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	/// Rebuild the underlying HTTP client with a fresh certificate.
183	///
184	/// No-op in tailscale mode (no cert to rotate). In mTLS mode, atomically
185	/// replaces the live client; in-flight requests continue with the old
186	/// client until they complete.
187	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	/// POST a status snapshot to the canopy server.
200	///
201	/// In tailscale mode, `base_url` is ignored and a `{TAILSCALE_URL}/public/status/{server_id}`
202	/// URL is used. In mTLS mode, posts to `{base_url}/status/{server_id}`.
203	///
204	/// The payload is free-form JSON; the canopy `/status` contract reserves the
205	/// top-level `healthy: bool` and `health: []` keys. The body is gzip-encoded
206	/// with `Content-Encoding: gzip`.
207	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	/// POST an event to the canopy server.
263	///
264	/// In tailscale mode, `base_url` is ignored and [`TAILSCALE_URL`] is used.
265	/// In mTLS mode, posts to `{base_url}/events`.
266	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
309/// Probe the tailscale canopy endpoint.
310///
311/// Returns a configured `reqwest::Client` if `GET /public/servers` responds
312/// 2xx — anything else (timeout, non-2xx, transport error) returns `None` so
313/// the caller can fall back to mTLS.
314///
315/// `/public/servers` is used because:
316/// - it lives under `/public/...`, the only mount that accepts tagged-device
317///   tailscale callers (everything else 403s with `tagged-device-not-allowed`);
318/// - it's a `GET` with no body, no `VersionHeader` requirement, and no auth;
319/// - it's read-only, so probing it has no side effects.
320async 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		// Direct mTLS-path build, bypassing the async constructor / tailscale probe.
400		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		// Construct an mTLS-state client directly (no network probe) and renew it.
412		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		// Tailscale-state client with no device key — renew is a no-op.
425		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}