use std::{
fmt,
io::Write,
net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr},
sync::{Arc, OnceLock},
time::Duration,
};
use flate2::{Compression, write::GzEncoder};
use hickory_resolver::{
ConnectionProvider, Resolver,
config::{ConnectionConfig, NameServerConfig, ResolverConfig},
net::runtime::TokioRuntimeProvider,
};
use jiff::Timestamp;
use miette::{IntoDiagnostic, Result, WrapErr};
use rcgen::{CertificateParams, DistinguishedName, DnType, KeyPair};
use reqwest::Url;
use serde::{Deserialize, Serialize};
use time::{Duration as TimeDuration, OffsetDateTime};
use tokio::sync::RwLock;
use tracing::debug;
use crate::Redacted;
pub const DEFAULT_CANOPY_URL: &str = "https://meta.tamanu.app";
pub const TAILSCALE_URL: &str = "https://canopy.tail53aef.ts.net";
const TAILSCALE_HOST: &str = "canopy.tail53aef.ts.net";
const CANOPY_HARDCODED_V4: Ipv4Addr = Ipv4Addr::new(100, 99, 98, 97);
const CANOPY_HARDCODED_V6: Ipv6Addr =
Ipv6Addr::new(0xfd7a, 0x115c, 0xa1e0, 0, 0, 0, 0x9337, 0xfb52);
const CERT_VALIDITY_DAYS: i64 = 6;
pub const CERT_RENEW_AFTER: Duration = Duration::from_secs(5 * 24 * 60 * 60);
const TAILSCALE_PROBE_TIMEOUT: Duration = Duration::from_secs(5);
pub type ClientBuilderFactory = Arc<dyn Fn() -> reqwest::ClientBuilder + Send + Sync>;
pub fn user_agent(product: &str, version: &str) -> String {
static OS_COMMENT: OnceLock<String> = OnceLock::new();
let os_comment = OS_COMMENT.get_or_init(|| {
let os = sysinfo::System::long_os_version()
.or_else(sysinfo::System::name)
.unwrap_or_else(|| std::env::consts::OS.to_owned());
format!("{os}; {}", sysinfo::System::cpu_arch())
});
format!("{product}/{version} ({os_comment})")
}
pub fn client_builder(version: &str) -> reqwest::ClientBuilder {
reqwest::Client::builder().user_agent(user_agent("bestool", version))
}
pub async fn tailscale_client(make_builder: &ClientBuilderFactory) -> Option<reqwest::Client> {
probe_tailscale(make_builder).await
}
#[derive(Copy, Clone, Debug, Eq, PartialEq, Deserialize, Serialize)]
#[serde(rename_all = "lowercase")]
pub enum Severity {
Critical,
Error,
Warning,
Info,
Debug,
}
#[derive(Debug, Clone, Serialize)]
pub struct NewEvent<'a> {
pub source: &'a str,
#[serde(rename = "ref")]
pub r#ref: &'a str,
pub message: &'a str,
#[serde(skip_serializing_if = "Option::is_none")]
pub description: Option<&'a str>,
#[serde(skip_serializing_if = "Option::is_none")]
pub severity: Option<Severity>,
#[serde(rename = "occurredAt", skip_serializing_if = "Option::is_none")]
pub occurred_at: Option<Timestamp>,
#[serde(skip_serializing_if = "Option::is_none")]
pub active: Option<bool>,
}
pub struct CanopyClient {
device_key: Option<Redacted<String>>,
tamanu_version: String,
make_builder: ClientBuilderFactory,
state: RwLock<State>,
}
enum State {
Tailscale(reqwest::Client),
Mtls(reqwest::Client),
}
impl State {
fn is_tailscale(&self) -> bool {
matches!(self, State::Tailscale(_))
}
fn http(&self) -> reqwest::Client {
match self {
State::Tailscale(http) | State::Mtls(http) => http.clone(),
}
}
}
impl fmt::Debug for CanopyClient {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("CanopyClient").finish_non_exhaustive()
}
}
impl CanopyClient {
pub async fn new(
tamanu_version: impl Into<String>,
device_key_pem: Option<&str>,
make_builder: impl Fn() -> reqwest::ClientBuilder + Send + Sync + 'static,
) -> Result<Option<Self>> {
let tamanu_version = tamanu_version.into();
let device_key = device_key_pem.map(|s| Redacted(s.to_owned()));
let make_builder: ClientBuilderFactory = Arc::new(make_builder);
if let Some(http) = probe_tailscale(&make_builder).await {
debug!("canopy: tailscale endpoint reachable, preferring it");
return Ok(Some(Self {
device_key,
tamanu_version,
make_builder,
state: RwLock::new(State::Tailscale(http)),
}));
}
if let Some(pem) = device_key_pem {
debug!("canopy: tailscale unreachable, falling back to mTLS");
let http = build_mtls_http(&make_builder, pem)?;
return Ok(Some(Self {
device_key,
tamanu_version,
make_builder,
state: RwLock::new(State::Mtls(http)),
}));
}
Ok(None)
}
pub async fn is_tailscale(&self) -> bool {
self.state.read().await.is_tailscale()
}
pub async fn refresh(&self) -> Result<()> {
if let Some(http) = probe_tailscale(&self.make_builder).await {
let mut state = self.state.write().await;
if !state.is_tailscale() {
debug!("canopy refresh: switching to tailscale path");
}
*state = State::Tailscale(http);
return Ok(());
}
if let Some(pem) = &self.device_key {
let http = build_mtls_http(&self.make_builder, &pem.0)?;
let mut state = self.state.write().await;
if state.is_tailscale() {
debug!("canopy refresh: tailscale dropped, falling back to mTLS");
}
*state = State::Mtls(http);
return Ok(());
}
debug!("canopy refresh: no auth path available, keeping current state");
Ok(())
}
pub async fn renew(&self) -> Result<()> {
let Some(pem) = &self.device_key else {
return Ok(());
};
let mut state = self.state.write().await;
if state.is_tailscale() {
return Ok(());
}
*state = State::Mtls(build_mtls_http(&self.make_builder, &pem.0)?);
Ok(())
}
pub async fn post_status(
&self,
base_url: &Url,
server_id: &str,
payload: &serde_json::Value,
) -> Result<()> {
let (http, url) = {
let state = self.state.read().await;
let url = match &*state {
State::Tailscale(_) => format!("{TAILSCALE_URL}/public/status/{server_id}")
.parse::<Url>()
.into_diagnostic()
.wrap_err("building tailscale /public/status URL")?,
State::Mtls(_) => base_url
.join(&format!("/status/{server_id}"))
.into_diagnostic()
.wrap_err("building /status URL")?,
};
(state.http(), url)
};
let raw = serde_json::to_vec(payload)
.into_diagnostic()
.wrap_err("serialising canopy /status payload")?;
let compressed = gzip_bytes(&raw)
.into_diagnostic()
.wrap_err("gzipping canopy /status payload")?;
debug!(
%url,
raw_bytes = raw.len(),
gzip_bytes = compressed.len(),
"posting status snapshot to canopy",
);
let response = http
.post(url)
.header("X-Version", &self.tamanu_version)
.header(reqwest::header::CONTENT_TYPE, "application/json")
.header(reqwest::header::CONTENT_ENCODING, "gzip")
.body(compressed)
.send()
.await
.into_diagnostic()
.wrap_err("posting status to canopy")?;
let status = response.status();
if !status.is_success() {
let body = response.text().await.unwrap_or_default();
return Err(miette::miette!("canopy /status returned {status}: {body}"));
}
Ok(())
}
pub async fn get(
&self,
base_url: &Url,
tailscale_path: &str,
mtls_path: &str,
) -> Result<reqwest::Response> {
let (http, url) = {
let state = self.state.read().await;
let url = match &*state {
State::Tailscale(_) => format!("{TAILSCALE_URL}{tailscale_path}")
.parse::<Url>()
.into_diagnostic()
.wrap_err("building tailscale GET URL")?,
State::Mtls(_) => base_url
.join(mtls_path)
.into_diagnostic()
.wrap_err("building mTLS GET URL")?,
};
(state.http(), url)
};
debug!(%url, "GET via canopy");
http.get(url)
.header("X-Version", &self.tamanu_version)
.send()
.await
.into_diagnostic()
.wrap_err("GET via canopy")
}
pub async fn post_event(&self, base_url: &Url, event: NewEvent<'_>) -> Result<()> {
let (http, url) = {
let state = self.state.read().await;
let url = match &*state {
State::Tailscale(_) => format!("{TAILSCALE_URL}/public/events")
.parse::<Url>()
.into_diagnostic()
.wrap_err("building tailscale /public/events URL")?,
State::Mtls(_) => base_url
.join("/events")
.into_diagnostic()
.wrap_err("building /events URL")?,
};
(state.http(), url)
};
debug!(
%url,
source = event.source,
r#ref = event.r#ref,
active = ?event.active,
"posting event to canopy"
);
let response = http
.post(url)
.header("X-Version", &self.tamanu_version)
.json(&event)
.send()
.await
.into_diagnostic()
.wrap_err("posting event to canopy")?;
let status = response.status();
if !status.is_success() {
let body = response.text().await.unwrap_or_default();
return Err(miette::miette!("canopy /events returned {status}: {body}"));
}
Ok(())
}
}
async fn probe_tailscale(make_builder: &ClientBuilderFactory) -> Option<reqwest::Client> {
let dns_addrs: Vec<SocketAddr> = tailscale_resolver()
.lookup_ip("canopy")
.await
.ok()
.map(|addrs| addrs.iter().map(|ip| SocketAddr::new(ip, 443)).collect())
.unwrap_or_default();
if !dns_addrs.is_empty()
&& let Some(client) = try_probe(&dns_addrs, make_builder).await
{
return Some(client);
}
let hardcoded = [
SocketAddr::new(IpAddr::V4(CANOPY_HARDCODED_V4), 443),
SocketAddr::new(IpAddr::V6(CANOPY_HARDCODED_V6), 443),
];
debug!(
?hardcoded,
"canopy tailscale DNS lookup empty or probe failed, trying hardcoded IPs"
);
try_probe(&hardcoded, make_builder).await
}
async fn try_probe(
addrs: &[SocketAddr],
make_builder: &ClientBuilderFactory,
) -> Option<reqwest::Client> {
let client = make_builder()
.timeout(TAILSCALE_PROBE_TIMEOUT)
.resolve_to_addrs(TAILSCALE_HOST, addrs)
.build()
.ok()?;
let url = format!("{TAILSCALE_URL}/public/servers");
match client.get(&url).send().await {
Ok(resp) if resp.status().is_success() => Some(client),
Ok(resp) => {
debug!(status = %resp.status(), ?addrs, "canopy tailscale probe: unexpected status");
None
}
Err(err) => {
debug!(?addrs, "canopy tailscale probe failed: {err}");
None
}
}
}
fn tailscale_resolver() -> Resolver<impl ConnectionProvider> {
Resolver::builder_with_config(
ResolverConfig::from_parts(
None,
vec!["tail53aef.ts.net.".parse().unwrap()],
vec![NameServerConfig::new(
"100.100.100.100".parse().unwrap(),
true,
vec![ConnectionConfig::udp()],
)],
),
TokioRuntimeProvider::default(),
)
.build()
.expect("tailscale resolver config is hardcoded and cannot fail to build")
}
fn gzip_bytes(bytes: &[u8]) -> std::io::Result<Vec<u8>> {
let mut encoder = GzEncoder::new(Vec::with_capacity(bytes.len() / 2), Compression::default());
encoder.write_all(bytes)?;
encoder.finish()
}
pub fn device_identity(device_key_pem: &str) -> Result<reqwest::Identity> {
let key_pair = KeyPair::from_pem(device_key_pem)
.into_diagnostic()
.wrap_err("parsing device key PEM")?;
let mut params = CertificateParams::new(vec!["device.local".into()])
.into_diagnostic()
.wrap_err("building certificate params")?;
params.distinguished_name = DistinguishedName::new();
params
.distinguished_name
.push(DnType::CommonName, "device.local");
let now = OffsetDateTime::now_utc();
params.not_before = now - TimeDuration::minutes(1);
params.not_after = now + TimeDuration::days(CERT_VALIDITY_DAYS);
let cert = params
.self_signed(&key_pair)
.into_diagnostic()
.wrap_err("self-signing certificate")?;
let mut combined = cert.pem();
combined.push('\n');
combined.push_str(&key_pair.serialize_pem());
reqwest::Identity::from_pem(combined.as_bytes())
.into_diagnostic()
.wrap_err("building reqwest TLS identity")
}
fn build_mtls_http(
make_builder: &ClientBuilderFactory,
device_key_pem: &str,
) -> Result<reqwest::Client> {
let identity = device_identity(device_key_pem)?;
make_builder()
.identity(identity)
.use_rustls_tls()
.timeout(Duration::from_secs(30))
.build()
.into_diagnostic()
.wrap_err("building canopy HTTP client")
}
#[cfg(test)]
mod tests {
use super::*;
const TEST_DEVICE_KEY: &str = "\
-----BEGIN PRIVATE KEY-----
MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgVvhzsYiidp38GYn1
KxD5Wipc/h8lglVsy1UFZq/SZbGhRANCAAT2EsEq7xjeWVnim9XwdYXga/LBbppm
fXLgamTYOa/w9n/Ta64fiYWmN54kEd0DgnflJDLtID321Zz6xswvK/VN
-----END PRIVATE KEY-----";
fn test_factory() -> ClientBuilderFactory {
Arc::new(reqwest::Client::builder)
}
#[test]
fn build_mtls_http_from_p256_key() {
let result = build_mtls_http(&test_factory(), TEST_DEVICE_KEY);
assert!(result.is_ok(), "{:?}", result.err());
}
#[test]
fn build_mtls_http_fails_on_garbage_key() {
assert!(build_mtls_http(&test_factory(), "not a real PEM").is_err());
}
#[tokio::test]
async fn renew_with_mtls_state_swaps_in_fresh_client() {
let http = build_mtls_http(&test_factory(), TEST_DEVICE_KEY).unwrap();
let client = CanopyClient {
device_key: Some(Redacted(TEST_DEVICE_KEY.to_owned())),
tamanu_version: "2.54.2".into(),
make_builder: test_factory(),
state: RwLock::new(State::Mtls(http)),
};
client.renew().await.expect("renew should succeed");
assert!(!client.is_tailscale().await);
}
#[tokio::test]
async fn renew_is_noop_in_tailscale_mode() {
let http = reqwest::Client::new();
let client = CanopyClient {
device_key: None,
tamanu_version: "2.54.2".into(),
make_builder: test_factory(),
state: RwLock::new(State::Tailscale(http)),
};
client.renew().await.expect("renew should be a no-op");
assert!(client.is_tailscale().await);
}
#[test]
fn user_agent_has_product_and_os_comment() {
let ua = user_agent("bestool", "1.2.3");
assert!(
ua.starts_with("bestool/1.2.3 "),
"unexpected user-agent: {ua}"
);
assert!(ua.contains('('), "expected OS comment in: {ua}");
assert!(ua.ends_with(')'), "expected OS comment in: {ua}");
assert!(
ua.contains(sysinfo::System::cpu_arch().as_str()),
"expected arch in: {ua}"
);
}
#[test]
fn gzip_bytes_roundtrips() {
use flate2::read::GzDecoder;
use std::io::Read;
let original = br#"{"healthy":true,"health":[{"check":"x","healthy":true}]}"#;
let compressed = gzip_bytes(original).expect("gzip should succeed");
assert!(
compressed.starts_with(&[0x1f, 0x8b]),
"expected gzip magic bytes"
);
let mut decoder = GzDecoder::new(&compressed[..]);
let mut decompressed = Vec::new();
decoder.read_to_end(&mut decompressed).unwrap();
assert_eq!(decompressed, original);
}
#[test]
fn severity_serialises_lowercase() {
assert_eq!(
serde_json::to_string(&Severity::Warning).unwrap(),
"\"warning\""
);
assert_eq!(
serde_json::to_string(&Severity::Critical).unwrap(),
"\"critical\""
);
}
#[test]
fn new_event_omits_optional_fields() {
let evt = NewEvent {
source: "src",
r#ref: "host/alert:tgt",
message: "msg",
description: None,
severity: None,
occurred_at: None,
active: None,
};
let json = serde_json::to_string(&evt).unwrap();
assert!(json.contains("\"source\":\"src\""));
assert!(json.contains("\"ref\":\"host/alert:tgt\""));
assert!(json.contains("\"message\":\"msg\""));
assert!(!json.contains("description"));
assert!(!json.contains("severity"));
assert!(!json.contains("occurredAt"));
assert!(!json.contains("active"));
}
#[test]
fn new_event_serialises_occurred_at_as_camel_case() {
let evt = NewEvent {
source: "src",
r#ref: "ref",
message: "msg",
description: Some("desc"),
severity: Some(Severity::Warning),
occurred_at: Some("2025-01-01T00:00:00Z".parse().unwrap()),
active: Some(true),
};
let json = serde_json::to_string(&evt).unwrap();
assert!(json.contains("\"occurredAt\":"));
assert!(json.contains("\"description\":\"desc\""));
assert!(json.contains("\"severity\":\"warning\""));
assert!(json.contains("\"active\":true"));
}
}