use std::{
path::{Path, PathBuf},
process::Command,
};
use miette::{IntoDiagnostic, Result};
use tracing::{debug, info, warn};
use uuid::Uuid;
pub fn standard_device_key_path() -> PathBuf {
if cfg!(windows) {
PathBuf::from(r"C:\Tamanu\device-key.pem")
} else {
PathBuf::from("/etc/tamanu/device-key.pem")
}
}
pub fn standard_server_id_path() -> PathBuf {
if cfg!(windows) {
PathBuf::from(r"C:\Tamanu\server-id")
} else {
PathBuf::from("/etc/tamanu/server-id")
}
}
pub async fn get_or_create_server_id(client: &tokio_postgres::Client) -> Result<String> {
let path = standard_server_id_path();
if let Some(id) = read_server_id_file(&path) {
return Ok(id);
}
if let Some(id) = query_server_id_row(client).await? {
match write_server_id_file(&path, &id) {
Ok(()) => info!(
path = %path.display(),
"copied metaServerId from Tamanu DB to standard path"
),
Err(err) => debug!(
path = %path.display(),
%err,
"could not copy metaServerId to standard path; will retry next run"
),
}
return Ok(id);
}
let id = Uuid::new_v4().to_string();
info!(server_id = %id, "generating new metaServerId");
match write_server_id_file(&path, &id) {
Ok(()) => Ok(id),
Err(err) => {
debug!(
path = %path.display(),
%err,
"could not persist new metaServerId to file; falling back to DB"
);
client
.execute(
"INSERT INTO local_system_facts (key, value) VALUES ('metaServerId', $1)",
&[&id],
)
.await
.into_diagnostic()?;
Ok(id)
}
}
}
async fn query_server_id_row(
client: &tokio_postgres::Client,
) -> Result<Option<String>> {
let row = client
.query_opt(
"SELECT value FROM local_system_facts WHERE key = 'metaServerId'",
&[],
)
.await
.into_diagnostic()?;
let Some(row) = row else {
return Ok(None);
};
let id: String = row.try_get(0).into_diagnostic()?;
debug!(server_id = %id, "found existing metaServerId in DB");
Ok(Some(id))
}
fn read_server_id_file(path: &Path) -> Option<String> {
match std::fs::read_to_string(path) {
Ok(s) => {
let trimmed = s.trim();
if trimmed.is_empty() {
warn!(path = %path.display(), "server-id file is empty; ignoring");
return None;
}
if Uuid::parse_str(trimmed).is_err() {
warn!(
path = %path.display(),
"server-id file does not contain a UUID; ignoring",
);
return None;
}
debug!(path = %path.display(), server_id = trimmed, "loaded metaServerId from standard path");
Some(trimmed.to_string())
}
Err(err) if err.kind() == std::io::ErrorKind::NotFound => None,
Err(err) => {
debug!(path = %path.display(), %err, "could not read server-id file");
None
}
}
}
fn write_server_id_file(path: &Path, id: &str) -> std::io::Result<()> {
use std::io::Write as _;
if let Some(parent) = path.parent()
&& !parent.exists()
{
return Err(std::io::Error::new(
std::io::ErrorKind::NotFound,
"parent directory does not exist",
));
}
let tmp = path.with_extension("tmp");
{
let mut f = std::fs::OpenOptions::new()
.write(true)
.create_new(true)
.open(&tmp)?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt as _;
let perms = std::fs::Permissions::from_mode(0o644);
f.set_permissions(perms)?;
}
f.write_all(id.as_bytes())?;
f.write_all(b"\n")?;
f.sync_all()?;
}
std::fs::rename(&tmp, path)
}
pub async fn fetch_device_key(database_url: &str) -> Option<String> {
fetch_device_key_with(|| fetch_device_key_from_db(database_url)).await
}
pub async fn fetch_device_key_with<F, Fut>(db_fetch: F) -> Option<String>
where
F: FnOnce() -> Fut,
Fut: std::future::Future<Output = Option<String>>,
{
let path = standard_device_key_path();
if let Some(pem) = read_device_key_file(&path) {
return Some(pem);
}
let pem = db_fetch().await?;
if !path.exists() {
match write_device_key_file(&path, &pem) {
Ok(()) => info!(
path = %path.display(),
"copied deviceKey from Tamanu DB to standard path"
),
Err(err) => debug!(
path = %path.display(),
%err,
"could not copy deviceKey to standard path; will retry next run"
),
}
}
Some(pem)
}
pub async fn query_device_key_row(client: &tokio_postgres::Client) -> Option<String> {
match client
.query_opt(
"SELECT value FROM local_system_facts WHERE key = 'deviceKey'",
&[],
)
.await
{
Ok(Some(row)) => match row.try_get::<_, String>(0) {
Ok(pem) => {
info!("loaded deviceKey from Tamanu DB for canopy targets");
Some(pem)
}
Err(err) => {
warn!("deviceKey row not a string: {err}");
None
}
},
Ok(None) => {
info!("no deviceKey in local_system_facts; canopy targets unavailable");
None
}
Err(err) => {
warn!("failed to query deviceKey: {err}");
None
}
}
}
fn read_device_key_file(path: &Path) -> Option<String> {
match std::fs::read_to_string(path) {
Ok(s) if !s.trim().is_empty() => {
debug!(path = %path.display(), "loaded deviceKey from standard path");
Some(s)
}
Ok(_) => {
warn!(path = %path.display(), "deviceKey file is empty; ignoring");
None
}
Err(err) if err.kind() == std::io::ErrorKind::NotFound => None,
Err(err) => {
debug!(path = %path.display(), %err, "could not read deviceKey file");
None
}
}
}
fn write_device_key_file(path: &Path, pem: &str) -> std::io::Result<()> {
use std::io::Write as _;
if let Some(parent) = path.parent()
&& !parent.exists()
{
return Err(std::io::Error::new(
std::io::ErrorKind::NotFound,
"parent directory does not exist",
));
}
let tmp = path.with_extension("pem.tmp");
{
let mut opts = std::fs::OpenOptions::new();
opts.write(true).create_new(true);
#[cfg(windows)]
{
use std::os::windows::fs::OpenOptionsExt as _;
const FILE_ATTRIBUTE_HIDDEN: u32 = 0x0000_0002;
opts.attributes(FILE_ATTRIBUTE_HIDDEN);
}
let mut f = opts.open(&tmp)?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt as _;
let perms = std::fs::Permissions::from_mode(0o600);
f.set_permissions(perms)?;
}
f.write_all(pem.as_bytes())?;
f.sync_all()?;
}
std::fs::rename(&tmp, path)
}
async fn fetch_device_key_from_db(database_url: &str) -> Option<String> {
let (client, connection) = match tokio_postgres::connect(database_url, tokio_postgres::NoTls)
.await
{
Ok(c) => c,
Err(err) => {
warn!("failed to connect for deviceKey fetch: {err}");
return None;
}
};
tokio::spawn(async move {
if let Err(err) = connection.await {
warn!("deviceKey-fetch connection error: {err}");
}
});
query_device_key_row(&client).await
}
pub fn get_tailscale_info() -> (Option<String>, Option<String>) {
let output = match Command::new("tailscale")
.arg("status")
.arg("--json")
.output()
{
Ok(output) if output.status.success() => output,
Ok(output) => {
debug!(
status = %output.status,
"tailscale status command failed"
);
return (None, None);
}
Err(e) => {
debug!(error = %e, "tailscale not available");
return (None, None);
}
};
let parsed: serde_json::Value = match serde_json::from_slice(&output.stdout) {
Ok(v) => v,
Err(e) => {
warn!(error = %e, "failed to parse tailscale status JSON");
return (None, None);
}
};
let self_node = &parsed["Self"];
let ip = self_node["TailscaleIPs"]
.as_array()
.and_then(|ips| ips.first())
.and_then(|ip| ip.as_str())
.map(String::from);
let name = self_node["DNSName"]
.as_str()
.map(|s| s.trim_end_matches('.').to_string());
(ip, name)
}
pub fn detect_virtualisation() -> Option<String> {
let output = Command::new("systemd-detect-virt").output().ok()?;
let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string();
if stdout.is_empty() {
return None;
}
Some(stdout)
}
#[cfg(test)]
mod tests {
use std::sync::Mutex;
use tokio_postgres::NoTls;
use super::*;
static DB_TEST_MUTEX: Mutex<()> = Mutex::new(());
async fn test_db_client() -> tokio_postgres::Client {
let url = std::env::var("DATABASE_URL").expect("DATABASE_URL must be set for tests");
let (client, connection) = tokio_postgres::connect(&url, NoTls).await.unwrap();
tokio::spawn(async move {
if let Err(e) = connection.await {
eprintln!("connection error: {e}");
}
});
client
.batch_execute(
"CREATE TABLE IF NOT EXISTS local_system_facts (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
)",
)
.await
.unwrap();
client
.execute(
"DELETE FROM local_system_facts WHERE key = 'metaServerId'",
&[],
)
.await
.unwrap();
client
}
#[tokio::test]
async fn test_get_or_create_server_id_generates_new() {
let _lock = DB_TEST_MUTEX.lock().unwrap();
let client = test_db_client().await;
let id = get_or_create_server_id(&client).await.unwrap();
assert!(!id.is_empty());
uuid::Uuid::parse_str(&id).expect("should be a valid UUID");
let row = client
.query_one(
"SELECT value FROM local_system_facts WHERE key = 'metaServerId'",
&[],
)
.await
.unwrap();
let stored: String = row.get(0);
assert_eq!(stored, id);
}
#[tokio::test]
async fn test_get_or_create_server_id_returns_existing() {
let _lock = DB_TEST_MUTEX.lock().unwrap();
let client = test_db_client().await;
client
.execute(
"INSERT INTO local_system_facts (key, value) VALUES ('metaServerId', 'existing-id-123')",
&[],
)
.await
.unwrap();
let id = get_or_create_server_id(&client).await.unwrap();
assert_eq!(id, "existing-id-123");
}
#[tokio::test]
async fn test_get_or_create_server_id_is_stable() {
let _lock = DB_TEST_MUTEX.lock().unwrap();
let client = test_db_client().await;
let id1 = get_or_create_server_id(&client).await.unwrap();
let id2 = get_or_create_server_id(&client).await.unwrap();
assert_eq!(id1, id2);
}
#[test]
fn read_device_key_file_returns_none_for_missing() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("missing.pem");
assert!(read_device_key_file(&path).is_none());
}
#[test]
fn read_device_key_file_returns_contents() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("device.pem");
std::fs::write(&path, "PEM CONTENT").unwrap();
assert_eq!(read_device_key_file(&path).as_deref(), Some("PEM CONTENT"));
}
#[test]
fn read_device_key_file_treats_empty_as_missing() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("empty.pem");
std::fs::write(&path, " \n").unwrap();
assert!(read_device_key_file(&path).is_none());
}
#[test]
fn write_device_key_file_creates_and_roundtrips() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("device.pem");
write_device_key_file(&path, "PEM").unwrap();
assert_eq!(std::fs::read_to_string(&path).unwrap(), "PEM");
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt as _;
let mode = std::fs::metadata(&path).unwrap().permissions().mode();
assert_eq!(mode & 0o777, 0o600);
}
}
#[test]
fn write_device_key_file_errors_when_parent_missing() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("nope").join("device.pem");
assert!(write_device_key_file(&path, "PEM").is_err());
}
#[test]
fn write_device_key_file_refuses_to_overwrite() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("device.pem");
std::fs::write(&path, "OLD").unwrap();
write_device_key_file(&path, "NEW").unwrap();
assert_eq!(std::fs::read_to_string(&path).unwrap(), "NEW");
}
#[test]
fn read_server_id_file_returns_none_for_missing() {
let dir = tempfile::tempdir().unwrap();
assert!(read_server_id_file(&dir.path().join("missing")).is_none());
}
#[test]
fn read_server_id_file_returns_uuid() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("server-id");
let id = "7deb2793-0425-427e-8a19-7213946fa9be";
std::fs::write(&path, format!("{id}\n")).unwrap();
assert_eq!(read_server_id_file(&path).as_deref(), Some(id));
}
#[test]
fn read_server_id_file_rejects_non_uuid() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("server-id");
std::fs::write(&path, "not-a-uuid\n").unwrap();
assert!(read_server_id_file(&path).is_none());
}
#[test]
fn write_server_id_file_roundtrips() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("server-id");
let id = Uuid::new_v4().to_string();
write_server_id_file(&path, &id).unwrap();
assert_eq!(read_server_id_file(&path).as_deref(), Some(id.as_str()));
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt as _;
let mode = std::fs::metadata(&path).unwrap().permissions().mode();
assert_eq!(mode & 0o777, 0o644);
}
}
}