节点在线、应用在线、配置在线使用令牌查询
大石头 authored at 2021-12-16 19:49:30
15.68 KiB
Stardust
//! Configuration loading, environment overrides and atomic persistence.

use std::io::Write;
use std::path::{Path, PathBuf};
use std::sync::RwLock;

use anyhow::{bail, Context, Result};
use serde::{Deserialize, Serialize};

/// Persistent agent configuration. Field names match the protocol docs.
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(rename_all = "PascalCase", default)]
pub struct Config {
    /// StarServer base URL.
    pub server: String,
    /// Product identifier used by the server for filtering.
    pub product_code: String,
    /// Registered node code.
    pub code: String,
    /// Registered node secret.
    pub secret: String,
    /// Optional project name.
    pub project: String,
    /// Update channel.
    pub channel: String,
    /// Data directory for identity, events and logs.
    pub data_dir: String,
    /// Log level (`debug`, `info`, `warn`, `error`).
    pub log_level: String,
    /// Heartbeat period in seconds.
    pub metrics_interval: i64,
    /// Enables `bash` / `cmd` execution. Off by default.
    pub allow_exec: bool,
    /// Command whitelist. Empty means all built-in commands.
    pub allowed_commands: Vec<String>,
    /// Default command timeout in seconds (1..=300).
    pub command_timeout: i64,
    /// Verify TLS certificates. `false` is for testing only.
    pub tls_verify: bool,
}

impl Default for Config {
    fn default() -> Self {
        Self {
            server: String::new(),
            product_code: "StarAgent-Rust".to_string(),
            code: String::new(),
            secret: String::new(),
            project: String::new(),
            channel: "Release".to_string(),
            data_dir: default_data_dir(),
            log_level: "info".to_string(),
            metrics_interval: 60,
            allow_exec: false,
            allowed_commands: Vec::new(),
            command_timeout: 30,
            tls_verify: true,
        }
    }
}

impl Config {
    /// Applies defaults and trims user supplied values.
    pub fn normalize(&mut self) {
        self.server = self.server.trim().trim_end_matches('/').to_string();
        self.product_code = self.product_code.trim().to_string();
        if self.product_code.is_empty() {
            self.product_code = "StarAgent-Rust".to_string();
        }
        self.channel = self.channel.trim().to_string();
        if self.channel.is_empty() {
            self.channel = "Release".to_string();
        }
        self.log_level = self.log_level.trim().to_string();
        if self.log_level.is_empty() {
            self.log_level = "info".to_string();
        }
        self.data_dir = self.data_dir.trim().to_string();
        if self.data_dir.is_empty() {
            self.data_dir = default_data_dir();
        }
        if self.metrics_interval <= 0 {
            self.metrics_interval = 60;
        }
        if self.command_timeout <= 0 {
            self.command_timeout = 30;
        }
        self.allowed_commands = self
            .allowed_commands
            .iter()
            .map(|item| item.trim().to_string())
            .filter(|item| !item.is_empty())
            .collect();
    }

    /// Validates values required to run the agent.
    pub fn validate(&self) -> Result<()> {
        if self.server.is_empty() {
            bail!("Server is required (configure staragent.json or STARAGENT_SERVER)");
        }
        let url = url::Url::parse(&self.server)
            .with_context(|| format!("invalid Server {:?}", self.server))?;
        if !matches!(url.scheme(), "http" | "https") {
            bail!("unsupported Server scheme {:?}", url.scheme());
        }
        if url.host_str().is_none() {
            bail!("invalid Server {:?}", self.server);
        }
        if self.product_code.is_empty() {
            bail!("ProductCode is required");
        }
        if !(5..=3600).contains(&self.metrics_interval) {
            bail!(
                "MetricsInterval must be between 5 and 3600 seconds, got {}",
                self.metrics_interval
            );
        }
        if !(1..=300).contains(&self.command_timeout) {
            bail!(
                "CommandTimeout must be between 1 and 300 seconds, got {}",
                self.command_timeout
            );
        }
        Ok(())
    }

