use std::net::SocketAddr; use std::path::{Path, PathBuf}; use std::time::Duration; use anyhow::{Context, Result}; use crate::cli::{ AdminTargetArgs, IssueEnrollTokenArgs, ListEnrollTokensArgs, RevokeAgentArgs, RevokeEnrollTokenArgs, ServeArgs, StateStatsArgs, }; use crate::config::types::{ DEFAULT_CONFIG, DaemonConfig, FileConfig, FileTelemetryConfig, IssueTokenSettings, StateAccessSettings, TelemetryConfig, }; impl DaemonConfig { pub fn from_serve_args(args: &ServeArgs) -> Result { let file = load_file_config(&args.config_file)?; let defaults = &*DEFAULT_CONFIG; let data_dir = args .data_dir .clone() .or(file.data_dir.clone()) .unwrap_or_else(|| defaults.data_dir.clone()); let bind = match args.bind { Some(bind) => bind, None => match file.bind { Some(ref bind) => bind.parse::().with_context(|| { format!( "invalid bind address `{bind}` in {}", args.config_file.display() ) })?, None => defaults.bind, }, }; let public_url = normalize_public_url( args.public_url .as_deref() .or(file.public_url.as_deref()) .unwrap_or(&defaults.public_url), ); let state_file_raw = args .state_file .clone() .or(file.state_file) .unwrap_or_else(|| defaults.state_file.clone()); let state_file = resolve_path(&data_dir, state_file_raw); let command_timeout = args .command_timeout_ms .map(Duration::from_millis) .or(file.command_timeout) .unwrap_or(defaults.command_timeout); let enroll_token_ttl = args .enroll_token_ttl_seconds .map(Duration::from_secs) .or(file.enroll_token_ttl) .unwrap_or(defaults.enroll_token_ttl); let history_retention = args .observation_retention_seconds .map(Duration::from_secs) .or(file.history_retention) .unwrap_or(defaults.history_retention); validate_durations(command_timeout, enroll_token_ttl, history_retention)?; let device_retention = file.device_retention.unwrap_or(defaults.device_retention); validate_device_retention(device_retention)?; let pid_file_raw = args .pid_file .clone() .or(file.pid_file) .unwrap_or_else(|| defaults.pid_file.clone()); let pid_file = resolve_path(&data_dir, pid_file_raw); let ui_dist_dir = args .ui_dist_dir .clone() .or(file.ui_dist_dir) .unwrap_or_else(|| defaults.ui_dist_dir.clone()); let bootstrap_enroll_tokens = if args.bootstrap_enroll_tokens.is_empty() { file.bootstrap_enroll_tokens .unwrap_or_else(|| defaults.bootstrap_enroll_tokens.clone()) } else { args.bootstrap_enroll_tokens.clone() }; let telemetry = resolve_telemetry(file.telemetry); Ok(Self { data_dir, bind, public_url, state_file, command_timeout, enroll_token_ttl, history_retention, device_retention, pid_file, ui_dist_dir, bootstrap_enroll_tokens, telemetry, access: file.access, }) } } pub fn normalize_public_url(url: &str) -> String { url.trim_end_matches('/').to_string() } pub fn issue_token_endpoint(base_url: &str) -> String { format!( "{}/api/v1/control/enroll-token", normalize_public_url(base_url) ) } pub fn resolve_path(data_dir: &Path, candidate: PathBuf) -> PathBuf { if candidate.is_absolute() { candidate } else { data_dir.join(candidate) } } pub fn resolve_issue_token_settings(args: &IssueEnrollTokenArgs) -> Result { let file = load_file_config(&args.config_file)?; let defaults = &*DEFAULT_CONFIG; let state = resolve_state_access( &args.config_file, args.data_dir.clone(), args.state_file.clone(), args.public_url.clone(), &args.target, )?; let ttl = args .ttl_seconds .map(Duration::from_secs) .or(file.enroll_token_ttl) .unwrap_or(defaults.enroll_token_ttl); validate_durations(defaults.command_timeout, ttl, defaults.history_retention)?; Ok(IssueTokenSettings { data_dir: state.data_dir, state_file: state.state_file, public_url: state.public_url, ttl, }) } pub fn resolve_list_enroll_token_settings( args: &ListEnrollTokensArgs, ) -> Result { resolve_state_access( &args.config_file, args.data_dir.clone(), args.state_file.clone(), args.public_url.clone(), &args.target, ) } pub fn resolve_revoke_enroll_token_settings( args: &RevokeEnrollTokenArgs, ) -> Result { resolve_state_access( &args.config_file, args.data_dir.clone(), args.state_file.clone(), args.public_url.clone(), &args.target, ) } pub fn resolve_revoke_agent_settings(args: &RevokeAgentArgs) -> Result { resolve_state_access( &args.config_file, args.data_dir.clone(), args.state_file.clone(), args.public_url.clone(), &args.target, ) } pub fn resolve_state_stats_settings(args: &StateStatsArgs) -> Result { resolve_state_access( &args.config_file, args.data_dir.clone(), args.state_file.clone(), args.public_url.clone(), &args.target, ) } pub(crate) fn load_file_config(path: &Path) -> Result { if !path.exists() { return Ok(FileConfig::default()); } let raw = std::fs::read_to_string(path) .with_context(|| format!("failed to read config file {}", path.display()))?; let raw: toml::Value = toml::from_str(&raw) .with_context(|| format!("failed to parse config file {}", path.display()))?; let mut value = serde_json::to_value(raw)?; let deprecated = wakey_core::config_duration::normalize(&mut value, super::types::DURATION_KEYS) .map_err(anyhow::Error::msg)?; let config = serde_json::from_value(value) .with_context(|| format!("failed to parse config file {}", path.display()))?; for key in deprecated { // Daemon config is loaded before telemetry initialization. Keep these // warnings on stderr, including when init-config emits TOML on stdout. eprintln!( "warning: {}: `{}` is deprecated; use `{}` with a duration string", path.display(), key.old, key.new ); } Ok(config) } /// Device expiry uses whole-second snapshot timestamps, not an active timer. /// Unlike history retention, zero is a supported opt-out. pub(crate) fn validate_device_retention(retention: Duration) -> Result<()> { anyhow::ensure!( retention.subsec_nanos() == 0, "device_retention must use whole seconds" ); anyhow::ensure!( i64::try_from(retention.as_secs()).is_ok(), "device_retention is too large" ); Ok(()) } pub(crate) fn validate_durations( command_timeout: Duration, enroll_token_ttl: Duration, history_retention: Duration, ) -> Result<()> { for (name, value) in [ ("command_timeout", command_timeout), ("enroll_token_ttl", enroll_token_ttl), ("history_retention", history_retention), ] { anyhow::ensure!(!value.is_zero(), "{name} must be positive"); anyhow::ensure!( std::time::Instant::now().checked_add(value).is_some(), "{name} exceeds the platform timer range" ); } anyhow::ensure!( enroll_token_ttl.subsec_nanos() == 0, "enroll_token_ttl must use whole seconds" ); anyhow::ensure!( history_retention.subsec_nanos() == 0, "history_retention must use whole seconds" ); Ok(()) } fn resolve_admin_public_url( mode: &AdminTargetArgs, config_public_url: Option, ) -> Result> { match (mode.live, mode.offline) { (true, false) => config_public_url .ok_or_else(|| { anyhow::anyhow!("--live requires --public-url or a configured public_url") }) .map(Some), (false, true) => Ok(None), _ => Ok(config_public_url), } } fn resolve_telemetry(file: Option) -> TelemetryConfig { // Service-specific defaults live in the CC template, not the shared type. let mut out = DEFAULT_CONFIG.telemetry.clone(); if let Some(file) = file { out.authorization_env = file.authorization_env; out.console_filter = file.console_filter; out.otlp_log_filter = file.otlp_log_filter; out.trace_filter = file.trace_filter; if let Some(endpoint) = file.otlp_endpoint { let trimmed = endpoint.trim().to_string(); if !trimmed.is_empty() { out.otlp_endpoint = Some(trimmed); } } if let Some(name) = file.service_name { let trimmed = name.trim(); if !trimmed.is_empty() { out.service_name = trimmed.to_string(); } } if let Some(json_logs) = file.json_logs { out.json_logs = json_logs; } } out } fn resolve_state_access( config_file: &Path, cli_data_dir: Option, cli_state_file: Option, cli_public_url: Option, mode: &AdminTargetArgs, ) -> Result { let file = load_file_config(config_file)?; let defaults = &*DEFAULT_CONFIG; let data_dir = cli_data_dir .or(file.data_dir) .unwrap_or_else(|| defaults.data_dir.clone()); let state_file = resolve_path( &data_dir, cli_state_file .or(file.state_file) .unwrap_or_else(|| defaults.state_file.clone()), ); let resolved_public_url = cli_public_url .or(file.public_url) .map(|url| normalize_public_url(&url)); let public_url = resolve_admin_public_url(mode, resolved_public_url)?; Ok(StateAccessSettings { data_dir, state_file, public_url, }) } #[cfg(test)] mod tests { use super::{DaemonConfig, normalize_public_url, resolve_path, validate_device_retention}; use crate::cli::ServeArgs; use crate::config::types::DEFAULT_CONFIG; use std::path::Path; use std::path::PathBuf; use std::time::Duration; #[test] fn normalize_public_url_trims_trailing_slash() { assert_eq!( normalize_public_url("https://cp.example.com/"), "https://cp.example.com" ); } #[test] fn resolve_path_joins_relative_path() { let out = resolve_path( Path::new("/var/lib/wakey-control-plane"), PathBuf::from("state.sqlite3"), ); assert_eq!( out, PathBuf::from("/var/lib/wakey-control-plane/state.sqlite3") ); } #[test] fn daemon_config_uses_shared_default_template() { let config_file = std::env::temp_dir().join(format!( "wakey-cc-missing-config-{}.toml", std::process::id() )); let _ = std::fs::remove_file(&config_file); let resolved = DaemonConfig::from_serve_args(&ServeArgs { data_dir: None, bind: None, public_url: None, state_file: None, bootstrap_enroll_tokens: Vec::new(), command_timeout_ms: None, enroll_token_ttl_seconds: None, observation_retention_seconds: None, pid_file: None, ui_dist_dir: None, config_file, bootstrap_config: false, }) .expect("resolve default config"); let defaults = &*DEFAULT_CONFIG; assert_eq!(resolved.data_dir, defaults.data_dir); assert_eq!( resolved.state_file, defaults.data_dir.join(&defaults.state_file) ); assert_eq!(resolved.command_timeout, defaults.command_timeout); assert_eq!(resolved.device_retention, defaults.device_retention); assert_eq!( resolved.telemetry.service_name, defaults.telemetry.service_name ); } #[test] fn device_retention_accepts_disabled_and_whole_seconds_only() { assert!(validate_device_retention(Duration::ZERO).is_ok()); assert!(validate_device_retention(DEFAULT_CONFIG.device_retention).is_ok()); assert!(validate_device_retention(Duration::from_millis(1_500)).is_err()); assert!(validate_device_retention(Duration::from_secs(u64::MAX)).is_err()); } #[test] fn invalid_duration_policies_are_rejected_without_clamping() { use std::time::Duration; let defaults = &*DEFAULT_CONFIG; assert!( super::validate_durations( Duration::ZERO, defaults.enroll_token_ttl, defaults.history_retention ) .is_err() ); assert!( super::validate_durations( defaults.command_timeout, Duration::from_millis(500), defaults.history_retention ) .is_err() ); assert!( super::validate_durations( defaults.command_timeout, defaults.enroll_token_ttl, Duration::ZERO ) .is_err() ); assert!( super::validate_durations( Duration::from_secs(u64::MAX), defaults.enroll_token_ttl, defaults.history_retention ) .is_err() ); } }