aegis-tool 0.3.3

Aegis SSH client and managed host agent.
Documentation
use std::fs::OpenOptions;
#[cfg(unix)]
use std::fs::Permissions;
#[cfg(unix)]
use std::os::unix::fs::{MetadataExt, PermissionsExt};
use std::path::{Path, PathBuf};

use aegis_dto::HostId;
use anyhow::{Context, Result};
use capulus::paths::home_dir;
use capulus::store::ensure_directory;
use capulus::{InvocationLock, acquire_in_with_ui, acquire_named_in_with_ui};
use sha2::{Digest, Sha256};

use crate::config::locks_dir;

const LOCAL_SYSTEM_LOCK_NAME: &str = "aegis-system";
const USER_AUTH_LOCK_NAME: &str = "aegis-user-auth";

pub fn local_system_lock() -> Result<InvocationLock> {
    acquire_user_scoped_lock(LOCAL_SYSTEM_LOCK_NAME)
}

pub fn deployment_lock(project: &str) -> Result<InvocationLock> {
    acquire_user_scoped_lock(&format!(
        "aegis-deployment-{}",
        lock_hash(project.as_bytes())
    ))
}

pub fn host_shell_assets_lock(host_id: &HostId) -> Result<InvocationLock> {
    let name = format!("aegis-shell-assets-{}", lock_hash(host_id.as_bytes()));
    let (root, path) = prepare_user_scoped_lock_file(&name)?;
    let lock = acquire_named_in_with_ui(&root, &name, true, crate::ui::current())?;
    preserve_home_owner(&root, &path)?;
    Ok(lock)
}

pub fn user_auth_lock() -> Result<InvocationLock> {
    acquire_user_scoped_lock(USER_AUTH_LOCK_NAME)
}

pub fn prepare_user_auth_lock_for_privileged_reexec() -> Result<()> {
    prepare_user_scoped_lock_file(USER_AUTH_LOCK_NAME).map(|_| ())
}

fn prepare_user_scoped_lock_file(name: &str) -> Result<(PathBuf, PathBuf)> {
    let root = locks_dir()?;
    ensure_directory(&root, Some(0o700))?;
    let path = root.join(format!("{name}.lock"));
    if !path.exists() {
        match OpenOptions::new().create_new(true).write(true).open(&path) {
            Ok(_) => {}
            Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => {}
            Err(error) => {
                return Err(error).with_context(|| format!("failed to prepare {}", path.display()));
            }
        }
    }
    preserve_home_owner(&root, &path)?;
    secure_lock_permissions(&root, &path)?;
    Ok((root, path))
}

fn acquire_user_scoped_lock(name: &str) -> Result<InvocationLock> {
    let (root, path) = prepare_user_scoped_lock_file(name)?;
    let lock = if name == LOCAL_SYSTEM_LOCK_NAME {
        acquire_in_with_ui(&root, name, true, crate::ui::current())?
    } else {
        acquire_named_in_with_ui(&root, name, true, crate::ui::current())?
    };
    preserve_home_owner(&root, &path)?;
    Ok(lock)
}

#[cfg(unix)]
fn preserve_home_owner(root: &Path, path: &Path) -> Result<()> {
    if unsafe { libc::geteuid() } != 0 {
        return Ok(());
    }
    let home = home_dir()?;
    let home_metadata = std::fs::metadata(&home)
        .with_context(|| format!("failed to inspect {}", home.display()))?;
    let owner = (home_metadata.uid(), home_metadata.gid());
    for candidate in [root, path] {
        let metadata = std::fs::metadata(candidate)
            .with_context(|| format!("failed to inspect {}", candidate.display()))?;
        if (metadata.uid(), metadata.gid()) != owner {
            std::os::unix::fs::chown(candidate, Some(owner.0), Some(owner.1)).with_context(
                || format!("failed to preserve ownership of {}", candidate.display()),
            )?;
        }
    }
    Ok(())
}

#[cfg(not(unix))]
fn preserve_home_owner(_root: &Path, _path: &Path) -> Result<()> {
    Ok(())
}

fn secure_lock_permissions(root: &Path, path: &Path) -> Result<()> {
    #[cfg(unix)]
    {
        std::fs::set_permissions(root, Permissions::from_mode(0o700))
            .with_context(|| format!("failed to secure {}", root.display()))?;
        std::fs::set_permissions(path, Permissions::from_mode(0o600))
            .with_context(|| format!("failed to secure {}", path.display()))?;
    }
    #[cfg(not(unix))]
    {
        let _ = (root, path);
    }
    Ok(())
}

fn lock_hash(bytes: &[u8]) -> String {
    let digest = Sha256::digest(bytes);
    digest[..8]
        .iter()
        .map(|byte| format!("{byte:02x}"))
        .collect()
}

#[cfg(all(test, unix))]
mod tests {
    use std::fs;
    use std::os::unix::fs::PermissionsExt;

    use tempfile::tempdir;

    use super::secure_lock_permissions;

    #[test]
    fn administration_locks_do_not_claim_the_system_child_bypass() {
        use std::process::Command;
        const MARKER: &str = "AEGIS_TEST_LOCK_CHILD";
        if let Ok(stage) = std::env::var(MARKER) {
            if stage == "parent" {
                drop(super::user_auth_lock().unwrap());
                let _deployment = super::deployment_lock("example-project").unwrap();
                let _system = super::local_system_lock().unwrap();
                let mut command = Command::new(std::env::current_exe().unwrap());
                command
                    .args([
                        "--exact",
                        "locks::tests::administration_locks_do_not_claim_the_system_child_bypass",
                    ])
                    .env(MARKER, "child");
                capulus::configure_child_command(&mut command);
                let output = capulus::process::CaptureOptions {
                    timeout: std::time::Duration::from_secs(3),
                    ..Default::default()
                }
                .validate()
                .unwrap()
                .run(&mut command, None)
                .unwrap();
                assert!(output.status.success(), "{}", output.stderr);
            } else {
                let _lock = super::local_system_lock().unwrap();
            }
            return;
        }
        let home = tempdir().unwrap();
        let output = capulus::process::CaptureOptions::default()
            .validate()
            .unwrap()
            .run(
                Command::new(std::env::current_exe().unwrap())
                    .args([
                        "--exact",
                        "locks::tests::administration_locks_do_not_claim_the_system_child_bypass",
                    ])
                    .env(MARKER, "parent")
                    .env("HOME", home.path())
                    .env_remove("SUDO_USER")
                    .env_remove("CAPULUS_SINGLE_INSTANCE_BYPASS"),
                None,
            )
            .unwrap();
        assert!(
            output.status.success(),
            "{} {}",
            output.stdout,
            output.stderr
        );
    }

    #[test]
    fn user_scoped_lock_paths_are_private() {
        let temp = tempdir().expect("temporary directory");
        let root = temp.path().join("locks");
        fs::create_dir(&root).expect("lock directory");
        let path = root.join("aegis-system.lock");
        fs::write(&path, b"").expect("lock file");

        secure_lock_permissions(&root, &path).expect("secure lock paths");

        assert_eq!(
            0o700,
            fs::metadata(&root)
                .expect("root metadata")
                .permissions()
                .mode()
                & 0o777
        );
        assert_eq!(
            0o600,
            fs::metadata(&path)
                .expect("file metadata")
                .permissions()
                .mode()
                & 0o777
        );
    }
}