#[cfg(any(feature = "canopy-register", feature = "canopy-import"))]
use std::io::Read as _;
#[cfg(any(feature = "canopy-register", feature = "canopy-import"))]
use base64::{
Engine as _,
engine::general_purpose::{STANDARD, STANDARD_NO_PAD, URL_SAFE, URL_SAFE_NO_PAD},
};
use clap::{Parser, Subcommand};
#[cfg(any(feature = "canopy-register", feature = "canopy-import"))]
use miette::{IntoDiagnostic as _, bail, miette};
use miette::Result;
use super::Context;
#[derive(Debug, Clone, Parser)]
pub struct CanopyArgs {
#[command(subcommand)]
pub action: Action,
}
super::subcommands! {
[CanopyArgs => |args: CanopyArgs, mut ctx: Context| -> Result<(Action, Context)> {
let action = args.action.clone();
ctx.provide(args);
Ok((action, ctx))
}]
#[cfg(feature = "canopy-register")]
register => Register(RegisterArgs),
#[cfg(feature = "canopy-export")]
export => Export(ExportArgs),
#[cfg(feature = "canopy-import")]
import => Import(ImportArgs),
#[cfg(feature = "canopy-tags")]
tags => Tags(TagsArgs),
#[cfg(feature = "canopy-backup")]
backup => Backup(BackupArgs),
#[cfg(feature = "canopy-restore")]
restore => Restore(RestoreArgs),
#[cfg(feature = "canopy-restore")]
kopia => Kopia(KopiaArgs),
#[cfg(feature = "canopy-unregister")]
unregister => Unregister(UnregisterArgs)
}
#[cfg(any(feature = "canopy-register", feature = "canopy-export"))]
async fn load_registration(
config: Option<&std::path::Path>,
) -> Result<Option<bestool_canopy::registration::Registration>> {
match config {
Some(dir) => bestool_canopy::registration::load_from(dir).await,
None => bestool_canopy::registration::load().await,
}
}
#[cfg(all(
feature = "alertd",
any(feature = "canopy-register", feature = "canopy-unregister")
))]
async fn restart_daemon_for_registration_change() {
let addrs = bestool_alertd::commands::default_server_addrs();
if let Err(err) = bestool_alertd::commands::restart(&addrs).await {
tracing::debug!(%err, "alertd daemon not reachable");
println!("(alertd daemon not reachable; it will re-read the registration on next start)");
}
}
#[cfg(all(
not(feature = "alertd"),
any(feature = "canopy-register", feature = "canopy-unregister")
))]
async fn restart_daemon_for_registration_change() {}
#[cfg(all(
unix,
any(
feature = "canopy-register",
feature = "canopy-import",
feature = "canopy-unregister"
)
))]
fn ensure_writable_or_reexec(dir: &std::path::Path) -> Result<()> {
if registration_dir_writable(dir) || privilege::user::privileged() {
return Ok(());
}
tracing::info!(
dir = %dir.display(),
"registration directory is not writable; re-executing under sudo"
);
let args: Vec<String> = std::env::args().collect();
let status = std::process::Command::new("sudo")
.args(args)
.status()
.into_diagnostic()?;
std::process::exit(status.code().unwrap_or(1));
}
#[cfg(all(
not(unix),
any(
feature = "canopy-register",
feature = "canopy-import",
feature = "canopy-unregister"
)
))]
fn ensure_writable_or_reexec(_dir: &std::path::Path) -> Result<()> {
Ok(())
}
#[cfg(all(
unix,
any(
feature = "canopy-register",
feature = "canopy-import",
feature = "canopy-unregister"
)
))]
fn registration_dir_writable(dir: &std::path::Path) -> bool {
let mut candidate = dir;
let existing = loop {
if candidate.exists() {
break candidate;
}
match candidate.parent() {
Some(parent) => candidate = parent,
None => return false,
}
};
let probe = existing.join(format!(".bestool-write-test.{}", std::process::id()));
match std::fs::File::create(&probe) {
Ok(_) => {
let _ = std::fs::remove_file(&probe);
true
}
Err(_) => false,
}
}
#[cfg(any(feature = "canopy-register", feature = "canopy-import"))]
fn read_stdin(what: &str) -> Result<String> {
let mut buf = String::new();
std::io::stdin()
.read_to_string(&mut buf)
.into_diagnostic()
.map_err(|e| miette!("reading {what} from stdin: {e}"))?;
if buf.trim().is_empty() {
bail!("no {what} given on the command line or stdin");
}
Ok(buf)
}
#[cfg(any(feature = "canopy-register", feature = "canopy-import"))]
fn decode_base64(input: &str) -> Result<Vec<u8>> {
for engine in [&STANDARD, &STANDARD_NO_PAD, &URL_SAFE, &URL_SAFE_NO_PAD] {
if let Ok(bytes) = engine.decode(input) {
return Ok(bytes);
}
}
Err(miette!("input is not valid base64"))
}
#[cfg(any(feature = "canopy-register", feature = "canopy-unregister"))]
async fn clear_legacy_identity(tags_path: &std::path::Path) -> Vec<String> {
use bestool_tamanu::server_info::{
standard_device_key_path, standard_server_id_path, standard_tags_path,
};
let mut removed = Vec::new();
for path in [
standard_device_key_path(),
standard_server_id_path(),
tags_path.to_path_buf(),
standard_tags_path(),
] {
match std::fs::remove_file(&path) {
Ok(()) => removed.push(path.display().to_string()),
Err(err) if err.kind() == std::io::ErrorKind::NotFound => {}
Err(err) => tracing::warn!("could not remove {}: {err}", path.display()),
}
}
match delete_identity_db_rows().await {
DbOutcome::Deleted(n) if n > 0 => {
removed.push(format!("{n} local_system_facts row(s) (deviceKey/metaServerId)"));
}
DbOutcome::Deleted(_) => {}
DbOutcome::Skipped(why) => {
tracing::warn!("{why}; leaving any deviceKey/metaServerId DB rows in place");
}
}
removed
}
#[cfg(any(feature = "canopy-register", feature = "canopy-unregister"))]
enum DbOutcome {
Deleted(u64),
Skipped(String),
}
#[cfg(any(feature = "canopy-register", feature = "canopy-unregister"))]
async fn delete_identity_db_rows() -> DbOutcome {
let url = match resolve_database_url().await {
Ok(url) => url,
Err(why) => return DbOutcome::Skipped(why),
};
let client = match bestool_postgres::pool::connect_one(&url, "bestool-canopy-identity-cleanup").await
{
Ok(client) => client,
Err(err) => {
return DbOutcome::Skipped(format!("could not connect to the Tamanu database: {err}"));
}
};
match client
.execute(
"DELETE FROM local_system_facts WHERE key IN ('deviceKey', 'metaServerId')",
&[],
)
.await
{
Ok(n) => DbOutcome::Deleted(n),
Err(err) => DbOutcome::Skipped(format!("could not delete the DB rows: {err}")),
}
}
#[cfg(any(feature = "canopy-register", feature = "canopy-unregister"))]
async fn resolve_database_url() -> Result<String, String> {
use bestool_tamanu::config::{database_url_override, load_config};
if let Some(url) = database_url_override() {
return Ok(url);
}
match bestool_tamanu::try_find_tamanu(None).await {
Ok(Some((_, root))) => match load_config(&root, None) {
Ok(config) => Ok(config.database_url()),
Err(err) => Err(format!("could not load Tamanu config: {err}")),
},
Ok(None) => Err("no Tamanu install found and TAMANU_DATABASE_URL not set".into()),
Err(err) => Err(format!("could not locate Tamanu: {err}")),
}
}
#[cfg(test)]
#[cfg(any(feature = "canopy-register", feature = "canopy-import"))]
mod tests {
use super::*;
#[test]
fn decode_base64_accepts_all_variants() {
let raw = b"\x00\xff\x10hello world?!";
for encoded in [
STANDARD.encode(raw),
STANDARD_NO_PAD.encode(raw),
URL_SAFE.encode(raw),
URL_SAFE_NO_PAD.encode(raw),
] {
assert_eq!(decode_base64(&encoded).unwrap(), raw);
}
}
#[test]
fn decode_base64_rejects_garbage() {
assert!(decode_base64("not valid base64 !!!! \u{00a0}").is_err());
}
#[cfg(unix)]
#[test]
fn registration_dir_writable_true_for_writable_dir() {
let dir = std::env::temp_dir().join(format!("bestool-canopy-rw-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
assert!(registration_dir_writable(&dir), "fresh temp dir should be writable");
assert!(registration_dir_writable(&dir.join("missing/deeper")));
std::fs::remove_dir_all(&dir).ok();
}
#[cfg(unix)]
#[test]
fn registration_dir_writable_false_for_readonly_dir() {
use std::os::unix::fs::PermissionsExt as _;
if privilege::user::privileged() {
return;
}
let dir = std::env::temp_dir().join(format!("bestool-canopy-ro-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o500)).unwrap();
assert!(!registration_dir_writable(&dir), "0500 dir must not be writable");
std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o700)).ok();
std::fs::remove_dir_all(&dir).ok();
}
}