//! Agent configuration: TOML file + environment overrides. //! //! Multi-instance is foundational, not bolted on: one agent supervises N game //! instances on the host, each declared as an `[[instance]]` block. Connection //! secrets may come from env so the config file can be world-readable-ish //! while the token is not. use anyhow::{bail, Context, Result}; use serde::Deserialize; use std::collections::HashSet; use std::path::{Path, PathBuf}; use crate::rcon::RconConfig; use crate::steamcmd::SteamcmdConfig; /// Instance ids share the NATS subject namespace with host-level segments. const RESERVED_INSTANCE_IDS: &[&str] = &["host", "cmd", "files", "update", "agent"]; pub const SUPPORTED_GAMES: &[&str] = &["rust", "conan", "soulmask", "dune"]; #[derive(Debug, Clone, Deserialize)] #[serde(deny_unknown_fields)] pub struct ConfigFile { pub agent: AgentSection, #[serde(default, rename = "instance")] pub instances: Vec, #[serde(default)] pub prober: ProberSection, } #[derive(Debug, Clone, Deserialize)] #[serde(deny_unknown_fields)] pub struct AgentSection { pub license_id: Option, pub nats_url: Option, pub nats_token: Option, /// NATS username for per-license auth. Defaults to license_id when a /// password is set but no user is given. pub nats_user: Option, /// NATS password (the per-license token). When set, the agent authenticates /// with user+password instead of a bare token. pub nats_password: Option, #[serde(default = "default_heartbeat_seconds")] pub heartbeat_seconds: u64, #[serde(default = "default_log_level")] pub log_level: String, } #[derive(Debug, Clone, Deserialize)] #[serde(deny_unknown_fields)] pub struct InstanceConfig { /// Short slug, unique per license: becomes a NATS subject segment. pub id: String, /// One of SUPPORTED_GAMES. pub game: String, /// Install root for this instance on the host. pub root: PathBuf, /// Optional human label shown in the panel. #[serde(default)] pub label: Option, /// Game server executable. Relative paths resolve against `root`. /// Absent = unmanaged instance (telemetry only, no process control). #[serde(default)] pub executable: Option, /// Arguments as a proper list — no shell splitting, quoted values survive. #[serde(default)] pub args: Vec, /// Working directory for the process. Defaults to the executable's directory. #[serde(default)] pub working_dir: Option, /// RCON connection settings for this instance. Absent = rcon unavailable. /// Protocol defaults to WebRcon for rust, Source for conan/soulmask. #[serde(default)] pub rcon: Option, /// SteamCMD update settings. Absent = defaults apply (steamcmd on PATH, /// validate = false). #[serde(default)] pub steamcmd: Option, } impl InstanceConfig { /// Absolute executable path, if this instance is process-managed. pub fn resolved_executable(&self) -> Option { self.executable.as_ref().map(|exe| { if exe.is_absolute() { exe.clone() } else { self.root.join(exe) } }) } } #[derive(Debug, Clone, Default, Deserialize)] #[serde(deny_unknown_fields)] pub struct ProberSection { #[serde(default = "default_probe_interval")] pub interval_seconds: u64, /// Extra TCP targets beyond the built-in defaults. #[serde(default, rename = "target")] pub targets: Vec, } #[derive(Debug, Clone, Deserialize)] #[serde(deny_unknown_fields)] pub struct ProbeTargetConfig { pub name: String, pub host: String, pub port: u16, } fn default_heartbeat_seconds() -> u64 { 60 } fn default_probe_interval() -> u64 { 300 } fn default_log_level() -> String { "info".to_string() } /// Fully-resolved settings after merging file + env. Everything required is /// present and validated. #[derive(Debug, Clone)] pub struct Settings { pub license_id: String, pub nats_url: String, pub nats_token: Option, pub nats_user: Option, pub nats_password: Option, pub heartbeat_seconds: u64, pub log_level: String, pub instances: Vec, pub probe_interval_seconds: u64, pub probe_targets: Vec, } pub fn default_config_path() -> PathBuf { #[cfg(windows)] { PathBuf::from(r"C:\ProgramData\Corrosion\agent.toml") } #[cfg(not(windows))] { PathBuf::from("/etc/corrosion/agent.toml") } } pub fn load(path: &Path) -> Result { let raw = std::fs::read_to_string(path) .with_context(|| format!("reading config file {}", path.display()))?; let file: ConfigFile = toml::from_str(&raw) .with_context(|| format!("parsing config file {}", path.display()))?; resolve(file) } /// Merge env overrides (env wins) and validate. fn resolve(file: ConfigFile) -> Result { let license_id = std::env::var("CORROSION_LICENSE_ID") .ok() .filter(|v| !v.is_empty()) .or(file.agent.license_id) .context("license_id missing: set [agent].license_id or CORROSION_LICENSE_ID")?; let nats_url = std::env::var("CORROSION_NATS_URL") .ok() .filter(|v| !v.is_empty()) .or(file.agent.nats_url) .context("nats_url missing: set [agent].nats_url or CORROSION_NATS_URL")?; let nats_token = std::env::var("CORROSION_NATS_TOKEN") .ok() .filter(|v| !v.is_empty()) .or(file.agent.nats_token); let nats_user = std::env::var("CORROSION_NATS_USER") .ok() .filter(|v| !v.is_empty()) .or(file.agent.nats_user); let nats_password = std::env::var("CORROSION_NATS_PASSWORD") .ok() .filter(|v| !v.is_empty()) .or(file.agent.nats_password); validate_subject_segment("license_id", &license_id)?; let mut seen: HashSet<&str> = HashSet::new(); for inst in &file.instances { validate_subject_segment("instance id", &inst.id)?; if RESERVED_INSTANCE_IDS.contains(&inst.id.as_str()) { bail!("instance id '{}' is reserved", inst.id); } if !seen.insert(inst.id.as_str()) { bail!("duplicate instance id '{}'", inst.id); } if !SUPPORTED_GAMES.contains(&inst.game.as_str()) { bail!( "instance '{}': unsupported game '{}' (supported: {})", inst.id, inst.game, SUPPORTED_GAMES.join(", ") ); } } if file.agent.heartbeat_seconds < 10 { bail!("[agent].heartbeat_seconds must be >= 10"); } Ok(Settings { license_id, nats_url, nats_token, nats_user, nats_password, heartbeat_seconds: file.agent.heartbeat_seconds, log_level: file.agent.log_level, instances: file.instances, probe_interval_seconds: file.prober.interval_seconds.max(30), probe_targets: file.prober.targets, }) } /// NATS subject segments must not contain '.', '*', '>', whitespace, etc. /// Keep it strict: lowercase alphanumerics plus '-' and '_', max 64 chars. fn validate_subject_segment(what: &str, value: &str) -> Result<()> { if value.is_empty() || value.len() > 64 { bail!("{what} '{value}' must be 1-64 characters"); } if !value .chars() .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-' || c == '_') { bail!("{what} '{value}' may only contain lowercase letters, digits, '-' and '_'"); } Ok(()) }