use std::{
fmt,
future::Future,
net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr},
sync::{Arc, Mutex, OnceLock},
time::{Duration, Instant},
};
use hickory_resolver::{
ConnectionProvider, Resolver,
config::{ConnectionConfig, NameServerConfig, ResolverConfig},
net::runtime::TokioRuntimeProvider,
};
use miette::{IntoDiagnostic, Result, WrapErr};
use rcgen::{CertificateParams, DistinguishedName, DnType, KeyPair};
use reqwest::Url;
use time::{Duration as TimeDuration, OffsetDateTime};
use tokio::sync::RwLock;
use tracing::debug;
use crate::{
Redacted,
transport::{CanopyRequest, CanopyResponse, CanopyTransport},
};
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);
const DNS_LOOKUP_TIMEOUT: Duration = Duration::from_secs(2);
const PROBE_CACHE_TTL: Duration = Duration::from_secs(60);
pub type ClientBuilderFactory = Arc<dyn Fn() -> reqwest::ClientBuilder + Send + Sync>;
fn user_agent() -> &'static str {
static UA: OnceLock<String> = OnceLock::new();
UA.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!(
"bestool-canopy/{} ({os}; {})",
env!("CARGO_PKG_VERSION"),
sysinfo::System::cpu_arch(),
)
})
}
pub async fn tailscale_client(make_builder: &ClientBuilderFactory) -> Option<reqwest::Client> {
let tailscale_url = TAILSCALE_URL
.parse()
.expect("default tailscale URL is valid");
probe_tailscale(&tailscale_url, make_builder, true).await
}
pub struct ReqwestTransport {
base_url: Url,
tailscale_url: Url,
device_key: Option<Redacted<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 ReqwestTransport {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("ReqwestTransport").finish_non_exhaustive()
}
}
impl ReqwestTransport {
pub async fn new(
base_url: Url,
tailscale_url: Url,
device_key_pem: Option<&str>,
make_builder: impl Fn() -> reqwest::ClientBuilder + Send + Sync + 'static,
) -> Result<Option<Self>> {
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(&tailscale_url, &make_builder, true).await {
debug!("canopy: tailscale endpoint reachable, preferring it");
return Ok(Some(Self {
base_url,
tailscale_url,
device_key,
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 {
base_url,
tailscale_url,
device_key,
make_builder,
state: RwLock::new(State::Mtls(http)),
}));
}
Ok(None)
}
#[cfg(test)]
pub(crate) fn mtls_for_tests(base: &str) -> Self {
use crate::test_support::{TEST_DEVICE_KEY, test_factory};
let http = build_mtls_http(&test_factory(), TEST_DEVICE_KEY).unwrap();
Self {
base_url: base.parse().unwrap(),
tailscale_url: TAILSCALE_URL.parse().unwrap(),
device_key: Some(Redacted(TEST_DEVICE_KEY.to_owned())),
make_builder: test_factory(),
state: RwLock::new(State::Mtls(http)),
}
}
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.tailscale_url, &self.make_builder, false).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(())
}
async fn endpoint_url(&self, path: &str) -> Result<(reqwest::Client, Url)> {
let state = self.state.read().await;
let url = match &*state {
State::Tailscale(_) => self
.tailscale_url
.join(&format!("/public{path}"))
.into_diagnostic()
.wrap_err_with(|| format!("building tailscale /public{path} URL"))?,
State::Mtls(_) => self
.base_url
.join(path)
.into_diagnostic()
.wrap_err_with(|| format!("building {path} URL"))?,
};
Ok((state.http(), url))
}
#[cfg(feature = "raw-requests")]
pub async fn get(&self, tailscale_path: &str, mtls_path: &str) -> Result<reqwest::Response> {
let (http, url) = {
let state = self.state.read().await;
let url = match &*state {
State::Tailscale(_) => self
.tailscale_url
.join(tailscale_path)
.into_diagnostic()
.wrap_err("building tailscale GET URL")?,
State::Mtls(_) => self
.base_url
.join(mtls_path)
.into_diagnostic()
.wrap_err("building mTLS GET URL")?,
};
(state.http(), url)
};
debug!(%url, "GET via canopy");
http.get(url)
.send()
.await
.into_diagnostic()
.wrap_err("GET via canopy")
}
#[cfg(feature = "raw-requests")]
pub async fn request(
&self,
method: reqwest::Method,
path: &str,
) -> Result<reqwest::RequestBuilder> {
let (http, url) = self.endpoint_url(path).await?;
debug!(%url, %method, "arbitrary canopy request");
Ok(http.request(method, url))
}
}
#[async_trait::async_trait]
impl CanopyTransport for ReqwestTransport {
async fn call(&self, request: CanopyRequest) -> Result<CanopyResponse> {
let (parts, body) = request.into_parts();
let path = parts.uri.to_string();
let (http, url) = self.endpoint_url(&path).await?;
debug!(%url, method = %parts.method, "canopy request");
let mut req = http.request(parts.method, url).headers(parts.headers);
if !body.is_empty() {
req = req.body(body);
}
let response = req
.send()
.await
.into_diagnostic()
.wrap_err("sending canopy request")?;
let status = response.status();
let version = response.version();
let headers = response.headers().clone();
let body = response
.bytes()
.await
.into_diagnostic()
.wrap_err("reading canopy response body")?;
let mut out = http::Response::new(body);
*out.status_mut() = status;
*out.version_mut() = version;
*out.headers_mut() = headers;
Ok(out)
}
}
async fn probe_tailscale(
tailscale_url: &Url,
make_builder: &ClientBuilderFactory,
use_cache: bool,
) -> Option<reqwest::Client> {
let host = tailscale_url.host_str()?;
if host != TAILSCALE_HOST {
return probe_once(tailscale_url, host, &[], make_builder).await;
}
if use_cache && let Some(outcome) = cached_outcome() {
debug!("canopy: reusing cached tailnet reachability");
return match outcome {
TailnetOutcome::Unreachable => None,
TailnetOutcome::Reachable(addrs) => build_probe_client(host, &addrs, make_builder),
};
}
let discovered = discover_tailnet(tailscale_url, host, make_builder).await;
store_outcome(match &discovered {
Some((addrs, _)) => TailnetOutcome::Reachable(addrs.clone()),
None => TailnetOutcome::Unreachable,
});
discovered.map(|(_, client)| client)
}
async fn discover_tailnet(
tailscale_url: &Url,
host: &str,
make_builder: &ClientBuilderFactory,
) -> Option<(Vec<SocketAddr>, reqwest::Client)> {
if !tailscale_present() {
debug!("canopy: no tailscale interface on this host; skipping tailnet probe");
return None;
}
let via_dns = async {
let addrs = resolve_via_tailscale_dns().await;
if addrs.is_empty() {
return None;
}
probe_once(tailscale_url, host, &addrs, make_builder)
.await
.map(|client| (addrs, client))
};
let via_hardcoded = async {
let addrs = vec![
SocketAddr::new(IpAddr::V4(CANOPY_HARDCODED_V4), 443),
SocketAddr::new(IpAddr::V6(CANOPY_HARDCODED_V6), 443),
];
probe_once(tailscale_url, host, &addrs, make_builder)
.await
.map(|client| (addrs, client))
};
race_first_some(via_dns, via_hardcoded).await
}
async fn resolve_via_tailscale_dns() -> Vec<SocketAddr> {
match tokio::time::timeout(DNS_LOOKUP_TIMEOUT, tailscale_resolver().lookup_ip("canopy")).await {
Ok(Ok(addrs)) => addrs.iter().map(|ip| SocketAddr::new(ip, 443)).collect(),
Ok(Err(err)) => {
debug!("canopy tailscale DNS lookup failed: {err}");
Vec::new()
}
Err(_) => {
debug!("canopy tailscale DNS lookup timed out");
Vec::new()
}
}
}
fn build_probe_client(
host: &str,
addrs: &[SocketAddr],
make_builder: &ClientBuilderFactory,
) -> Option<reqwest::Client> {
let mut builder = make_builder()
.user_agent(user_agent())
.timeout(TAILSCALE_PROBE_TIMEOUT);
if !addrs.is_empty() {
builder = builder.resolve_to_addrs(host, addrs);
}
builder.build().ok()
}
async fn probe_once(
tailscale_url: &Url,
host: &str,
addrs: &[SocketAddr],
make_builder: &ClientBuilderFactory,
) -> Option<reqwest::Client> {
let client = build_probe_client(host, addrs, make_builder)?;
let url = tailscale_url.join("/public/servers").ok()?;
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
}
}
}
async fn race_first_some<T>(
a: impl Future<Output = Option<T>>,
b: impl Future<Output = Option<T>>,
) -> Option<T> {
use futures::future::{Either, select};
let a = std::pin::pin!(a);
let b = std::pin::pin!(b);
match select(a, b).await {
Either::Left((Some(v), _)) => Some(v),
Either::Right((Some(v), _)) => Some(v),
Either::Left((None, rest)) => rest.await,
Either::Right((None, rest)) => rest.await,
}
}
fn tailscale_present() -> bool {
sysinfo::Networks::new_with_refreshed_list()
.values()
.flat_map(|net| net.ip_networks())
.any(|net| is_tailscale_addr(&net.addr))
}
fn is_tailscale_addr(addr: &IpAddr) -> bool {
match addr {
IpAddr::V4(v4) => {
let o = v4.octets();
o[0] == 100 && (64..=127).contains(&o[1])
}
IpAddr::V6(v6) => {
let s = v6.segments();
s[0] == 0xfd7a && s[1] == 0x115c && s[2] == 0xa1e0
}
}
}
#[derive(Clone)]
enum TailnetOutcome {
Reachable(Vec<SocketAddr>),
Unreachable,
}
struct CachedProbe {
stored_at: Instant,
outcome: TailnetOutcome,
}
fn probe_cache() -> &'static Mutex<Option<CachedProbe>> {
static CACHE: OnceLock<Mutex<Option<CachedProbe>>> = OnceLock::new();
CACHE.get_or_init(|| Mutex::new(None))
}
fn cached_outcome() -> Option<TailnetOutcome> {
let guard = probe_cache().lock().expect("canopy probe cache poisoned");
let entry = guard.as_ref()?;
(entry.stored_at.elapsed() < PROBE_CACHE_TTL).then(|| entry.outcome.clone())
}
fn store_outcome(outcome: TailnetOutcome) {
*probe_cache().lock().expect("canopy probe cache poisoned") = Some(CachedProbe {
stored_at: Instant::now(),
outcome,
});
}
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")
}
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()
.user_agent(user_agent())
.identity(identity)
.use_rustls_tls()
.timeout(Duration::from_secs(30))
.build()
.into_diagnostic()
.wrap_err("building canopy HTTP client")
}
#[cfg(test)]
mod tests {
use crate::test_support::{TEST_DEVICE_KEY, closed_url, serve_once, test_factory};
use super::*;
#[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 no_device_key_still_builds_over_tailscale() {
let (tailnet, _server) = serve_once("HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\n[]");
let transport = ReqwestTransport::new(
DEFAULT_CANOPY_URL.parse().unwrap(),
tailnet.parse().unwrap(),
None,
reqwest::Client::builder,
)
.await
.expect("keyless build should not error")
.expect("a reachable tailnet is an auth path in its own right");
assert!(transport.is_tailscale().await);
}
#[tokio::test]
async fn no_device_key_and_no_tailnet_leaves_no_auth_path() {
let transport = ReqwestTransport::new(
DEFAULT_CANOPY_URL.parse().unwrap(),
closed_url().parse().unwrap(),
None,
reqwest::Client::builder,
)
.await
.expect("keyless build should not error");
assert!(transport.is_none());
}
#[tokio::test]
async fn device_key_carries_the_call_when_the_tailnet_is_unreachable() {
let transport = ReqwestTransport::new(
DEFAULT_CANOPY_URL.parse().unwrap(),
closed_url().parse().unwrap(),
Some(TEST_DEVICE_KEY),
reqwest::Client::builder,
)
.await
.expect("mTLS build should not error")
.expect("a device key is an auth path when the tailnet is out of reach");
assert!(!transport.is_tailscale().await);
}
#[tokio::test]
async fn renew_with_mtls_state_swaps_in_fresh_client() {
let transport = ReqwestTransport::mtls_for_tests(DEFAULT_CANOPY_URL);
transport.renew().await.expect("renew should succeed");
assert!(!transport.is_tailscale().await);
}
#[tokio::test]
async fn renew_is_noop_in_tailscale_mode() {
let transport = ReqwestTransport {
base_url: DEFAULT_CANOPY_URL.parse().unwrap(),
tailscale_url: TAILSCALE_URL.parse().unwrap(),
device_key: None,
make_builder: test_factory(),
state: RwLock::new(State::Tailscale(reqwest::Client::new())),
};
transport.renew().await.expect("renew should be a no-op");
assert!(transport.is_tailscale().await);
}
#[tokio::test]
async fn tailscale_state_routes_under_public() {
let transport = ReqwestTransport {
base_url: DEFAULT_CANOPY_URL.parse().unwrap(),
tailscale_url: "https://tailnet.example".parse().unwrap(),
device_key: None,
make_builder: test_factory(),
state: RwLock::new(State::Tailscale(reqwest::Client::new())),
};
let (_, url) = transport.endpoint_url("/backup-target").await.unwrap();
assert_eq!(url.as_str(), "https://tailnet.example/public/backup-target");
}
#[test]
fn user_agent_identifies_the_crate_with_os_comment() {
let ua = user_agent();
assert!(
ua.starts_with(concat!("bestool-canopy/", env!("CARGO_PKG_VERSION"), " ")),
"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 tailscale_addr_classifies_cgnat_v4() {
assert!(is_tailscale_addr(&IpAddr::V4(Ipv4Addr::new(100, 64, 0, 1))));
assert!(is_tailscale_addr(&IpAddr::V4(Ipv4Addr::new(
100, 127, 255, 255
))));
assert!(is_tailscale_addr(&IpAddr::V4(CANOPY_HARDCODED_V4)));
assert!(!is_tailscale_addr(&IpAddr::V4(Ipv4Addr::new(
100, 63, 255, 255
))));
assert!(!is_tailscale_addr(&IpAddr::V4(Ipv4Addr::new(
100, 128, 0, 0
))));
assert!(!is_tailscale_addr(&IpAddr::V4(Ipv4Addr::new(10, 0, 0, 1))));
assert!(!is_tailscale_addr(&IpAddr::V4(Ipv4Addr::new(100, 0, 0, 1))));
}
#[test]
fn tailscale_addr_classifies_ula_v6() {
assert!(is_tailscale_addr(&IpAddr::V6(CANOPY_HARDCODED_V6)));
assert!(is_tailscale_addr(&IpAddr::V6(Ipv6Addr::new(
0xfd7a, 0x115c, 0xa1e0, 0, 0, 0, 0, 1
))));
assert!(!is_tailscale_addr(&IpAddr::V6(Ipv6Addr::new(
0xfd00, 0x115c, 0xa1e0, 0, 0, 0, 0, 1
))));
assert!(!is_tailscale_addr(&IpAddr::V6(Ipv6Addr::LOCALHOST)));
}
#[test]
fn probe_cache_roundtrips_and_expires() {
store_outcome(TailnetOutcome::Reachable(vec![SocketAddr::new(
IpAddr::V4(CANOPY_HARDCODED_V4),
443,
)]));
match cached_outcome() {
Some(TailnetOutcome::Reachable(addrs)) => {
assert_eq!(
addrs,
vec![SocketAddr::new(IpAddr::V4(CANOPY_HARDCODED_V4), 443)]
);
}
other => panic!(
"expected freshly stored Reachable, got {:?}",
other.is_some()
),
}
if let Some(stale) = Instant::now().checked_sub(PROBE_CACHE_TTL + Duration::from_secs(1)) {
*probe_cache().lock().unwrap() = Some(CachedProbe {
stored_at: stale,
outcome: TailnetOutcome::Unreachable,
});
assert!(cached_outcome().is_none());
}
}
}