diff --git a/Cargo.lock b/Cargo.lock index 5cd52bb..26f981c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1031,6 +1031,22 @@ version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" +[[package]] +name = "humantime" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15cdd26707701c53297e2fa6afb323d55fbc1d0810c3aec078ae3ef0424c3c15" + +[[package]] +name = "humantime-serde" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57a3db5ea5923d99402c94e9feb261dc5ee9b4efa158b0315f788cf549cc200c" +dependencies = [ + "humantime", + "serde", +] + [[package]] name = "hyper" version = "1.9.0" @@ -3615,6 +3631,7 @@ dependencies = [ "clap", "futures-util", "http", + "humantime-serde", "macaddr", "nix 0.31.2", "reqwest", @@ -3638,6 +3655,7 @@ dependencies = [ "axum", "clap", "futures-util", + "humantime-serde", "macaddr", "nix 0.31.2", "reqwest", @@ -3658,6 +3676,7 @@ name = "wakey-core" version = "0.3.6" dependencies = [ "anyhow", + "humantime", "macaddr", "opentelemetry", "opentelemetry-appender-tracing", diff --git a/docs/adr/0002-remote-terminal-sessions.md b/docs/adr/0002-remote-terminal-sessions.md index 480b23f..41477a2 100644 --- a/docs/adr/0002-remote-terminal-sessions.md +++ b/docs/adr/0002-remote-terminal-sessions.md @@ -84,7 +84,7 @@ The agent keeps its terminal manager outside the control-WebSocket reconnect loo Closing a session must terminate the entire PTY process group, not only the shell process. Cleanup should send a hangup first, then escalate to termination and forced kill after bounded grace periods. Explicit operator close, absolute session timeout, agent process exit, and machine reboot close the session. -The agent configuration sets `terminal.session_ttl_seconds` for newly created PTYs. The default is 43,200 seconds (12 hours); explicit zero disables automatic expiry. The agent owns and enforces this deadline locally because PTYs survive browser and control-plane disconnection. It advertises the policy to the control plane and includes the creation-time TTL in terminal inventory, allowing CC to mirror expiry and preserve it across CC restart. Missing fields from older agents retain the 12-hour default. Positive values are exact and values that cannot fit the platform timer are rejected rather than clamped. +The agent configuration sets `terminal.session_ttl` (for example, `"12h"`) for newly created PTYs. The default is 12 hours; `"0s"` disables automatic expiry. Legacy `session_ttl_seconds` is accepted with a deprecation warning. The wire protocol still uses integer seconds, so fractional-second TTLs are rejected. The agent owns and enforces this deadline locally because PTYs survive browser and control-plane disconnection. It advertises the policy to the control plane and includes the creation-time TTL in terminal inventory, allowing CC to mirror expiry and preserve it across CC restart. Missing fields from older agents retain the 12-hour default. Positive values are exact and values that cannot fit the platform timer are rejected rather than clamped. An agent process restart or machine reboot still ends every terminal session because PTY file descriptors cannot be recovered by a new process. Surviving that boundary would require an external session host such as `tmux` or a separate terminal daemon and is not part of this design. diff --git a/docs/config-durations.md b/docs/config-durations.md new file mode 100644 index 0000000..8daa721 --- /dev/null +++ b/docs/config-durations.md @@ -0,0 +1,74 @@ +# Configuration Durations + +Agent and control-plane TOML duration settings use strings with explicit units. +Runtime code receives `std::time::Duration`; API and WebSocket numeric fields +such as `timeout_ms`, `session_ttl_seconds`, and `latency_ms` are unchanged. + +## Agent + +```toml +reconnect_base = "1s" +reconnect_max = "30s" +observation_sync_interval = "60s" +observation_retention = "7d" + +[terminal] +session_ttl = "12h" +``` + +`observation_retention` prunes the agent's local hotplug memory during inventory +collection. It does not delete the control plane's last snapshot for an agent. +`"0d"` disables this local pruning. `terminal.session_ttl = "0s"` disables PTY +expiry. Reconnect and sync timers must be positive and fit the platform timer. +Terminal TTL and hook retention must resolve to whole seconds because their +protocol/storage timestamps use seconds. Reconnect and sync intervals can use +subsecond values. +Reconnect scheduling retains its existing 100ms minimum backoff. + +## Control Plane + +```toml +command_timeout = "30s" +enroll_token_ttl = "1d" +history_retention = "30d" +``` + +All three settings must be positive. Enrollment TTL and history retention must +resolve to whole seconds. Command timeout can use subsecond values. +`history_retention` deletes old audit events and alert transitions, not device +snapshots or saved identities. Device retention is a separate follow-up. + +## Legacy Files + +| Old Key | New Key | Numeric Legacy Unit | +| --- | --- | --- | +| Agent `reconnect_base_ms` | `reconnect_base` | milliseconds | +| Agent `reconnect_max_ms` | `reconnect_max` | milliseconds | +| Agent `observation_sync_interval_seconds` | `observation_sync_interval` | seconds | +| Agent `observation_retention_days` | `observation_retention` | days | +| Agent `terminal.session_ttl_seconds` | `terminal.session_ttl` | seconds | +| CC `command_timeout_ms` | `command_timeout` | milliseconds | +| CC `enroll_token_ttl_seconds` | `enroll_token_ttl` | seconds | +| CC `observation_retention_seconds` | `history_retention` | seconds | + +Old keys accept non-negative integers in their original units, or explicit +duration strings, and produce a warning on stderr when loaded. New keys require +strings; bare numbers are rejected. Supplying both keys for the same setting is +an error, even when their values agree. Negative, malformed, and overflowing +values are rejected; zero active timers are rejected rather than silently +clamped. No secrets are included in deprecation warnings. + +`init-config --from-config ...` reads legacy files and writes only canonical +keys and formatted duration strings. Existing numeric CLI flags are unchanged +in this slice, and their overrides also serialize in the new format. + +For example, legacy `reconnect_base_ms = 1500` is read as 1500 milliseconds +and written back as `reconnect_base = "1s 500ms"`. New-key +`reconnect_base = 1500` is rejected: no implicit numeric unit is assigned. +Serialization always writes duration strings, regardless of the accepted input +format, using `humantime-serde`. + +The parser is [humantime](https://docs.rs/humantime/latest/humantime/fn.parse_duration.html). +Examples include `"14d"`, `"168h"`, `"250ms"`, and `"1h 30m"`. These are elapsed +durations, not calendar scheduling rules; use days/hours for retention rather +than assuming calendar-month semantics. diff --git a/docs/roadmap.md b/docs/roadmap.md index 5b55f4b..e9f121f 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -22,6 +22,8 @@ live browser recovery and shutdown behavior are not established by those checks. ## Device Management And Presentation +- [x] Endpoint-derived presence on snapshot writes and fleet reads; endpoint-less legacy rows retain their summary. CLI ordering matches the core signal order. + - [x] Make **forget remembered device** reachable in the frontend, with confirmation. The existing backend removes the known identity and its identifiers, not agent observations. An observed device may reappear as unknown. - [x] Add remembered-device rename support, with backend validation and frontend editing (`PATCH /api/v1/control/devices/{device_id}`). Pinned state and identifiers are preserved. - [ ] Check and complete editing of remembered-device notes. @@ -34,6 +36,8 @@ of remembered identifiers based on observation expiry. ## Telemetry Follow-Ups +- [x] Human-readable agent/CC config durations with unit-aware legacy loading, stderr deprecation warnings, and canonical init-config output. See [configuration durations](config-durations.md). History retention remains separate from future device expiry. + - [x] Separate console, OTLP log, and trace filters using per-layer filtering. Explicit signal config overrides `RUST_LOG`, then CLI verbosity supplies defaults. Unconfigured defaults remain info-level; exporter-failure logs stay local. Live validation pending. - [ ] Record hostname-resolution success/failure without exporting hostnames or resolved addresses. Current spans show timing but do not explicitly classify the result. - [ ] Investigate the slow resolver with a reproducible device-side check. Timing isolated the phase, not the cause; do not treat instrumentation as a fix. diff --git a/wakey-agent/Cargo.toml b/wakey-agent/Cargo.toml index bcd901d..91fc776 100644 --- a/wakey-agent/Cargo.toml +++ b/wakey-agent/Cargo.toml @@ -9,6 +9,7 @@ anyhow = "1" clap = { version = "4", features = ["derive"] } futures-util = "0.3" http = "1" +humantime-serde = "1" macaddr = { version = "1", features = ["serde", "serde_std"] } nix = { version = "0.31", default-features = false, features = [ "signal", diff --git a/wakey-agent/src/config.rs b/wakey-agent/src/config.rs index 6ebbe8e..e935639 100644 --- a/wakey-agent/src/config.rs +++ b/wakey-agent/src/config.rs @@ -3,6 +3,8 @@ use serde::{Deserialize, Serialize}; use std::fmt; use std::path::{Path, PathBuf}; use std::sync::LazyLock; +use std::time::Duration; +use wakey_core::config_duration::{self, LegacyDuration}; use crate::protocol::{ DEFAULT_TERMINAL_MAX_SESSIONS, DEFAULT_TERMINAL_SESSION_TTL_SECONDS, @@ -19,19 +21,25 @@ const DEFAULT_MAC_NAME_CACHE_PATH: &str = "/tmp/wakey_mac_names.json"; const DEFAULT_OBSERVATION_STORE_PATH: &str = "/tmp/wakey_observations.json"; pub const DEFAULT_OBSERVATION_RETENTION_DAYS: u64 = 7; +// Derive canonical serde functions once; the trait adapter below translates +// legacy keys without maintaining another copy of every config field. #[derive(Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(remote = "Self")] pub struct AgentConfig { pub server_url: String, pub agent_id: String, pub agent_token: String, - #[serde(default = "default_reconnect_base_ms")] - pub reconnect_base_ms: u64, - #[serde(default = "default_reconnect_max_ms")] - pub reconnect_max_ms: u64, - #[serde(default = "default_observation_sync_interval_seconds")] - pub observation_sync_interval_seconds: u64, - #[serde(default = "default_observation_retention_days")] - pub observation_retention_days: u64, + #[serde(default = "default_reconnect_base", with = "humantime_serde")] + pub reconnect_base: Duration, + #[serde(default = "default_reconnect_max", with = "humantime_serde")] + pub reconnect_max: Duration, + #[serde( + default = "default_observation_sync_interval", + with = "humantime_serde" + )] + pub observation_sync_interval: Duration, + #[serde(default = "default_observation_retention", with = "humantime_serde")] + pub observation_retention: Duration, #[serde(default = "default_pid_file")] pub pid_file: PathBuf, #[serde(default = "default_dhcp_leases_path")] @@ -47,7 +55,7 @@ pub struct AgentConfig { } #[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] -#[serde(deny_unknown_fields)] +#[serde(remote = "Self", deny_unknown_fields)] pub struct TerminalConfig { #[serde(default)] pub enabled: bool, @@ -60,8 +68,8 @@ pub struct TerminalConfig { #[serde(default = "default_terminal_max_sessions")] pub max_sessions: usize, /// Maximum lifetime of a PTY process. Zero disables automatic expiry. - #[serde(default = "default_terminal_session_ttl_seconds")] - pub session_ttl_seconds: u64, + #[serde(default = "default_terminal_session_ttl", with = "humantime_serde")] + pub session_ttl: Duration, } impl Default for TerminalConfig { @@ -72,7 +80,7 @@ impl Default for TerminalConfig { args: Vec::new(), current_dir: None, max_sessions: default_terminal_max_sessions(), - session_ttl_seconds: default_terminal_session_ttl_seconds(), + session_ttl: default_terminal_session_ttl(), } } } @@ -81,10 +89,10 @@ pub static DEFAULT_CONFIG: LazyLock = LazyLock::new(|| AgentConfig server_url: "https://wakey.ldlda.com".to_string(), agent_id: "REPLACE_ME_AGENT_ID".to_string(), agent_token: "REPLACE_ME_AGENT_TOKEN".to_string(), - reconnect_base_ms: default_reconnect_base_ms(), - reconnect_max_ms: default_reconnect_max_ms(), - observation_sync_interval_seconds: default_observation_sync_interval_seconds(), - observation_retention_days: default_observation_retention_days(), + reconnect_base: default_reconnect_base(), + reconnect_max: default_reconnect_max(), + observation_sync_interval: default_observation_sync_interval(), + observation_retention: default_observation_retention(), pid_file: default_pid_file(), dhcp_leases_path: default_dhcp_leases_path(), mac_name_cache_path: default_mac_name_cache_path(), @@ -99,16 +107,10 @@ impl fmt::Debug for AgentConfig { .field("server_url", &self.server_url) .field("agent_id", &self.agent_id) .field("agent_token", &"") - .field("reconnect_base_ms", &self.reconnect_base_ms) - .field("reconnect_max_ms", &self.reconnect_max_ms) - .field( - "observation_sync_interval_seconds", - &self.observation_sync_interval_seconds, - ) - .field( - "observation_retention_days", - &self.observation_retention_days, - ) + .field("reconnect_base", &self.reconnect_base) + .field("reconnect_max", &self.reconnect_max) + .field("observation_sync_interval", &self.observation_sync_interval) + .field("observation_retention", &self.observation_retention) .field("pid_file", &self.pid_file) .field("dhcp_leases_path", &self.dhcp_leases_path) .field("mac_name_cache_path", &self.mac_name_cache_path) @@ -119,20 +121,20 @@ impl fmt::Debug for AgentConfig { } } -const fn default_reconnect_base_ms() -> u64 { - 1_000 +const fn default_reconnect_base() -> Duration { + Duration::from_secs(1) } -const fn default_reconnect_max_ms() -> u64 { - 30_000 +const fn default_reconnect_max() -> Duration { + Duration::from_secs(30) } -const fn default_observation_sync_interval_seconds() -> u64 { - 60 +const fn default_observation_sync_interval() -> Duration { + Duration::from_secs(60) } -const fn default_observation_retention_days() -> u64 { - DEFAULT_OBSERVATION_RETENTION_DAYS +const fn default_observation_retention() -> Duration { + Duration::from_secs(DEFAULT_OBSERVATION_RETENTION_DAYS * 86_400) } fn default_pid_file() -> PathBuf { @@ -159,17 +161,43 @@ const fn default_terminal_max_sessions() -> usize { DEFAULT_TERMINAL_MAX_SESSIONS } -const fn default_terminal_session_ttl_seconds() -> u64 { - DEFAULT_TERMINAL_SESSION_TTL_SECONDS +const fn default_terminal_session_ttl() -> Duration { + Duration::from_secs(DEFAULT_TERMINAL_SESSION_TTL_SECONDS) } impl TerminalConfig { pub(crate) fn session_ttl(&self) -> Result> { - checked_terminal_session_ttl(self.session_ttl_seconds).map_err(anyhow::Error::msg) + anyhow::ensure!( + self.session_ttl.subsec_nanos() == 0, + "terminal.session_ttl must use whole seconds (the terminal protocol uses seconds)" + ); + checked_terminal_session_ttl(self.session_ttl.as_secs()).map_err(anyhow::Error::msg) } } impl AgentConfig { + /// Validate scheduling bounds and second-based storage/protocol boundaries. + /// Zero is allowed for hook retention and terminal TTL, not active timers. + fn validate(&self) -> Result<()> { + for (name, duration) in [ + ("reconnect_base", self.reconnect_base), + ("reconnect_max", self.reconnect_max), + ("observation_sync_interval", self.observation_sync_interval), + ] { + anyhow::ensure!(!duration.is_zero(), "{name} must be positive"); + anyhow::ensure!( + std::time::Instant::now().checked_add(duration).is_some(), + "{name} exceeds the platform timer range" + ); + } + anyhow::ensure!( + self.observation_retention.subsec_nanos() == 0, + "observation_retention must use whole seconds (hook timestamps use seconds)" + ); + self.terminal.session_ttl()?; + Ok(()) + } + pub fn local_path_envs(&self) -> Vec<(&'static str, &Path)> { vec![ (WAKEY_DHCP_LEASES_ENV, self.dhcp_leases_path.as_path()), @@ -191,13 +219,99 @@ pub fn apply_local_path_env_to_command(cmd: &mut std::process::Command, config: pub fn load_config(path: &Path) -> Result { let content = std::fs::read_to_string(path) .with_context(|| format!("failed to read agent config {}", path.display()))?; - let config: AgentConfig = toml::from_str(&content) + let raw: toml::Value = toml::from_str(&content) .with_context(|| format!("failed to parse agent config {}", path.display()))?; - config.terminal.session_ttl()?; + let mut value = serde_json::to_value(raw)?; + let mut deprecated = config_duration::normalize(&mut value, AGENT_DURATION_KEYS) + .map_err(anyhow::Error::msg)? + .into_iter() + .map(|key| ("", key)) + .collect::>(); + if let Some(terminal) = value.get_mut("terminal") { + deprecated.extend( + config_duration::normalize(terminal, TERMINAL_DURATION_KEYS) + .map_err(anyhow::Error::msg)? + .into_iter() + .map(|key| ("terminal.", key)), + ); + } + let config: AgentConfig = serde_json::from_value(value) + .with_context(|| format!("failed to parse agent config {}", path.display()))?; + config.validate()?; + for (prefix, key) in deprecated { + // Initial config loading precedes tracing setup; stderr also keeps + // init-config stdout valid TOML and never prints config secrets. + eprintln!( + "warning: {}: `{prefix}{}` is deprecated; use `{prefix}{}` with a duration string", + path.display(), + key.old, + key.new + ); + } Ok(config) } +const AGENT_DURATION_KEYS: &[LegacyDuration] = &[ + LegacyDuration { + old: "reconnect_base_ms", + new: "reconnect_base", + unit: Duration::from_millis(1), + }, + LegacyDuration { + old: "reconnect_max_ms", + new: "reconnect_max", + unit: Duration::from_millis(1), + }, + LegacyDuration { + old: "observation_sync_interval_seconds", + new: "observation_sync_interval", + unit: Duration::from_secs(1), + }, + LegacyDuration { + old: "observation_retention_days", + new: "observation_retention", + unit: Duration::from_secs(86_400), + }, +]; +const TERMINAL_DURATION_KEYS: &[LegacyDuration] = &[LegacyDuration { + old: "session_ttl_seconds", + new: "session_ttl", + unit: Duration::from_secs(1), +}]; + +impl Serialize for AgentConfig { + fn serialize( + &self, + serializer: S, + ) -> std::result::Result { + Self::serialize(self, serializer) + } +} +impl<'de> Deserialize<'de> for AgentConfig { + fn deserialize>( + deserializer: D, + ) -> std::result::Result { + config_duration::deserialize_legacy(deserializer, AGENT_DURATION_KEYS, Self::deserialize) + } +} +impl Serialize for TerminalConfig { + fn serialize( + &self, + serializer: S, + ) -> std::result::Result { + Self::serialize(self, serializer) + } +} +impl<'de> Deserialize<'de> for TerminalConfig { + fn deserialize>( + deserializer: D, + ) -> std::result::Result { + config_duration::deserialize_legacy(deserializer, TERMINAL_DURATION_KEYS, Self::deserialize) + } +} + pub fn save_config(path: &Path, config: &AgentConfig) -> Result<()> { + config.validate()?; let content = toml::to_string_pretty(config).context("failed to serialize agent config")?; write_config_atomically(path, &content) } @@ -262,6 +376,62 @@ fn write_config_atomically(path: &Path, content: &str) -> Result<()> { mod tests { use super::*; + const HEADER: &str = + "server_url = 'https://example.com'\nagent_id = 'agent-1'\nagent_token = 'secret'\n"; + + #[test] + fn legacy_duration_keys_convert_and_serialize_canonically() { + let legacy: AgentConfig = toml::from_str(&format!("{HEADER}\nreconnect_base_ms = 123\nreconnect_max_ms = 456\nobservation_sync_interval_seconds = 7\nobservation_retention_days = 3\n[terminal]\nsession_ttl_seconds = 600\n")).unwrap(); + let canonical: AgentConfig = toml::from_str(&format!("{HEADER}\nreconnect_base = '123ms'\nreconnect_max = '456ms'\nobservation_sync_interval = '7s'\nobservation_retention = '3d'\n[terminal]\nsession_ttl = '10m'\n")).unwrap(); + assert_eq!(legacy, canonical); + legacy.validate().unwrap(); + let output = toml::to_string(&legacy).unwrap(); + for key in AGENT_DURATION_KEYS { + assert!(!output.contains(key.old)); + assert!(output.contains(key.new)); + } + assert!(!output.contains("session_ttl_seconds")); + assert_eq!(toml::from_str::(&output).unwrap(), canonical); + } + + #[test] + fn config_rejects_ambiguous_units_and_invalid_timer_values() { + for fields in [ + "reconnect_base = '1s'\nreconnect_base_ms = 1000", + "observation_retention = '7d'\nobservation_retention_days = 7", + "reconnect_base = 1000", + "observation_retention_days = -1", + "observation_retention_days = 9223372036854775807", + "reconnect_base = 'nonsense'", + "[terminal]\nsession_ttl = '1h'\nsession_ttl_seconds = 3600", + ] { + assert!( + toml::from_str::(&format!("{HEADER}{fields}")).is_err(), + "{fields}" + ); + } + for fields in [ + "observation_sync_interval = '0s'", + "reconnect_base = '0s'", + "[terminal]\nsession_ttl = '500ms'", + ] { + let config: AgentConfig = toml::from_str(&format!("{HEADER}{fields}")).unwrap(); + assert!(config.validate().is_err(), "{fields}"); + } + let config: AgentConfig = toml::from_str(&format!( + "{HEADER}observation_retention = '0d'\n[terminal]\nsession_ttl = '0s'" + )) + .unwrap(); + config.validate().unwrap(); + assert!(config.observation_retention.is_zero()); + assert_eq!(config.terminal.session_ttl().unwrap(), None); + let oversized = AgentConfig { + reconnect_max: Duration::from_secs(u64::MAX), + ..config + }; + assert!(oversized.validate().is_err()); + } + #[test] fn config_roundtrip() { let dir = std::env::temp_dir().join(format!("wakey-agent-config-{}", std::process::id())); @@ -270,10 +440,10 @@ mod tests { server_url: "https://example.com".into(), agent_id: "agent-1".into(), agent_token: "secret".into(), - reconnect_base_ms: 123, - reconnect_max_ms: 456, - observation_sync_interval_seconds: 7, - observation_retention_days: 3, + reconnect_base: Duration::from_millis(123), + reconnect_max: Duration::from_millis(456), + observation_sync_interval: Duration::from_secs(7), + observation_retention: Duration::from_secs(3 * 86_400), pid_file: "/tmp/test-wakey-agent.pid".into(), dhcp_leases_path: "/tmp/test-dhcp.leases".into(), mac_name_cache_path: "/tmp/test-names.json".into(), @@ -293,7 +463,7 @@ mod tests { args: vec!["-l".into()], current_dir: Some("/tmp".into()), max_sessions: 2, - session_ttl_seconds: 600, + session_ttl: Duration::from_secs(600), }, }; @@ -323,8 +493,8 @@ agent_token = "secret" .expect("config should parse"); assert_eq!( - config.observation_retention_days, - DEFAULT_OBSERVATION_RETENTION_DAYS + config.observation_retention, + DEFAULT_CONFIG.observation_retention ); assert_eq!(config.terminal, TerminalConfig::default()); } @@ -348,8 +518,8 @@ max_sessions = 2 assert!(config.terminal.args.is_empty()); assert!(config.terminal.current_dir.is_none()); assert_eq!( - config.terminal.session_ttl_seconds, - DEFAULT_TERMINAL_SESSION_TTL_SECONDS + config.terminal.session_ttl, + TerminalConfig::default().session_ttl ); } @@ -374,18 +544,18 @@ max_session = 67 #[test] fn terminal_ttl_zero_is_unlimited_and_positive_is_exact() { let mut terminal = TerminalConfig { - session_ttl_seconds: 0, + session_ttl: Duration::ZERO, ..TerminalConfig::default() }; assert_eq!(terminal.session_ttl().expect("unlimited TTL"), None); - terminal.session_ttl_seconds = 67; + terminal.session_ttl = Duration::from_secs(67); assert_eq!( terminal.session_ttl().expect("finite TTL"), Some(std::time::Duration::from_secs(67)) ); - terminal.session_ttl_seconds = u64::MAX; + terminal.session_ttl = Duration::from_secs(u64::MAX); assert!(terminal.session_ttl().is_err()); } } diff --git a/wakey-agent/src/dispatch.rs b/wakey-agent/src/dispatch.rs index e7cece5..d80aa9a 100644 --- a/wakey-agent/src/dispatch.rs +++ b/wakey-agent/src/dispatch.rs @@ -89,9 +89,9 @@ pub async fn inventory_for_config( .instrument(tracing::info_span!("inventory_dhcp_leases")) .await?; tracing::Span::current().record("leases", leases.len()); - match wakey::wakey_linux::observations::prune_stale_observations_from_path( + match wakey::wakey_linux::observations::prune_expired( &config.observation_store_path, - config.observation_retention_days, + config.observation_retention, ) .instrument(tracing::info_span!("inventory_observation_prune")) .await diff --git a/wakey-agent/src/enroll.rs b/wakey-agent/src/enroll.rs index e77b2b6..9333067 100644 --- a/wakey-agent/src/enroll.rs +++ b/wakey-agent/src/enroll.rs @@ -143,10 +143,10 @@ mod tests { server_url: "https://old.example.com".into(), agent_id: "old-agent".into(), agent_token: "old-token".into(), - reconnect_base_ms: 2_000, - reconnect_max_ms: 60_000, - observation_sync_interval_seconds: 30, - observation_retention_days: 11, + reconnect_base: std::time::Duration::from_secs(2), + reconnect_max: std::time::Duration::from_secs(60), + observation_sync_interval: std::time::Duration::from_secs(30), + observation_retention: std::time::Duration::from_secs(11 * 86_400), pid_file: "/tmp/custom-wakey-agent.pid".into(), dhcp_leases_path: "/tmp/custom-dhcp.leases".into(), mac_name_cache_path: "/tmp/custom-names.json".into(), @@ -164,7 +164,7 @@ mod tests { args: vec!["-l".into()], current_dir: Some("/root".into()), max_sessions: 2, - session_ttl_seconds: 600, + session_ttl: std::time::Duration::from_secs(600), }, }; @@ -176,7 +176,10 @@ mod tests { assert_eq!(config.agent_id, "agent-123"); assert_eq!(config.agent_token, "token-xyz"); assert_eq!(config.server_url, "https://control.example.com"); - assert_eq!(config.observation_retention_days, 11); + assert_eq!( + config.observation_retention, + base_config.observation_retention + ); assert_eq!(config.pid_file, base_config.pid_file); assert_eq!(config.dhcp_leases_path, base_config.dhcp_leases_path); assert_eq!(config.terminal, base_config.terminal); diff --git a/wakey-agent/src/main.rs b/wakey-agent/src/main.rs index 0f60d68..6473eee 100644 --- a/wakey-agent/src/main.rs +++ b/wakey-agent/src/main.rs @@ -290,7 +290,10 @@ fn init_config(args: InitConfigArgs) -> Result<()> { cfg.agent_token = agent_token; } if let Some(days) = args.observation_retention_days { - cfg.observation_retention_days = days; + cfg.observation_retention = std::time::Duration::from_secs( + days.checked_mul(86_400) + .ok_or_else(|| anyhow::anyhow!("observation retention is too large"))?, + ); } if let Some(path) = &args.config { diff --git a/wakey-agent/src/session.rs b/wakey-agent/src/session.rs index 79999cc..fd7e120 100644 --- a/wakey-agent/src/session.rs +++ b/wakey-agent/src/session.rs @@ -18,16 +18,16 @@ pub async fn run(config: AgentConfig) -> Result<()> { // Terminal workers belong to the agent process, not a single control socket. // Keeping this manager outside the reconnect loop lets PTYs survive CC loss. let (terminal_manager, mut terminal_events) = TerminalManager::new(&config); - let mut backoff = config.reconnect_base_ms.max(100); + let mut backoff = config.reconnect_base.max(Duration::from_millis(100)); loop { match run_once(&config, &terminal_manager, &mut terminal_events).await { Ok(()) => { - backoff = config.reconnect_base_ms.max(100); + backoff = config.reconnect_base.max(Duration::from_millis(100)); } Err(err) => { - warn!(agent_id = %config.agent_id, error = %err, backoff_ms = backoff, "agent session ended; reconnecting"); - sleep(Duration::from_millis(backoff)).await; - backoff = next_backoff_ms(backoff, config.reconnect_max_ms); + warn!(agent_id = %config.agent_id, error = %err, backoff_ms = backoff.as_millis() as u64, "agent session ended; reconnecting"); + sleep(backoff).await; + backoff = next_backoff(backoff, config.reconnect_max); } } } @@ -94,7 +94,7 @@ async fn run_once( .enabled .then_some(TerminalCapabilityOptions { max_sessions: config.terminal.max_sessions.max(1), - session_ttl_seconds: config.terminal.session_ttl_seconds, + session_ttl_seconds: config.terminal.session_ttl.as_secs(), }), }, }, @@ -117,9 +117,7 @@ async fn run_once( let mut heartbeat = interval(Duration::from_secs(30)); heartbeat.set_missed_tick_behavior(MissedTickBehavior::Skip); - let mut snapshot_sync = interval(Duration::from_secs( - config.observation_sync_interval_seconds.max(1), - )); + let mut snapshot_sync = interval(config.observation_sync_interval); snapshot_sync.set_missed_tick_behavior(MissedTickBehavior::Skip); snapshot_sync.reset(); loop { @@ -238,9 +236,9 @@ where Ok(count) } -pub fn next_backoff_ms(current_ms: u64, max_ms: u64) -> u64 { - let cap = max_ms.max(current_ms); - current_ms.saturating_mul(2).min(cap) +pub fn next_backoff(current: Duration, max: Duration) -> Duration { + let cap = max.max(current); + current.saturating_mul(2).min(cap) } async fn handle_server_message( @@ -474,7 +472,7 @@ mod tests { use serde_json::Value; use tokio_tungstenite::tungstenite::Message; - use super::{next_backoff_ms, send_command_result, websocket_url}; + use super::{next_backoff, send_command_result, websocket_url}; use crate::protocol::{CommandResult, RequestId}; struct RecordingSink { @@ -533,14 +531,28 @@ mod tests { #[test] fn backoff_doubles_until_cap() { - assert_eq!(next_backoff_ms(1_000, 30_000), 2_000); - assert_eq!(next_backoff_ms(16_000, 30_000), 30_000); - assert_eq!(next_backoff_ms(30_000, 30_000), 30_000); + use std::time::Duration; + assert_eq!( + next_backoff(Duration::from_secs(1), Duration::from_secs(30)), + Duration::from_secs(2) + ); + assert_eq!( + next_backoff(Duration::from_secs(16), Duration::from_secs(30)), + Duration::from_secs(30) + ); + assert_eq!( + next_backoff(Duration::from_secs(30), Duration::from_secs(30)), + Duration::from_secs(30) + ); } #[test] fn backoff_never_shrinks_when_max_is_lower() { - assert_eq!(next_backoff_ms(8_000, 1_000), 8_000); + use std::time::Duration; + assert_eq!( + next_backoff(Duration::from_secs(8), Duration::from_secs(1)), + Duration::from_secs(8) + ); } #[tokio::test] diff --git a/wakey-agent/src/terminal.rs b/wakey-agent/src/terminal.rs index 6613393..ebc1092 100644 --- a/wakey-agent/src/terminal.rs +++ b/wakey-agent/src/terminal.rs @@ -224,7 +224,7 @@ impl TerminalManager { cancel: Some(cancel_tx), relay_credentials: relay_tx.clone(), created_at_unix, - session_ttl_seconds: config.terminal.session_ttl_seconds, + session_ttl_seconds: config.terminal.session_ttl.as_secs(), }, ); } diff --git a/wakey-control-plane/Cargo.toml b/wakey-control-plane/Cargo.toml index dc0c760..3ce90c3 100644 --- a/wakey-control-plane/Cargo.toml +++ b/wakey-control-plane/Cargo.toml @@ -9,6 +9,7 @@ anyhow = { workspace = true } axum = { version = "0.8", features = ["ws", "json"] } clap = { version = "4", features = ["derive"] } futures-util = "0.3" +humantime-serde = "1" macaddr = { workspace = true } nix = { version = "0.31", default-features = false, features = [ "signal", diff --git a/wakey-control-plane/src/api/commands.rs b/wakey-control-plane/src/api/commands.rs index 60ab262..0fa5963 100644 --- a/wakey-control-plane/src/api/commands.rs +++ b/wakey-control-plane/src/api/commands.rs @@ -172,11 +172,9 @@ pub async fn relay_agent_command( )); } - let timeout = std::time::Duration::from_millis( - timeout_ms - .unwrap_or(state.command_timeout.as_millis() as u64) - .max(1), - ); + let timeout = timeout_ms + .map(|value| std::time::Duration::from_millis(value.max(1))) + .unwrap_or(state.command_timeout); let outcome = tokio::time::timeout(timeout, pending_rx).await; let response = match outcome { Ok(Ok(AgentReply::Result(result))) => { diff --git a/wakey-control-plane/src/cli.rs b/wakey-control-plane/src/cli.rs index 1ca333a..7443d8e 100644 --- a/wakey-control-plane/src/cli.rs +++ b/wakey-control-plane/src/cli.rs @@ -72,6 +72,7 @@ pub struct ServeArgs { #[arg(long)] pub enroll_token_ttl_seconds: Option, + /// Audit/alert history retention in seconds (legacy CLI name; not device retention). #[arg(long)] pub observation_retention_seconds: Option, @@ -129,6 +130,7 @@ pub struct InitConfigArgs { #[arg(long)] pub enroll_token_ttl_seconds: Option, + /// Audit/alert history retention in seconds (legacy CLI name; not device retention). #[arg(long)] pub observation_retention_seconds: Option, diff --git a/wakey-control-plane/src/config/init.rs b/wakey-control-plane/src/config/init.rs index 1bc1825..85c470b 100644 --- a/wakey-control-plane/src/config/init.rs +++ b/wakey-control-plane/src/config/init.rs @@ -87,28 +87,31 @@ pub fn write_init_config(args: &InitConfigArgs) -> Result> { args.bootstrap_enroll_tokens.clone() }; + let command_timeout = args + .command_timeout_ms + .map(std::time::Duration::from_millis) + .or(base.command_timeout) + .unwrap_or(defaults.command_timeout); + let enroll_token_ttl = args + .enroll_token_ttl_seconds + .map(std::time::Duration::from_secs) + .or(base.enroll_token_ttl) + .unwrap_or(defaults.enroll_token_ttl); + let history_retention = args + .observation_retention_seconds + .map(std::time::Duration::from_secs) + .or(base.history_retention) + .unwrap_or(defaults.history_retention); + super::resolve::validate_durations(command_timeout, enroll_token_ttl, history_retention)?; + let body = FileConfig { data_dir: Some(data_dir), bind: Some(bind.to_string()), public_url: Some(public_url), state_file: Some(state_file), - command_timeout_ms: Some( - args.command_timeout_ms - .or(base.command_timeout_ms) - .unwrap_or(defaults.command_timeout.as_millis() as u64) - .max(1), - ), - enroll_token_ttl_seconds: Some( - args.enroll_token_ttl_seconds - .or(base.enroll_token_ttl_seconds) - .unwrap_or(defaults.enroll_token_ttl.as_secs()) - .max(1), - ), - observation_retention_seconds: Some( - args.observation_retention_seconds - .or(base.observation_retention_seconds) - .unwrap_or(defaults.observation_retention.as_secs()), - ), + command_timeout: Some(command_timeout), + enroll_token_ttl: Some(enroll_token_ttl), + history_retention: Some(history_retention), pid_file: Some(pid_file), ui_dist_dir: Some(ui_dist_dir), // Preserve the previous generated template's omission of an empty list. @@ -275,8 +278,17 @@ json_logs = true let parsed: toml::Value = toml::from_str(&rendered).expect("parse derived config"); assert_eq!(parsed["public_url"].as_str(), Some("https://new.example")); assert_eq!(parsed["bind"].as_str(), Some("127.0.0.1:9090")); - assert_eq!(parsed["command_timeout_ms"].as_integer(), Some(999)); - assert_eq!(parsed["enroll_token_ttl_seconds"].as_integer(), Some(5678)); + let converted: super::FileConfig = toml::from_str(&rendered).unwrap(); + assert_eq!( + converted.command_timeout, + Some(std::time::Duration::from_millis(999)) + ); + assert_eq!( + converted.enroll_token_ttl, + Some(std::time::Duration::from_secs(5678)) + ); + assert!(parsed.get("command_timeout_ms").is_none()); + assert!(parsed.get("enroll_token_ttl_seconds").is_none()); assert_eq!( parsed["state_file"].as_str(), Some("/tmp/wakey-base/old.sqlite3") @@ -335,10 +347,8 @@ json_logs = true parsed["state_file"].as_str(), defaults.data_dir.join(&defaults.state_file).to_str() ); - assert_eq!( - parsed["command_timeout_ms"].as_integer(), - Some(defaults.command_timeout.as_millis() as i64) - ); + let converted: super::FileConfig = toml::from_str(&rendered).unwrap(); + assert_eq!(converted.command_timeout, Some(defaults.command_timeout)); assert_eq!( parsed["telemetry"]["service_name"].as_str(), Some(defaults.telemetry.service_name.as_str()) diff --git a/wakey-control-plane/src/config/resolve.rs b/wakey-control-plane/src/config/resolve.rs index b431ccc..9f42d5b 100644 --- a/wakey-control-plane/src/config/resolve.rs +++ b/wakey-control-plane/src/config/resolve.rs @@ -51,26 +51,22 @@ impl DaemonConfig { .unwrap_or_else(|| defaults.state_file.clone()); let state_file = resolve_path(&data_dir, state_file_raw); - let command_timeout = Duration::from_millis( - args.command_timeout_ms - .or(file.command_timeout_ms) - .unwrap_or(defaults.command_timeout.as_millis() as u64) - .max(1), - ); - - let enroll_token_ttl = Duration::from_secs( - args.enroll_token_ttl_seconds - .or(file.enroll_token_ttl_seconds) - .unwrap_or(defaults.enroll_token_ttl.as_secs()) - .max(1), - ); - - let observation_retention = Duration::from_secs( - args.observation_retention_seconds - .or(file.observation_retention_seconds) - .unwrap_or(defaults.observation_retention.as_secs()) - .max(1), - ); + 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 pid_file_raw = args .pid_file @@ -101,7 +97,7 @@ impl DaemonConfig { state_file, command_timeout, enroll_token_ttl, - observation_retention, + history_retention, pid_file, ui_dist_dir, bootstrap_enroll_tokens, @@ -140,12 +136,12 @@ pub fn resolve_issue_token_settings(args: &IssueEnrollTokenArgs) -> Result Result { let raw = std::fs::read_to_string(path) .with_context(|| format!("failed to read config file {}", path.display()))?; - toml::from_str::(&raw) - .with_context(|| format!("failed to parse 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) +} + +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( @@ -348,4 +388,42 @@ mod tests { defaults.telemetry.service_name ); } + + #[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() + ); + } } diff --git a/wakey-control-plane/src/config/types.rs b/wakey-control-plane/src/config/types.rs index 4ee1002..bb31704 100644 --- a/wakey-control-plane/src/config/types.rs +++ b/wakey-control-plane/src/config/types.rs @@ -4,6 +4,7 @@ use std::sync::LazyLock; use std::time::Duration; use serde::{Deserialize, Serialize}; +use wakey_core::config_duration::{self, LegacyDuration}; pub use wakey_core::telemetry::TelemetryConfig; #[derive(Debug, Clone)] @@ -14,7 +15,7 @@ pub struct DaemonConfig { pub state_file: PathBuf, pub command_timeout: Duration, pub enroll_token_ttl: Duration, - pub observation_retention: Duration, + pub history_retention: Duration, pub pid_file: PathBuf, pub ui_dist_dir: PathBuf, pub bootstrap_enroll_tokens: Vec, @@ -24,14 +25,18 @@ pub struct DaemonConfig { /// TOML DTO for both loading partial files and generating complete files. /// Options preserve omission for precedence; runtime conversion stays separate. #[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)] +#[serde(remote = "Self")] pub(crate) struct FileConfig { pub(crate) data_dir: Option, pub(crate) bind: Option, pub(crate) public_url: Option, pub(crate) state_file: Option, - pub(crate) command_timeout_ms: Option, - pub(crate) enroll_token_ttl_seconds: Option, - pub(crate) observation_retention_seconds: Option, + #[serde(default, with = "humantime_serde::option")] + pub(crate) command_timeout: Option, + #[serde(default, with = "humantime_serde::option")] + pub(crate) enroll_token_ttl: Option, + #[serde(default, with = "humantime_serde::option")] + pub(crate) history_retention: Option, pub(crate) pid_file: Option, pub(crate) ui_dist_dir: Option, #[serde(alias = "enroll_tokens")] @@ -39,6 +44,35 @@ pub(crate) struct FileConfig { pub(crate) telemetry: Option, } +pub(crate) const DURATION_KEYS: &[LegacyDuration] = &[ + LegacyDuration { + old: "command_timeout_ms", + new: "command_timeout", + unit: Duration::from_millis(1), + }, + LegacyDuration { + old: "enroll_token_ttl_seconds", + new: "enroll_token_ttl", + unit: Duration::from_secs(1), + }, + LegacyDuration { + old: "observation_retention_seconds", + new: "history_retention", + unit: Duration::from_secs(1), + }, +]; + +impl Serialize for FileConfig { + fn serialize(&self, serializer: S) -> Result { + Self::serialize(self, serializer) + } +} +impl<'de> Deserialize<'de> for FileConfig { + fn deserialize>(deserializer: D) -> Result { + config_duration::deserialize_legacy(deserializer, DURATION_KEYS, Self::deserialize) + } +} + #[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)] pub(crate) struct FileTelemetryConfig { // Missing fields must remain distinguishable from explicit values when @@ -60,7 +94,7 @@ pub(crate) static DEFAULT_CONFIG: LazyLock = LazyLock::new(|| Daem state_file: "state.sqlite3".into(), command_timeout: Duration::from_millis(30_000), enroll_token_ttl: Duration::from_secs(86_400), - observation_retention: Duration::from_secs(2_592_000), + history_retention: Duration::from_secs(2_592_000), pid_file: "wakey-control-plane.pid".into(), ui_dist_dir: "ui/dist".into(), bootstrap_enroll_tokens: Vec::new(), @@ -100,7 +134,7 @@ mod tests { let explicit = FileConfig { bootstrap_enroll_tokens: Some(Vec::new()), - observation_retention_seconds: Some(0), + history_retention: Some(std::time::Duration::ZERO), telemetry: Some(FileTelemetryConfig { json_logs: Some(false), console_filter: Some("info".into()), @@ -124,4 +158,32 @@ mod tests { assert!(rendered.contains("bootstrap_enroll_tokens = []")); assert!(!rendered.starts_with("enroll_tokens")); } + + #[test] + fn legacy_durations_match_new_keys_and_emit_only_strings() { + let legacy: FileConfig = toml::from_str("command_timeout_ms = 1234\nenroll_token_ttl_seconds = 3600\nobservation_retention_seconds = 2592000").unwrap(); + let canonical: FileConfig = toml::from_str( + "command_timeout = '1234ms'\nenroll_token_ttl = '1h'\nhistory_retention = '30d'", + ) + .unwrap(); + assert_eq!(legacy, canonical); + let output = toml::to_string(&legacy).unwrap(); + for key in super::DURATION_KEYS { + assert!(!output.contains(key.old)); + assert!(output.contains(key.new)); + } + assert_eq!(toml::from_str::(&output).unwrap(), canonical); + } + + #[test] + fn file_durations_reject_duplicates_and_bare_numbers() { + for key in super::DURATION_KEYS { + let both = format!("{} = '1s'\n{} = 1", key.new, key.old); + assert!(toml::from_str::(&both).is_err()); + let number = format!("{} = 1", key.new); + assert!(toml::from_str::(&number).is_err()); + let negative = format!("{} = -1", key.old); + assert!(toml::from_str::(&negative).is_err()); + } + } } diff --git a/wakey-control-plane/src/runtime/mod.rs b/wakey-control-plane/src/runtime/mod.rs index 37ed5be..e1fd905 100644 --- a/wakey-control-plane/src/runtime/mod.rs +++ b/wakey-control-plane/src/runtime/mod.rs @@ -248,7 +248,7 @@ pub async fn serve(daemon: config::DaemonConfig) -> Result<()> { } Err(err) => warn!(error = %err, "periodic gc failed"), } - match app_state.store.gc_retention(daemon.observation_retention).await { + match app_state.store.gc_retention(daemon.history_retention).await { Ok(stats) => { if stats.audit_events_removed > 0 || stats.alert_transitions_removed > 0 { info!( diff --git a/wakey-core/Cargo.toml b/wakey-core/Cargo.toml index 0c45d3d..c68e5e1 100644 --- a/wakey-core/Cargo.toml +++ b/wakey-core/Cargo.toml @@ -17,6 +17,8 @@ time = { version = "0.3", features = ["formatting", "local-offset"], optional = tonic = { version = "0.14", default-features = false, optional = true } macaddr = { workspace = true } serde = { workspace = true } +serde_json = { workspace = true } +humantime = "2" serde_with = { version = "3", features = ["json"] } strum = { version = "0", features = ["derive", "strum_macros"] } thiserror = "2" diff --git a/wakey-core/src/config_duration.rs b/wakey-core/src/config_duration.rs new file mode 100644 index 0000000..1d35e4e --- /dev/null +++ b/wakey-core/src/config_duration.rs @@ -0,0 +1,114 @@ +//! Human-readable config durations and the unit-aware legacy key adapter. +//! Protocol numbers and metric units deliberately do not use this adapter. + +use std::time::Duration; + +use serde::{Deserialize, Deserializer, de::Error}; +use serde_json::Value; + +#[derive(Clone, Copy)] +pub struct LegacyDuration { + pub old: &'static str, + pub new: &'static str, + pub unit: Duration, +} + +/// Normalize one config table before normal serde decoding. The returned keys +/// let loaders warn only after validation, even before tracing is initialized. +pub fn normalize( + value: &mut Value, + keys: &[LegacyDuration], +) -> Result, String> { + let table = value.as_object_mut().ok_or("expected a config table")?; + let mut deprecated = Vec::new(); + for key in keys { + if table.contains_key(key.old) && table.contains_key(key.new) { + return Err(format!( + "configure only one of `{}` and `{}`", + key.old, key.new + )); + } + if let Some(old) = table.remove(key.old) { + let duration = if let Some(number) = old.as_u64() { + // u64 multiplication must not wrap for large legacy day values. + let nanos = key + .unit + .as_nanos() + .checked_mul(u128::from(number)) + .ok_or_else(|| format!("`{}` is too large", key.old))?; + let seconds = u64::try_from(nanos / 1_000_000_000) + .map_err(|_| format!("`{}` is too large", key.old))?; + Duration::new(seconds, (nanos % 1_000_000_000) as u32) + } else if let Some(text) = old.as_str() { + humantime::parse_duration(text).map_err(|err| format!("`{}`: {err}", key.old))? + } else { + return Err(format!( + "`{}` requires a non-negative integer or duration string", + key.old + )); + }; + table.insert( + key.new.into(), + Value::String(humantime::format_duration(duration).to_string()), + ); + deprecated.push(*key); + } + if let Some(value) = table.get(key.new) { + let text = value + .as_str() + .ok_or_else(|| format!("`{}` requires a duration string with units", key.new))?; + humantime::parse_duration(text).map_err(|err| format!("`{}`: {err}", key.new))?; + } + } + Ok(deprecated) +} + +/// Decode through the canonical serde definition after translating old keys. +/// The callback is a serde-generated associated function (`remote = "Self"`), +/// avoiding a second copy of every unrelated config field in a legacy DTO. +pub fn deserialize_legacy<'de, D, T>( + deserializer: D, + keys: &[LegacyDuration], + decode: impl FnOnce(Value) -> Result, +) -> Result +where + D: Deserializer<'de>, +{ + let mut value = Value::deserialize(deserializer)?; + normalize(&mut value, keys).map_err(D::Error::custom)?; + decode(value).map_err(D::Error::custom) +} + +#[cfg(test)] +mod tests { + use super::*; + const KEYS: &[LegacyDuration] = &[LegacyDuration { + old: "interval_ms", + new: "interval", + unit: Duration::from_millis(1), + }]; + + #[test] + fn legacy_units_are_exact_and_conflicts_are_rejected() { + let mut value = serde_json::json!({"interval_ms": 1234}); + assert_eq!(normalize(&mut value, KEYS).unwrap().len(), 1); + assert_eq!( + humantime::parse_duration(value["interval"].as_str().unwrap()).unwrap(), + Duration::from_millis(1234) + ); + assert!( + normalize( + &mut serde_json::json!({"interval_ms": 1, "interval": "1s"}), + KEYS + ) + .is_err() + ); + for invalid in [ + serde_json::json!({"interval": 5}), + serde_json::json!({"interval_ms": -1}), + serde_json::json!({"interval": "potato"}), + ] { + assert!(normalize(&mut invalid.clone(), KEYS).is_err()); + } + } +} diff --git a/wakey-core/src/lib.rs b/wakey-core/src/lib.rs index c66e429..7901781 100644 --- a/wakey-core/src/lib.rs +++ b/wakey-core/src/lib.rs @@ -1,5 +1,6 @@ //! Shared domain types and parsing for DHCP, neighbors, interfaces, devices, and wake flows. +pub mod config_duration; pub mod model; pub mod parse; diff --git a/wakey-linux/src/observations.rs b/wakey-linux/src/observations.rs index c2271e1..69e38ae 100644 --- a/wakey-linux/src/observations.rs +++ b/wakey-linux/src/observations.rs @@ -165,17 +165,27 @@ pub async fn prune_stale_observations_from_path( path: impl AsRef, retention_days: u64, ) -> io::Result { - if retention_days == 0 { - return Ok(0); - } - let max_age_seconds = retention_days.saturating_mul(24 * 60 * 60); - prune_observations_older_than_from_path(path, now_unix().saturating_sub(max_age_seconds)).await + prune_expired( + path, + std::time::Duration::from_secs(retention_days.saturating_mul(24 * 60 * 60)), + ) + .await } -async fn prune_observations_older_than_from_path( +/// Remove hook observations older than `retention` from the given store. +/// Zero disables expiry; age is measured from each row's last-seen timestamp. +pub async fn prune_expired( path: impl AsRef, - cutoff_unix: u64, + retention: std::time::Duration, ) -> io::Result { + if retention.is_zero() { + return Ok(0); + } + let max_age_seconds = retention.as_secs(); + prune_before(path, now_unix().saturating_sub(max_age_seconds)).await +} + +async fn prune_before(path: impl AsRef, cutoff_unix: u64) -> io::Result { let path = path.as_ref(); let mut store = load_observation_store_from_path(path).await?; let before = store.dhcp_clients.len() + store.neighbors.len(); @@ -648,7 +658,7 @@ mod tests { .await .expect("fixture should write"); - let removed = prune_observations_older_than_from_path(&observation_path, 15) + let removed = prune_before(&observation_path, 15) .await .expect("prune should succeed");