Skip to main content

bestool_alertd/
canopy.rs

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
27/// Base URL for the tailscale-internal canopy endpoint.
28///
29/// On hosts that share the canopy tailnet, posting to this URL works without
30/// mTLS — the tailscale identity is the auth.
31pub const TAILSCALE_URL: &str = "https://canopy.tail53aef.ts.net";
32
33/// Bare hostname used for `resolve_to_addrs` overrides.
34const TAILSCALE_HOST: &str = "canopy.tail53aef.ts.net";
35
36/// Hardcoded tailscale IPs for canopy, used when tailscale DNS
37/// (100.100.100.100) is unreachable but the tailnet otherwise is.
38const 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
42/// How long renewed canopy certs are valid for.
43///
44/// Set well above [`CERT_RENEW_AFTER`] so a renewal failure doesn't immediately
45/// strand the client.
46const CERT_VALIDITY_DAYS: i64 = 6;
47
48/// How long to wait between scheduled cert renewals.
49///
50/// Renewal runs in a background task in the daemon; the legacy single-shot
51/// alerts command builds the client once and exits well within this window.
52pub const CERT_RENEW_AFTER: Duration = Duration::from_secs(5 * 24 * 60 * 60);
53
54/// Timeout for the tailscale availability probe.
55const TAILSCALE_PROBE_TIMEOUT: Duration = Duration::from_secs(5);
56
57/// RFC 5424 syslog severities accepted by the canopy `/events` API.
58#[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/// Payload for posting to `POST /events` on a canopy server.
72#[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
88/// HTTP client with auth configured for talking to a canopy server.
89///
90/// Tries two auth paths in order of preference:
91/// 1. **Tailscale**: if the canopy tailnet endpoint is reachable, plain HTTPS
92///    works (auth is implicit via tailscale identity).
93/// 2. **mTLS**: a fresh self-signed cert from the device key, short-lived
94///    ([`CERT_VALIDITY_DAYS`]); for long-running daemons, [`Self::renew`]
95///    should tick on [`CERT_RENEW_AFTER`] to swap in a fresh cert before expiry.
96///
97/// [`Self::refresh`] re-probes tailscale and swaps modes on reload.
98pub struct CanopyClient {
99	device_key: Option<Redacted<String>>,
100	/// Tamanu version of the install this client speaks for. Sent verbatim in
101	/// the `X-Version` request header — canopy rejects events / status pushes
102	/// that don't carry one. Sourced from the running Tamanu install's
103	/// `package.json` (via `find_tamanu`); not the bestool / alertd version.
104	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	/// Build a canopy client, preferring tailscale and falling back to mTLS.
133	///
134	/// Probes the tailscale canopy endpoint first; if reachable, uses it.
135	/// Otherwise, if a device key PEM is provided, builds an mTLS client.
136	/// Returns `Ok(None)` if neither path is available.
137	///
138	/// `tamanu_version` is the version of the Tamanu install this client
139	/// speaks for; sent on every request via the `X-Version` header.
140	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	/// Returns true if the client is currently using the tailscale path.
170	pub async fn is_tailscale(&self) -> bool {
171		self.state.read().await.is_tailscale()
172	}
173
174	/// Re-probe tailscale and swap modes if the picture has changed.
175	///
176	/// Intended to be called when the daemon receives a reload signal.
177	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	/// Rebuild the underlying HTTP client with a fresh certificate.
202	///
203	/// No-op in tailscale mode (no cert to rotate). In mTLS mode, atomically
204	/// replaces the live client; in-flight requests continue with the old
205	/// client until they complete.
206	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	/// POST a status snapshot to the canopy server.
219	///
220	/// In tailscale mode, `base_url` is ignored and a `{TAILSCALE_URL}/public/status/{server_id}`
221	/// URL is used. In mTLS mode, posts to `{base_url}/status/{server_id}`.
222	///
223	/// The payload is free-form JSON; the canopy `/status` contract reserves the
224	/// top-level `healthy: bool` and `health: []` keys. The body is gzip-encoded
225	/// with `Content-Encoding: gzip`.
226	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	/// POST an event to the canopy server.
282	///
283	/// In tailscale mode, `base_url` is ignored and [`TAILSCALE_URL`] is used.
284	/// In mTLS mode, posts to `{base_url}/events`.
285	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
328/// Probe the tailscale canopy endpoint.
329///
330/// Returns a configured `reqwest::Client` if `GET /public/servers` responds
331/// 2xx — anything else (timeout, non-2xx, transport error) returns `None` so
332/// the caller can fall back to mTLS.
333///
334/// Tries two paths in order:
335/// 1. Resolve `canopy` via the tailscale DNS server (100.100.100.100) and
336///    probe with those addresses.
337/// 2. Use hardcoded tailscale IPs for canopy and probe with those.
338///
339/// `/public/servers` is used because:
340/// - it lives under `/public/...`, the only mount that accepts tagged-device
341///   tailscale callers (everything else 403s with `tagged-device-not-allowed`);
342/// - it's a `GET` with no body, no `VersionHeader` requirement, and no auth;
343/// - it's read-only, so probing it has no side effects.
344async 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		// Direct mTLS-path build, bypassing the async constructor / tailscale probe.
465		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		// Construct an mTLS-state client directly (no network probe) and renew it.
477		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		// Tailscale-state client with no device key — renew is a no-op.
490		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}