    /// Applies `STARAGENT_*` environment variables. Supports both the compact
    /// and the underscore forms used by the conformance runner.
    pub fn apply_env<F>(&mut self, lookup: F) -> Result<()>
    where
        F: Fn(&str) -> Option<String>,
    {
        let get = |names: &[&str]| names.iter().find_map(|name| lookup(name));

        if let Some(value) = get(&["STARAGENT_SERVER", "STARAGENT_SERVER_URL"]) {
            self.server = value;
        }
        if let Some(value) = get(&["STARAGENT_PRODUCTCODE", "STARAGENT_PRODUCT_CODE"]) {
            self.product_code = value;
        }
        if let Some(value) = get(&["STARAGENT_CODE"]) {
            self.code = value;
        }
        if let Some(value) = get(&["STARAGENT_SECRET"]) {
            self.secret = value;
        }
        if let Some(value) = get(&["STARAGENT_PROJECT"]) {
            self.project = value;
        }
        if let Some(value) = get(&["STARAGENT_CHANNEL"]) {
            self.channel = value;
        }
        if let Some(value) = get(&["STARAGENT_DATADIR", "STARAGENT_DATA_DIR"]) {
            self.data_dir = value;
        }
        if let Some(value) = get(&["STARAGENT_LOGLEVEL", "STARAGENT_LOG_LEVEL"]) {
            self.log_level = value;
        }
        if let Some(value) = get(&["STARAGENT_METRICSINTERVAL", "STARAGENT_METRICS_INTERVAL"]) {
            let parsed = value
                .trim()
                .parse::<i64>()
                .with_context(|| format!("invalid STARAGENT_METRICSINTERVAL={value:?}"))?;
            self.metrics_interval = parsed;
        }
        if let Some(value) = get(&["STARAGENT_ALLOWEXEC", "STARAGENT_ALLOW_EXEC"]) {
            self.allow_exec = parse_bool(&value)
                .with_context(|| format!("invalid STARAGENT_ALLOWEXEC={value:?}"))?;
        }
        if let Some(value) = get(&["STARAGENT_ALLOWEDCOMMANDS", "STARAGENT_ALLOWED_COMMANDS"]) {
            self.allowed_commands = split_list(&value);
        }
        if let Some(value) = get(&["STARAGENT_COMMANDTIMEOUT", "STARAGENT_COMMAND_TIMEOUT"]) {
            let parsed = value
                .trim()
                .parse::<i64>()
                .with_context(|| format!("invalid STARAGENT_COMMANDTIMEOUT={value:?}"))?;
            self.command_timeout = parsed;
        }
        if let Some(value) = get(&["STARAGENT_TLSVERIFY", "STARAGENT_TLS_VERIFY"]) {
            self.tls_verify = parse_bool(&value)
                .with_context(|| format!("invalid STARAGENT_TLSVERIFY={value:?}"))?;
        }

        Ok(())
    }
}

fn parse_bool(value: &str) -> Result<bool> {
    match value.trim().to_ascii_lowercase().as_str() {
        "1" | "true" | "yes" | "on" => Ok(true),
        "0" | "false" | "no" | "off" => Ok(false),
        other => bail!("invalid boolean {other:?}"),
    }
}

fn split_list(value: &str) -> Vec<String> {
    value
        .split([',', ';'])
        .map(|item| item.trim().to_string())
        .filter(|item| !item.is_empty())
        .collect()
}

/// Returns the default config path, honoring `STARAGENT_CONFIG`.
pub fn default_path() -> PathBuf {
    for name in ["STARAGENT_CONFIG", "STARAGENT_CONFIG_FILE"] {
        if let Ok(value) = std::env::var(name) {
            let value = value.trim();
            if !value.is_empty() {
                return PathBuf::from(value);
            }
        }
    }

    PathBuf::from("staragent.json")
}

