use std::{
collections::BTreeMap,
io,
net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr},
time::Duration,
};
use serde::Serialize;
use sysinfo::{CpuRefreshKind, Disks, MemoryRefreshKind, RefreshKind, System};
use tokio::net::TcpStream;
use tracing::debug;
use url::Url;
use bestool_tamanu::server_info::{detect_node_version, detect_virtualisation};
const PROBE_TIMEOUT: Duration = Duration::from_secs(3);
const IMDS_BASE: &str = "http://169.254.169.254/latest";
const IMDS_TIMEOUT: Duration = Duration::from_secs(2);
const IPV4_PROBE_ADDR: SocketAddr = SocketAddr::new(IpAddr::V4(Ipv4Addr::new(1, 1, 1, 1)), 443);
const IPV6_PROBE_ADDR: SocketAddr = SocketAddr::new(
IpAddr::V6(Ipv6Addr::new(0x2606, 0x4700, 0x4700, 0, 0, 0, 0, 0x1111)),
443,
);
const NAT64_PROBE_HOST: &str = "ipv4only.arpa";
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct Filesystem {
pub mountpoint: String,
pub fs_type: String,
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ServerInfo {
pub bestool_version: String,
pub tamanu_version: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub tamanu_root: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub tamanu_server_kind: Option<&'static str>,
#[serde(skip_serializing_if = "Option::is_none")]
pub node_version: Option<String>,
pub hostname: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub canonical_url: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub current_sync_tick: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub timezone: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub os_timezone: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub pg_version: Option<String>,
pub uptime_secs: u64,
pub cpu_cores: usize,
pub total_memory_bytes: u64,
pub os_kind: &'static str,
#[serde(skip_serializing_if = "Option::is_none")]
pub os_name: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub os_version: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub kernel: Option<String>,
pub arch: String,
pub virtualised: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub virtualisation: Option<String>,
pub filesystems: Vec<Filesystem>,
pub ipv4: bool,
pub ipv6: bool,
pub nat64: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub instance_tags: Option<BTreeMap<String, String>>,
}
#[derive(Debug, Clone, Default)]
pub struct ServerFacts {
pub canonical_url: Option<String>,
pub current_sync_tick: Option<String>,
pub timezone: Option<String>,
pub pg_version: Option<String>,
pub tamanu_root: Option<String>,
pub tamanu_server_kind: Option<&'static str>,
}
pub async fn gather(bestool_version: &str, tamanu_version: &str, facts: ServerFacts) -> ServerInfo {
let disks = Disks::new_with_refreshed_list();
let filesystems = disks
.iter()
.map(|d| Filesystem {
mountpoint: d.mount_point().to_string_lossy().to_string(),
fs_type: d.file_system().to_string_lossy().to_string(),
})
.collect();
let virt = detect_virtualisation().await;
let virtualised = !matches!(virt.as_deref(), None | Some("none"));
let (ipv4, ipv6, nat64, instance_tags) =
futures::join!(probe_ipv4(), probe_ipv6(), probe_nat64(), fetch_imds_tags());
let os_timezone = jiff::tz::TimeZone::system()
.iana_name()
.map(|s| s.to_string());
let sys = System::new_with_specifics(
RefreshKind::nothing()
.with_cpu(CpuRefreshKind::nothing())
.with_memory(MemoryRefreshKind::nothing().with_ram()),
);
let cpu_cores = sys.cpus().len();
let total_memory_bytes = sys.total_memory();
ServerInfo {
bestool_version: bestool_version.to_string(),
tamanu_version: tamanu_version.to_string(),
tamanu_root: facts.tamanu_root,
tamanu_server_kind: facts.tamanu_server_kind,
node_version: detect_node_version().await,
hostname: System::host_name(),
canonical_url: facts.canonical_url,
current_sync_tick: facts.current_sync_tick,
timezone: facts.timezone,
os_timezone,
pg_version: facts.pg_version,
uptime_secs: System::uptime(),
cpu_cores,
total_memory_bytes,
os_kind: if cfg!(target_os = "linux") {
"linux"
} else if cfg!(target_os = "windows") {
"windows"
} else if cfg!(target_os = "macos") {
"macos"
} else {
"other"
},
os_name: System::name(),
os_version: System::os_version().or_else(System::long_os_version),
kernel: System::kernel_version(),
arch: std::env::consts::ARCH.to_string(),
virtualised,
virtualisation: virt,
filesystems,
ipv4,
ipv6,
nat64,
instance_tags,
}
}
pub(crate) async fn fetch_imds_tags() -> Option<BTreeMap<String, String>> {
let client = reqwest::Client::builder()
.timeout(IMDS_TIMEOUT)
.connect_timeout(IMDS_TIMEOUT)
.build()
.ok()?;
let token = client
.put(format!("{IMDS_BASE}/api/token"))
.header("X-aws-ec2-metadata-token-ttl-seconds", "60")
.send()
.await
.ok()?
.error_for_status()
.ok()?
.text()
.await
.ok()?;
let list = client
.get(format!("{IMDS_BASE}/meta-data/tags/instance"))
.header("X-aws-ec2-metadata-token", &token)
.send()
.await
.ok()?
.error_for_status()
.ok()?
.text()
.await
.ok()?;
let keys = parse_tag_keys(&list);
if keys.is_empty() {
return None;
}
let mut tags = BTreeMap::new();
for key in keys {
let mut url = match Url::parse(&format!("{IMDS_BASE}/meta-data/tags/instance")) {
Ok(url) => url,
Err(err) => {
debug!(%err, "could not build IMDS tag URL");
continue;
}
};
match url.path_segments_mut() {
Ok(mut segments) => {
segments.push(&key);
}
Err(()) => continue,
}
match client
.get(url)
.header("X-aws-ec2-metadata-token", &token)
.send()
.await
.and_then(|r| r.error_for_status())
{
Ok(resp) => match resp.text().await {
Ok(value) => {
tags.insert(key, value);
}
Err(err) => debug!(%key, %err, "could not read IMDS tag value"),
},
Err(err) => debug!(%key, %err, "could not fetch IMDS tag"),
}
}
if tags.is_empty() { None } else { Some(tags) }
}
fn parse_tag_keys(list: &str) -> Vec<String> {
list.lines()
.map(str::trim)
.filter(|line| !line.is_empty())
.map(String::from)
.collect()
}
async fn probe_tcp(addr: SocketAddr) -> bool {
match tokio::time::timeout(PROBE_TIMEOUT, TcpStream::connect(addr)).await {
Ok(Ok(_)) => true,
Ok(Err(err)) => {
debug!(?addr, %err, "tcp probe failed");
false
}
Err(_) => {
debug!(?addr, "tcp probe timed out");
false
}
}
}
async fn probe_ipv4() -> bool {
probe_tcp(IPV4_PROBE_ADDR).await
}
async fn probe_ipv6() -> bool {
probe_tcp(IPV6_PROBE_ADDR).await
}
async fn probe_nat64() -> bool {
let result = tokio::time::timeout(PROBE_TIMEOUT, resolve_aaaa(NAT64_PROBE_HOST)).await;
match result {
Ok(Ok(present)) => present,
Ok(Err(err)) => {
debug!(%err, "nat64 probe failed");
false
}
Err(_) => {
debug!("nat64 probe timed out");
false
}
}
}
async fn resolve_aaaa(host: &str) -> io::Result<bool> {
use hickory_resolver::TokioResolver;
let resolver = TokioResolver::builder_tokio()
.map_err(io::Error::other)?
.build()
.map_err(io::Error::other)?;
let response = resolver.ipv6_lookup(host).await.map_err(io::Error::other)?;
Ok(!response.answers().is_empty())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_tag_keys_splits_and_trims_lines() {
let list = "Name\nenvironment\naws:cloudformation:stack-name\n";
assert_eq!(
parse_tag_keys(list),
vec![
"Name".to_string(),
"environment".to_string(),
"aws:cloudformation:stack-name".to_string(),
]
);
}
#[test]
fn parse_tag_keys_drops_blank_lines() {
assert!(parse_tag_keys("").is_empty());
assert!(parse_tag_keys("\n \n\n").is_empty());
assert_eq!(parse_tag_keys("\nName\n\n"), vec!["Name".to_string()]);
}
}