/// Loads the config file if present, applies environment overrides and
/// normalizes the result.
pub fn load(path: &Path) -> Result<Config> {
    let mut config = Config::default();
    match std::fs::read(path) {
        Ok(data) => {
            config = serde_json::from_slice(&data)
                .with_context(|| format!("decode config {}", path.display()))?;
        }
        Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
        Err(error) => {
            return Err(error).with_context(|| format!("read config {}", path.display()));
        }
    }

    config.apply_env(|name| std::env::var(name).ok())?;
    config.normalize();
    Ok(config)
}

/// Saves the config atomically with mode 0600.
pub fn save(path: &Path, config: &Config) -> Result<()> {
    let mut config = config.clone();
    config.normalize();
    let mut data = serde_json::to_vec_pretty(&config).context("encode config")?;
    data.push(b'\n');
    write_atomic(path, &data, 0o600)
}

/// Writes a file through a temporary file in the same directory and renames
/// it, avoiding partially written files.
pub fn write_atomic(path: &Path, data: &[u8], mode: u32) -> Result<()> {
    let _ = mode;
    let dir = path
        .parent()
        .filter(|value| !value.as_os_str().is_empty())
        .unwrap_or_else(|| Path::new("."));
    std::fs::create_dir_all(dir).with_context(|| format!("create directory {}", dir.display()))?;
    #[cfg(unix)]
    {
        use std::os::unix::fs::PermissionsExt;
        let _ = std::fs::set_permissions(dir, std::fs::Permissions::from_mode(0o700));
    }

    let mut temp = tempfile::Builder::new()
        .prefix(".staragent-")
        .suffix(".tmp")
        .tempfile_in(dir)
        .context("create temporary file")?;

    #[cfg(unix)]
    {
        use std::os::unix::fs::PermissionsExt;
        temp.as_file()
            .set_permissions(std::fs::Permissions::from_mode(mode))
            .context("chmod temporary file")?;
    }

    temp.write_all(data).context("write temporary file")?;
    temp.as_file().sync_all().context("sync temporary file")?;
    temp.persist(path)
        .map_err(|error| error.error)
        .with_context(|| format!("replace {}", path.display()))?;

    #[cfg(unix)]
    {
        use std::os::unix::fs::PermissionsExt;
        std::fs::set_permissions(path, std::fs::Permissions::from_mode(mode))
            .with_context(|| format!("chmod {}", path.display()))?;
    }

    Ok(())
}

/// Thread-safe config store with atomic updates.
pub struct Store {
    path: PathBuf,
    config: RwLock<Config>,
}

impl Store {
    /// Creates a store backed by `path`.
    pub fn new(path: PathBuf, mut config: Config) -> Self {
        config.normalize();
        Self {
            path,
            config: RwLock::new(config),
        }
    }

    /// Returns the config path.
    pub fn path(&self) -> &Path {
        &self.path
    }

    /// Returns a snapshot of the current config.
    pub fn snapshot(&self) -> Config {
        self.config
            .read()
            .unwrap_or_else(|poison| poison.into_inner())
            .clone()
    }

    /// Applies a change and persists it. The in-memory state is only updated
    /// after a successful write.
    pub fn update<F>(&self, apply: F) -> Result<Config>
    where
        F: FnOnce(&mut Config),
    {
        let mut guard = self
            .config
            .write()
            .unwrap_or_else(|poison| poison.into_inner());
        let mut next = guard.clone();
        apply(&mut next);
        next.normalize();
        save(&self.path, &next)?;
        *guard = next.clone();
        Ok(next)
    }
}

/// Platform-specific data directory.
pub fn default_data_dir() -> String {
    if cfg!(windows) {
        if let Ok(program_data) = std::env::var("ProgramData") {
            if !program_data.trim().is_empty() {
                return Path::new(&program_data)
                    .join("StarAgent")
                    .join("data")
                    .to_string_lossy()
                    .into_owned();
            }
        }
        return home_dir()
            .map(|home| home.join(".staragent"))
            .unwrap_or_else(|| PathBuf::from("data"))
            .to_string_lossy()
            .into_owned();
    }

    #[cfg(unix)]
    let is_root = unsafe { libc::geteuid() == 0 };
    #[cfg(not(unix))]
    let is_root = false;

    if cfg!(target_os = "macos") {
        let base = if is_root {
            PathBuf::from("/Library/Application Support/StarAgent")
        } else {
            home_dir()
                .map(|home| {
                    home.join("Library")
                        .join("Application Support")
                        .join("StarAgent")
                })
                .unwrap_or_else(|| PathBuf::from(".staragent"))
        };
        return base.to_string_lossy().into_owned();
    }

    if is_root {
        return "/var/lib/staragent".to_string();
    }

    home_dir()
        .map(|home| home.join(".staragent"))
        .unwrap_or_else(|| PathBuf::from("data"))
        .to_string_lossy()
        .into_owned()
}

fn home_dir() -> Option<PathBuf> {
    std::env::var_os("HOME")
        .map(PathBuf::from)
        .or_else(|| std::env::var_os("USERPROFILE").map(PathBuf::from))
}

#[cfg(test)]
mod tests {
    use super::*;
    use std::collections::HashMap;

    fn env_map(values: &[(&str, &str)]) -> HashMap<String, String> {
        values
            .iter()
            .map(|(key, value)| (key.to_string(), value.to_string()))
            .collect()
    }

    #[test]
    fn applies_environment_overrides() {
        let mut config = Config::default();
        let values = env_map(&[
            ("STARAGENT_SERVER", "https://star.example.com:6600/"),
            ("STARAGENT_PRODUCT_CODE", "StarAgent-Test"),
            ("STARAGENT_DATA_DIR", "/tmp/data"),
            ("STARAGENT_METRICS_INTERVAL", "12"),
            ("STARAGENT_ALLOW_EXEC", "true"),
            ("STARAGENT_ALLOWED_COMMANDS", "node/*, bash;cmd"),
            ("STARAGENT_COMMAND_TIMEOUT", "45"),
            ("STARAGENT_TLS_VERIFY", "false"),
        ]);
        config.apply_env(|name| values.get(name).cloned()).unwrap();
        config.normalize();

        assert_eq!(config.server, "https://star.example.com:6600");
        assert_eq!(config.product_code, "StarAgent-Test");
        assert_eq!(config.metrics_interval, 12);
        assert!(config.allow_exec);
        assert_eq!(config.allowed_commands, vec!["node/*", "bash", "cmd"]);
        assert_eq!(config.command_timeout, 45);
        assert!(!config.tls_verify);
        config.validate().unwrap();
    }

    #[test]
    fn rejects_invalid_values() {
        let mut config = Config::default();
        config.server = "ftp://example.com".into();
        assert!(config.validate().is_err());

        config.server = "https://example.com".into();
        config.metrics_interval = 2;
        assert!(config.validate().is_err());

        config.metrics_interval = 60;
        config.command_timeout = 301;
        assert!(config.validate().is_err());
    }

    #[test]
    fn store_persists_and_reloads() {
        let dir = tempfile::tempdir().unwrap();
        let path = dir.path().join("staragent.json");
        let mut config = Config::default();
        config.server = "https://star.example.com".into();
        config.metrics_interval = 5;
        let store = Store::new(path.clone(), config);

        store
            .update(|current| {
                current.code = "CODE1".into();
                current.secret = "SECRET1".into();
            })
            .unwrap();

        let loaded = load(&path).unwrap();
        assert_eq!(loaded.code, "CODE1");
        assert_eq!(loaded.secret, "SECRET1");
        assert_eq!(loaded.server, "https://star.example.com");

        #[cfg(unix)]
        {
            use std::os::unix::fs::PermissionsExt;
            let mode = std::fs::metadata(&path).unwrap().permissions().mode() & 0o777;
            assert_eq!(mode, 0o600);
        }
    }

    #[test]
    fn atomic_write_creates_parent() {
        let dir = tempfile::tempdir().unwrap();
        let path = dir.path().join("nested").join("events.json");
        write_atomic(&path, b"[]", 0o600).unwrap();
        assert_eq!(std::fs::read(&path).unwrap(), b"[]");
    }
}