feat(config): support human-readable durations with legacy compatibility

This commit is contained in:
lda committed 2026-10-10 23:37:04 +07:00 Verified
1 parent f91186a7b1
commit f098d85621
22 files changed
+715 -151

No files matched your search

Generated
+19
View File
@@ -1031,6 +1031,22 @@ version = "1.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" 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]] [[package]]
name = "hyper" name = "hyper"
version = "1.9.0" version = "1.9.0"
@@ -3615,6 +3631,7 @@ dependencies = [
"clap", "clap",
"futures-util", "futures-util",
"http", "http",
"humantime-serde",
"macaddr", "macaddr",
"nix 0.31.2", "nix 0.31.2",
"reqwest", "reqwest",
@@ -3638,6 +3655,7 @@ dependencies = [
"axum", "axum",
"clap", "clap",
"futures-util", "futures-util",
"humantime-serde",
"macaddr", "macaddr",
"nix 0.31.2", "nix 0.31.2",
"reqwest", "reqwest",
@@ -3658,6 +3676,7 @@ name = "wakey-core"
version = "0.3.6" version = "0.3.6"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"humantime",
"macaddr", "macaddr",
"opentelemetry", "opentelemetry",
"opentelemetry-appender-tracing", "opentelemetry-appender-tracing",
+1 -1
View File
@@ -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. 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. 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.
+74
View File
@@ -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.
+4
View File
@@ -22,6 +22,8 @@ live browser recovery and shutdown behavior are not established by those checks.
## Device Management And Presentation ## 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] 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. - [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. - [ ] Check and complete editing of remembered-device notes.
@@ -34,6 +36,8 @@ of remembered identifiers based on observation expiry.
## Telemetry Follow-Ups ## 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. - [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. - [ ] 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. - [ ] Investigate the slow resolver with a reproducible device-side check. Timing isolated the phase, not the cause; do not treat instrumentation as a fix.
+1
View File
@@ -9,6 +9,7 @@ anyhow = "1"
clap = { version = "4", features = ["derive"] } clap = { version = "4", features = ["derive"] }
futures-util = "0.3" futures-util = "0.3"
http = "1" http = "1"
humantime-serde = "1"
macaddr = { version = "1", features = ["serde", "serde_std"] } macaddr = { version = "1", features = ["serde", "serde_std"] }
nix = { version = "0.31", default-features = false, features = [ nix = { version = "0.31", default-features = false, features = [
"signal", "signal",
+221 -51
View File
@@ -3,6 +3,8 @@ use serde::{Deserialize, Serialize};
use std::fmt; use std::fmt;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use std::sync::LazyLock; use std::sync::LazyLock;
use std::time::Duration;
use wakey_core::config_duration::{self, LegacyDuration};
use crate::protocol::{ use crate::protocol::{
DEFAULT_TERMINAL_MAX_SESSIONS, DEFAULT_TERMINAL_SESSION_TTL_SECONDS, 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"; const DEFAULT_OBSERVATION_STORE_PATH: &str = "/tmp/wakey_observations.json";
pub const DEFAULT_OBSERVATION_RETENTION_DAYS: u64 = 7; 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)] #[derive(Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(remote = "Self")]
pub struct AgentConfig { pub struct AgentConfig {
pub server_url: String, pub server_url: String,
pub agent_id: String, pub agent_id: String,
pub agent_token: String, pub agent_token: String,
#[serde(default = "default_reconnect_base_ms")] #[serde(default = "default_reconnect_base", with = "humantime_serde")]
pub reconnect_base_ms: u64, pub reconnect_base: Duration,
#[serde(default = "default_reconnect_max_ms")] #[serde(default = "default_reconnect_max", with = "humantime_serde")]
pub reconnect_max_ms: u64, pub reconnect_max: Duration,
#[serde(default = "default_observation_sync_interval_seconds")] #[serde(
pub observation_sync_interval_seconds: u64, default = "default_observation_sync_interval",
#[serde(default = "default_observation_retention_days")] with = "humantime_serde"
pub observation_retention_days: u64, )]
pub observation_sync_interval: Duration,
#[serde(default = "default_observation_retention", with = "humantime_serde")]
pub observation_retention: Duration,
#[serde(default = "default_pid_file")] #[serde(default = "default_pid_file")]
pub pid_file: PathBuf, pub pid_file: PathBuf,
#[serde(default = "default_dhcp_leases_path")] #[serde(default = "default_dhcp_leases_path")]
@@ -47,7 +55,7 @@ pub struct AgentConfig {
} }
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] #[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
#[serde(deny_unknown_fields)] #[serde(remote = "Self", deny_unknown_fields)]
pub struct TerminalConfig { pub struct TerminalConfig {
#[serde(default)] #[serde(default)]
pub enabled: bool, pub enabled: bool,
@@ -60,8 +68,8 @@ pub struct TerminalConfig {
#[serde(default = "default_terminal_max_sessions")] #[serde(default = "default_terminal_max_sessions")]
pub max_sessions: usize, pub max_sessions: usize,
/// Maximum lifetime of a PTY process. Zero disables automatic expiry. /// Maximum lifetime of a PTY process. Zero disables automatic expiry.
#[serde(default = "default_terminal_session_ttl_seconds")] #[serde(default = "default_terminal_session_ttl", with = "humantime_serde")]
pub session_ttl_seconds: u64, pub session_ttl: Duration,
} }
impl Default for TerminalConfig { impl Default for TerminalConfig {
@@ -72,7 +80,7 @@ impl Default for TerminalConfig {
args: Vec::new(), args: Vec::new(),
current_dir: None, current_dir: None,
max_sessions: default_terminal_max_sessions(), 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<AgentConfig> = LazyLock::new(|| AgentConfig
server_url: "https://wakey.ldlda.com".to_string(), server_url: "https://wakey.ldlda.com".to_string(),
agent_id: "REPLACE_ME_AGENT_ID".to_string(), agent_id: "REPLACE_ME_AGENT_ID".to_string(),
agent_token: "REPLACE_ME_AGENT_TOKEN".to_string(), agent_token: "REPLACE_ME_AGENT_TOKEN".to_string(),
reconnect_base_ms: default_reconnect_base_ms(), reconnect_base: default_reconnect_base(),
reconnect_max_ms: default_reconnect_max_ms(), reconnect_max: default_reconnect_max(),
observation_sync_interval_seconds: default_observation_sync_interval_seconds(), observation_sync_interval: default_observation_sync_interval(),
observation_retention_days: default_observation_retention_days(), observation_retention: default_observation_retention(),
pid_file: default_pid_file(), pid_file: default_pid_file(),
dhcp_leases_path: default_dhcp_leases_path(), dhcp_leases_path: default_dhcp_leases_path(),
mac_name_cache_path: default_mac_name_cache_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("server_url", &self.server_url)
.field("agent_id", &self.agent_id) .field("agent_id", &self.agent_id)
.field("agent_token", &"<redacted>") .field("agent_token", &"<redacted>")
.field("reconnect_base_ms", &self.reconnect_base_ms) .field("reconnect_base", &self.reconnect_base)
.field("reconnect_max_ms", &self.reconnect_max_ms) .field("reconnect_max", &self.reconnect_max)
.field( .field("observation_sync_interval", &self.observation_sync_interval)
"observation_sync_interval_seconds", .field("observation_retention", &self.observation_retention)
&self.observation_sync_interval_seconds,
)
.field(
"observation_retention_days",
&self.observation_retention_days,
)
.field("pid_file", &self.pid_file) .field("pid_file", &self.pid_file)
.field("dhcp_leases_path", &self.dhcp_leases_path) .field("dhcp_leases_path", &self.dhcp_leases_path)
.field("mac_name_cache_path", &self.mac_name_cache_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 { const fn default_reconnect_base() -> Duration {
1_000 Duration::from_secs(1)
} }
const fn default_reconnect_max_ms() -> u64 { const fn default_reconnect_max() -> Duration {
30_000 Duration::from_secs(30)
} }
const fn default_observation_sync_interval_seconds() -> u64 { const fn default_observation_sync_interval() -> Duration {
60 Duration::from_secs(60)
} }
const fn default_observation_retention_days() -> u64 { const fn default_observation_retention() -> Duration {
DEFAULT_OBSERVATION_RETENTION_DAYS Duration::from_secs(DEFAULT_OBSERVATION_RETENTION_DAYS * 86_400)
} }
fn default_pid_file() -> PathBuf { fn default_pid_file() -> PathBuf {
@@ -159,17 +161,43 @@ const fn default_terminal_max_sessions() -> usize {
DEFAULT_TERMINAL_MAX_SESSIONS DEFAULT_TERMINAL_MAX_SESSIONS
} }
const fn default_terminal_session_ttl_seconds() -> u64 { const fn default_terminal_session_ttl() -> Duration {
DEFAULT_TERMINAL_SESSION_TTL_SECONDS Duration::from_secs(DEFAULT_TERMINAL_SESSION_TTL_SECONDS)
} }
impl TerminalConfig { impl TerminalConfig {
pub(crate) fn session_ttl(&self) -> Result<Option<std::time::Duration>> { pub(crate) fn session_ttl(&self) -> Result<Option<std::time::Duration>> {
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 { 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)> { pub fn local_path_envs(&self) -> Vec<(&'static str, &Path)> {
vec![ vec![
(WAKEY_DHCP_LEASES_ENV, self.dhcp_leases_path.as_path()), (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<AgentConfig> { pub fn load_config(path: &Path) -> Result<AgentConfig> {
let content = std::fs::read_to_string(path) let content = std::fs::read_to_string(path)
.with_context(|| format!("failed to read agent config {}", path.display()))?; .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()))?; .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::<Vec<_>>();
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) 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<S: serde::Serializer>(
&self,
serializer: S,
) -> std::result::Result<S::Ok, S::Error> {
Self::serialize(self, serializer)
}
}
impl<'de> Deserialize<'de> for AgentConfig {
fn deserialize<D: serde::Deserializer<'de>>(
deserializer: D,
) -> std::result::Result<Self, D::Error> {
config_duration::deserialize_legacy(deserializer, AGENT_DURATION_KEYS, Self::deserialize)
}
}
impl Serialize for TerminalConfig {
fn serialize<S: serde::Serializer>(
&self,
serializer: S,
) -> std::result::Result<S::Ok, S::Error> {
Self::serialize(self, serializer)
}
}
impl<'de> Deserialize<'de> for TerminalConfig {
fn deserialize<D: serde::Deserializer<'de>>(
deserializer: D,
) -> std::result::Result<Self, D::Error> {
config_duration::deserialize_legacy(deserializer, TERMINAL_DURATION_KEYS, Self::deserialize)
}
}
pub fn save_config(path: &Path, config: &AgentConfig) -> Result<()> { pub fn save_config(path: &Path, config: &AgentConfig) -> Result<()> {
config.validate()?;
let content = toml::to_string_pretty(config).context("failed to serialize agent config")?; let content = toml::to_string_pretty(config).context("failed to serialize agent config")?;
write_config_atomically(path, &content) write_config_atomically(path, &content)
} }
@@ -262,6 +376,62 @@ fn write_config_atomically(path: &Path, content: &str) -> Result<()> {
mod tests { mod tests {
use super::*; 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::<AgentConfig>(&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::<AgentConfig>(&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] #[test]
fn config_roundtrip() { fn config_roundtrip() {
let dir = std::env::temp_dir().join(format!("wakey-agent-config-{}", std::process::id())); 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(), server_url: "https://example.com".into(),
agent_id: "agent-1".into(), agent_id: "agent-1".into(),
agent_token: "secret".into(), agent_token: "secret".into(),
reconnect_base_ms: 123, reconnect_base: Duration::from_millis(123),
reconnect_max_ms: 456, reconnect_max: Duration::from_millis(456),
observation_sync_interval_seconds: 7, observation_sync_interval: Duration::from_secs(7),
observation_retention_days: 3, observation_retention: Duration::from_secs(3 * 86_400),
pid_file: "/tmp/test-wakey-agent.pid".into(), pid_file: "/tmp/test-wakey-agent.pid".into(),
dhcp_leases_path: "/tmp/test-dhcp.leases".into(), dhcp_leases_path: "/tmp/test-dhcp.leases".into(),
mac_name_cache_path: "/tmp/test-names.json".into(), mac_name_cache_path: "/tmp/test-names.json".into(),
@@ -293,7 +463,7 @@ mod tests {
args: vec!["-l".into()], args: vec!["-l".into()],
current_dir: Some("/tmp".into()), current_dir: Some("/tmp".into()),
max_sessions: 2, max_sessions: 2,
session_ttl_seconds: 600, session_ttl: Duration::from_secs(600),
}, },
}; };
@@ -323,8 +493,8 @@ agent_token = "secret"
.expect("config should parse"); .expect("config should parse");
assert_eq!( assert_eq!(
config.observation_retention_days, config.observation_retention,
DEFAULT_OBSERVATION_RETENTION_DAYS DEFAULT_CONFIG.observation_retention
); );
assert_eq!(config.terminal, TerminalConfig::default()); assert_eq!(config.terminal, TerminalConfig::default());
} }
@@ -348,8 +518,8 @@ max_sessions = 2
assert!(config.terminal.args.is_empty()); assert!(config.terminal.args.is_empty());
assert!(config.terminal.current_dir.is_none()); assert!(config.terminal.current_dir.is_none());
assert_eq!( assert_eq!(
config.terminal.session_ttl_seconds, config.terminal.session_ttl,
DEFAULT_TERMINAL_SESSION_TTL_SECONDS TerminalConfig::default().session_ttl
); );
} }
@@ -374,18 +544,18 @@ max_session = 67
#[test] #[test]
fn terminal_ttl_zero_is_unlimited_and_positive_is_exact() { fn terminal_ttl_zero_is_unlimited_and_positive_is_exact() {
let mut terminal = TerminalConfig { let mut terminal = TerminalConfig {
session_ttl_seconds: 0, session_ttl: Duration::ZERO,
..TerminalConfig::default() ..TerminalConfig::default()
}; };
assert_eq!(terminal.session_ttl().expect("unlimited TTL"), None); assert_eq!(terminal.session_ttl().expect("unlimited TTL"), None);
terminal.session_ttl_seconds = 67; terminal.session_ttl = Duration::from_secs(67);
assert_eq!( assert_eq!(
terminal.session_ttl().expect("finite TTL"), terminal.session_ttl().expect("finite TTL"),
Some(std::time::Duration::from_secs(67)) 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()); assert!(terminal.session_ttl().is_err());
} }
} }
+2 -2
View File
@@ -89,9 +89,9 @@ pub async fn inventory_for_config(
.instrument(tracing::info_span!("inventory_dhcp_leases")) .instrument(tracing::info_span!("inventory_dhcp_leases"))
.await?; .await?;
tracing::Span::current().record("leases", leases.len()); 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_store_path,
config.observation_retention_days, config.observation_retention,
) )
.instrument(tracing::info_span!("inventory_observation_prune")) .instrument(tracing::info_span!("inventory_observation_prune"))
.await .await
+9 -6
View File
@@ -143,10 +143,10 @@ mod tests {
server_url: "https://old.example.com".into(), server_url: "https://old.example.com".into(),
agent_id: "old-agent".into(), agent_id: "old-agent".into(),
agent_token: "old-token".into(), agent_token: "old-token".into(),
reconnect_base_ms: 2_000, reconnect_base: std::time::Duration::from_secs(2),
reconnect_max_ms: 60_000, reconnect_max: std::time::Duration::from_secs(60),
observation_sync_interval_seconds: 30, observation_sync_interval: std::time::Duration::from_secs(30),
observation_retention_days: 11, observation_retention: std::time::Duration::from_secs(11 * 86_400),
pid_file: "/tmp/custom-wakey-agent.pid".into(), pid_file: "/tmp/custom-wakey-agent.pid".into(),
dhcp_leases_path: "/tmp/custom-dhcp.leases".into(), dhcp_leases_path: "/tmp/custom-dhcp.leases".into(),
mac_name_cache_path: "/tmp/custom-names.json".into(), mac_name_cache_path: "/tmp/custom-names.json".into(),
@@ -164,7 +164,7 @@ mod tests {
args: vec!["-l".into()], args: vec!["-l".into()],
current_dir: Some("/root".into()), current_dir: Some("/root".into()),
max_sessions: 2, 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_id, "agent-123");
assert_eq!(config.agent_token, "token-xyz"); assert_eq!(config.agent_token, "token-xyz");
assert_eq!(config.server_url, "https://control.example.com"); 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.pid_file, base_config.pid_file);
assert_eq!(config.dhcp_leases_path, base_config.dhcp_leases_path); assert_eq!(config.dhcp_leases_path, base_config.dhcp_leases_path);
assert_eq!(config.terminal, base_config.terminal); assert_eq!(config.terminal, base_config.terminal);
+4 -1
View File
@@ -290,7 +290,10 @@ fn init_config(args: InitConfigArgs) -> Result<()> {
cfg.agent_token = agent_token; cfg.agent_token = agent_token;
} }
if let Some(days) = args.observation_retention_days { 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 { if let Some(path) = &args.config {
+29 -17
View File
@@ -18,16 +18,16 @@ pub async fn run(config: AgentConfig) -> Result<()> {
// Terminal workers belong to the agent process, not a single control socket. // Terminal workers belong to the agent process, not a single control socket.
// Keeping this manager outside the reconnect loop lets PTYs survive CC loss. // Keeping this manager outside the reconnect loop lets PTYs survive CC loss.
let (terminal_manager, mut terminal_events) = TerminalManager::new(&config); 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 { loop {
match run_once(&config, &terminal_manager, &mut terminal_events).await { match run_once(&config, &terminal_manager, &mut terminal_events).await {
Ok(()) => { Ok(()) => {
backoff = config.reconnect_base_ms.max(100); backoff = config.reconnect_base.max(Duration::from_millis(100));
} }
Err(err) => { Err(err) => {
warn!(agent_id = %config.agent_id, error = %err, backoff_ms = backoff, "agent session ended; reconnecting"); warn!(agent_id = %config.agent_id, error = %err, backoff_ms = backoff.as_millis() as u64, "agent session ended; reconnecting");
sleep(Duration::from_millis(backoff)).await; sleep(backoff).await;
backoff = next_backoff_ms(backoff, config.reconnect_max_ms); backoff = next_backoff(backoff, config.reconnect_max);
} }
} }
} }
@@ -94,7 +94,7 @@ async fn run_once(
.enabled .enabled
.then_some(TerminalCapabilityOptions { .then_some(TerminalCapabilityOptions {
max_sessions: config.terminal.max_sessions.max(1), 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)); let mut heartbeat = interval(Duration::from_secs(30));
heartbeat.set_missed_tick_behavior(MissedTickBehavior::Skip); heartbeat.set_missed_tick_behavior(MissedTickBehavior::Skip);
let mut snapshot_sync = interval(Duration::from_secs( let mut snapshot_sync = interval(config.observation_sync_interval);
config.observation_sync_interval_seconds.max(1),
));
snapshot_sync.set_missed_tick_behavior(MissedTickBehavior::Skip); snapshot_sync.set_missed_tick_behavior(MissedTickBehavior::Skip);
snapshot_sync.reset(); snapshot_sync.reset();
loop { loop {
@@ -238,9 +236,9 @@ where
Ok(count) Ok(count)
} }
pub fn next_backoff_ms(current_ms: u64, max_ms: u64) -> u64 { pub fn next_backoff(current: Duration, max: Duration) -> Duration {
let cap = max_ms.max(current_ms); let cap = max.max(current);
current_ms.saturating_mul(2).min(cap) current.saturating_mul(2).min(cap)
} }
async fn handle_server_message<S>( async fn handle_server_message<S>(
@@ -474,7 +472,7 @@ mod tests {
use serde_json::Value; use serde_json::Value;
use tokio_tungstenite::tungstenite::Message; 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}; use crate::protocol::{CommandResult, RequestId};
struct RecordingSink { struct RecordingSink {
@@ -533,14 +531,28 @@ mod tests {
#[test] #[test]
fn backoff_doubles_until_cap() { fn backoff_doubles_until_cap() {
assert_eq!(next_backoff_ms(1_000, 30_000), 2_000); use std::time::Duration;
assert_eq!(next_backoff_ms(16_000, 30_000), 30_000); assert_eq!(
assert_eq!(next_backoff_ms(30_000, 30_000), 30_000); 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] #[test]
fn backoff_never_shrinks_when_max_is_lower() { 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] #[tokio::test]
+1 -1
View File
@@ -224,7 +224,7 @@ impl TerminalManager {
cancel: Some(cancel_tx), cancel: Some(cancel_tx),
relay_credentials: relay_tx.clone(), relay_credentials: relay_tx.clone(),
created_at_unix, created_at_unix,
session_ttl_seconds: config.terminal.session_ttl_seconds, session_ttl_seconds: config.terminal.session_ttl.as_secs(),
}, },
); );
} }
+1
View File
@@ -9,6 +9,7 @@ anyhow = { workspace = true }
axum = { version = "0.8", features = ["ws", "json"] } axum = { version = "0.8", features = ["ws", "json"] }
clap = { version = "4", features = ["derive"] } clap = { version = "4", features = ["derive"] }
futures-util = "0.3" futures-util = "0.3"
humantime-serde = "1"
macaddr = { workspace = true } macaddr = { workspace = true }
nix = { version = "0.31", default-features = false, features = [ nix = { version = "0.31", default-features = false, features = [
"signal", "signal",
+3 -5
View File
@@ -172,11 +172,9 @@ pub async fn relay_agent_command(
)); ));
} }
let timeout = std::time::Duration::from_millis( let timeout = timeout_ms
timeout_ms .map(|value| std::time::Duration::from_millis(value.max(1)))
.unwrap_or(state.command_timeout.as_millis() as u64) .unwrap_or(state.command_timeout);
.max(1),
);
let outcome = tokio::time::timeout(timeout, pending_rx).await; let outcome = tokio::time::timeout(timeout, pending_rx).await;
let response = match outcome { let response = match outcome {
Ok(Ok(AgentReply::Result(result))) => { Ok(Ok(AgentReply::Result(result))) => {
+2
View File
@@ -72,6 +72,7 @@ pub struct ServeArgs {
#[arg(long)] #[arg(long)]
pub enroll_token_ttl_seconds: Option<u64>, pub enroll_token_ttl_seconds: Option<u64>,
/// Audit/alert history retention in seconds (legacy CLI name; not device retention).
#[arg(long)] #[arg(long)]
pub observation_retention_seconds: Option<u64>, pub observation_retention_seconds: Option<u64>,
@@ -129,6 +130,7 @@ pub struct InitConfigArgs {
#[arg(long)] #[arg(long)]
pub enroll_token_ttl_seconds: Option<u64>, pub enroll_token_ttl_seconds: Option<u64>,
/// Audit/alert history retention in seconds (legacy CLI name; not device retention).
#[arg(long)] #[arg(long)]
pub observation_retention_seconds: Option<u64>, pub observation_retention_seconds: Option<u64>,
+33 -23
View File
@@ -87,28 +87,31 @@ pub fn write_init_config(args: &InitConfigArgs) -> Result<Option<PathBuf>> {
args.bootstrap_enroll_tokens.clone() 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 { let body = FileConfig {
data_dir: Some(data_dir), data_dir: Some(data_dir),
bind: Some(bind.to_string()), bind: Some(bind.to_string()),
public_url: Some(public_url), public_url: Some(public_url),
state_file: Some(state_file), state_file: Some(state_file),
command_timeout_ms: Some( command_timeout: Some(command_timeout),
args.command_timeout_ms enroll_token_ttl: Some(enroll_token_ttl),
.or(base.command_timeout_ms) history_retention: Some(history_retention),
.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()),
),
pid_file: Some(pid_file), pid_file: Some(pid_file),
ui_dist_dir: Some(ui_dist_dir), ui_dist_dir: Some(ui_dist_dir),
// Preserve the previous generated template's omission of an empty list. // 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"); 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["public_url"].as_str(), Some("https://new.example"));
assert_eq!(parsed["bind"].as_str(), Some("127.0.0.1:9090")); assert_eq!(parsed["bind"].as_str(), Some("127.0.0.1:9090"));
assert_eq!(parsed["command_timeout_ms"].as_integer(), Some(999)); let converted: super::FileConfig = toml::from_str(&rendered).unwrap();
assert_eq!(parsed["enroll_token_ttl_seconds"].as_integer(), Some(5678)); 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!( assert_eq!(
parsed["state_file"].as_str(), parsed["state_file"].as_str(),
Some("/tmp/wakey-base/old.sqlite3") Some("/tmp/wakey-base/old.sqlite3")
@@ -335,10 +347,8 @@ json_logs = true
parsed["state_file"].as_str(), parsed["state_file"].as_str(),
defaults.data_dir.join(&defaults.state_file).to_str() defaults.data_dir.join(&defaults.state_file).to_str()
); );
assert_eq!( let converted: super::FileConfig = toml::from_str(&rendered).unwrap();
parsed["command_timeout_ms"].as_integer(), assert_eq!(converted.command_timeout, Some(defaults.command_timeout));
Some(defaults.command_timeout.as_millis() as i64)
);
assert_eq!( assert_eq!(
parsed["telemetry"]["service_name"].as_str(), parsed["telemetry"]["service_name"].as_str(),
Some(defaults.telemetry.service_name.as_str()) Some(defaults.telemetry.service_name.as_str())
+107 -29
View File
@@ -51,26 +51,22 @@ impl DaemonConfig {
.unwrap_or_else(|| defaults.state_file.clone()); .unwrap_or_else(|| defaults.state_file.clone());
let state_file = resolve_path(&data_dir, state_file_raw); let state_file = resolve_path(&data_dir, state_file_raw);
let command_timeout = Duration::from_millis( let command_timeout = args
args.command_timeout_ms .command_timeout_ms
.or(file.command_timeout_ms) .map(Duration::from_millis)
.unwrap_or(defaults.command_timeout.as_millis() as u64) .or(file.command_timeout)
.max(1), .unwrap_or(defaults.command_timeout);
); let enroll_token_ttl = args
.enroll_token_ttl_seconds
let enroll_token_ttl = Duration::from_secs( .map(Duration::from_secs)
args.enroll_token_ttl_seconds .or(file.enroll_token_ttl)
.or(file.enroll_token_ttl_seconds) .unwrap_or(defaults.enroll_token_ttl);
.unwrap_or(defaults.enroll_token_ttl.as_secs()) let history_retention = args
.max(1), .observation_retention_seconds
); .map(Duration::from_secs)
.or(file.history_retention)
let observation_retention = Duration::from_secs( .unwrap_or(defaults.history_retention);
args.observation_retention_seconds validate_durations(command_timeout, enroll_token_ttl, history_retention)?;
.or(file.observation_retention_seconds)
.unwrap_or(defaults.observation_retention.as_secs())
.max(1),
);
let pid_file_raw = args let pid_file_raw = args
.pid_file .pid_file
@@ -101,7 +97,7 @@ impl DaemonConfig {
state_file, state_file,
command_timeout, command_timeout,
enroll_token_ttl, enroll_token_ttl,
observation_retention, history_retention,
pid_file, pid_file,
ui_dist_dir, ui_dist_dir,
bootstrap_enroll_tokens, bootstrap_enroll_tokens,
@@ -140,12 +136,12 @@ pub fn resolve_issue_token_settings(args: &IssueEnrollTokenArgs) -> Result<Issue
&args.target, &args.target,
)?; )?;
let ttl = Duration::from_secs( let ttl = args
args.ttl_seconds .ttl_seconds
.or(file.enroll_token_ttl_seconds) .map(Duration::from_secs)
.unwrap_or(defaults.enroll_token_ttl.as_secs()) .or(file.enroll_token_ttl)
.max(1), .unwrap_or(defaults.enroll_token_ttl);
); validate_durations(defaults.command_timeout, ttl, defaults.history_retention)?;
Ok(IssueTokenSettings { Ok(IssueTokenSettings {
data_dir: state.data_dir, data_dir: state.data_dir,
@@ -206,8 +202,52 @@ pub(crate) fn load_file_config(path: &Path) -> Result<FileConfig> {
let raw = std::fs::read_to_string(path) let raw = std::fs::read_to_string(path)
.with_context(|| format!("failed to read config file {}", path.display()))?; .with_context(|| format!("failed to read config file {}", path.display()))?;
toml::from_str::<FileConfig>(&raw) let raw: toml::Value = toml::from_str(&raw)
.with_context(|| format!("failed to parse config file {}", path.display())) .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( fn resolve_admin_public_url(
@@ -348,4 +388,42 @@ mod tests {
defaults.telemetry.service_name 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()
);
}
} }
+68 -6
View File
@@ -4,6 +4,7 @@ use std::sync::LazyLock;
use std::time::Duration; use std::time::Duration;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use wakey_core::config_duration::{self, LegacyDuration};
pub use wakey_core::telemetry::TelemetryConfig; pub use wakey_core::telemetry::TelemetryConfig;
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
@@ -14,7 +15,7 @@ pub struct DaemonConfig {
pub state_file: PathBuf, pub state_file: PathBuf,
pub command_timeout: Duration, pub command_timeout: Duration,
pub enroll_token_ttl: Duration, pub enroll_token_ttl: Duration,
pub observation_retention: Duration, pub history_retention: Duration,
pub pid_file: PathBuf, pub pid_file: PathBuf,
pub ui_dist_dir: PathBuf, pub ui_dist_dir: PathBuf,
pub bootstrap_enroll_tokens: Vec<String>, pub bootstrap_enroll_tokens: Vec<String>,
@@ -24,14 +25,18 @@ pub struct DaemonConfig {
/// TOML DTO for both loading partial files and generating complete files. /// TOML DTO for both loading partial files and generating complete files.
/// Options preserve omission for precedence; runtime conversion stays separate. /// Options preserve omission for precedence; runtime conversion stays separate.
#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)] #[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
#[serde(remote = "Self")]
pub(crate) struct FileConfig { pub(crate) struct FileConfig {
pub(crate) data_dir: Option<PathBuf>, pub(crate) data_dir: Option<PathBuf>,
pub(crate) bind: Option<String>, pub(crate) bind: Option<String>,
pub(crate) public_url: Option<String>, pub(crate) public_url: Option<String>,
pub(crate) state_file: Option<PathBuf>, pub(crate) state_file: Option<PathBuf>,
pub(crate) command_timeout_ms: Option<u64>, #[serde(default, with = "humantime_serde::option")]
pub(crate) enroll_token_ttl_seconds: Option<u64>, pub(crate) command_timeout: Option<Duration>,
pub(crate) observation_retention_seconds: Option<u64>, #[serde(default, with = "humantime_serde::option")]
pub(crate) enroll_token_ttl: Option<Duration>,
#[serde(default, with = "humantime_serde::option")]
pub(crate) history_retention: Option<Duration>,
pub(crate) pid_file: Option<PathBuf>, pub(crate) pid_file: Option<PathBuf>,
pub(crate) ui_dist_dir: Option<PathBuf>, pub(crate) ui_dist_dir: Option<PathBuf>,
#[serde(alias = "enroll_tokens")] #[serde(alias = "enroll_tokens")]
@@ -39,6 +44,35 @@ pub(crate) struct FileConfig {
pub(crate) telemetry: Option<FileTelemetryConfig>, pub(crate) telemetry: Option<FileTelemetryConfig>,
} }
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<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
Self::serialize(self, serializer)
}
}
impl<'de> Deserialize<'de> for FileConfig {
fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
config_duration::deserialize_legacy(deserializer, DURATION_KEYS, Self::deserialize)
}
}
#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)] #[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
pub(crate) struct FileTelemetryConfig { pub(crate) struct FileTelemetryConfig {
// Missing fields must remain distinguishable from explicit values when // Missing fields must remain distinguishable from explicit values when
@@ -60,7 +94,7 @@ pub(crate) static DEFAULT_CONFIG: LazyLock<DaemonConfig> = LazyLock::new(|| Daem
state_file: "state.sqlite3".into(), state_file: "state.sqlite3".into(),
command_timeout: Duration::from_millis(30_000), command_timeout: Duration::from_millis(30_000),
enroll_token_ttl: Duration::from_secs(86_400), 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(), pid_file: "wakey-control-plane.pid".into(),
ui_dist_dir: "ui/dist".into(), ui_dist_dir: "ui/dist".into(),
bootstrap_enroll_tokens: Vec::new(), bootstrap_enroll_tokens: Vec::new(),
@@ -100,7 +134,7 @@ mod tests {
let explicit = FileConfig { let explicit = FileConfig {
bootstrap_enroll_tokens: Some(Vec::new()), bootstrap_enroll_tokens: Some(Vec::new()),
observation_retention_seconds: Some(0), history_retention: Some(std::time::Duration::ZERO),
telemetry: Some(FileTelemetryConfig { telemetry: Some(FileTelemetryConfig {
json_logs: Some(false), json_logs: Some(false),
console_filter: Some("info".into()), console_filter: Some("info".into()),
@@ -124,4 +158,32 @@ mod tests {
assert!(rendered.contains("bootstrap_enroll_tokens = []")); assert!(rendered.contains("bootstrap_enroll_tokens = []"));
assert!(!rendered.starts_with("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::<FileConfig>(&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::<FileConfig>(&both).is_err());
let number = format!("{} = 1", key.new);
assert!(toml::from_str::<FileConfig>(&number).is_err());
let negative = format!("{} = -1", key.old);
assert!(toml::from_str::<FileConfig>(&negative).is_err());
}
}
} }
+1 -1
View File
@@ -248,7 +248,7 @@ pub async fn serve(daemon: config::DaemonConfig) -> Result<()> {
} }
Err(err) => warn!(error = %err, "periodic gc failed"), 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) => { Ok(stats) => {
if stats.audit_events_removed > 0 || stats.alert_transitions_removed > 0 { if stats.audit_events_removed > 0 || stats.alert_transitions_removed > 0 {
info!( info!(
+2
View File
@@ -17,6 +17,8 @@ time = { version = "0.3", features = ["formatting", "local-offset"], optional =
tonic = { version = "0.14", default-features = false, optional = true } tonic = { version = "0.14", default-features = false, optional = true }
macaddr = { workspace = true } macaddr = { workspace = true }
serde = { workspace = true } serde = { workspace = true }
serde_json = { workspace = true }
humantime = "2"
serde_with = { version = "3", features = ["json"] } serde_with = { version = "3", features = ["json"] }
strum = { version = "0", features = ["derive", "strum_macros"] } strum = { version = "0", features = ["derive", "strum_macros"] }
thiserror = "2" thiserror = "2"
+114
View File
@@ -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<Vec<LegacyDuration>, 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<T, serde_json::Error>,
) -> Result<T, D::Error>
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());
}
}
}
+1
View File
@@ -1,5 +1,6 @@
//! Shared domain types and parsing for DHCP, neighbors, interfaces, devices, and wake flows. //! Shared domain types and parsing for DHCP, neighbors, interfaces, devices, and wake flows.
pub mod config_duration;
pub mod model; pub mod model;
pub mod parse; pub mod parse;
+18 -8
View File
@@ -165,17 +165,27 @@ pub async fn prune_stale_observations_from_path(
path: impl AsRef<Path>, path: impl AsRef<Path>,
retention_days: u64, retention_days: u64,
) -> io::Result<usize> { ) -> io::Result<usize> {
if retention_days == 0 { prune_expired(
return Ok(0); path,
} std::time::Duration::from_secs(retention_days.saturating_mul(24 * 60 * 60)),
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 .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<Path>, path: impl AsRef<Path>,
cutoff_unix: u64, retention: std::time::Duration,
) -> io::Result<usize> { ) -> io::Result<usize> {
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<Path>, cutoff_unix: u64) -> io::Result<usize> {
let path = path.as_ref(); let path = path.as_ref();
let mut store = load_observation_store_from_path(path).await?; let mut store = load_observation_store_from_path(path).await?;
let before = store.dhcp_clients.len() + store.neighbors.len(); let before = store.dhcp_clients.len() + store.neighbors.len();
@@ -648,7 +658,7 @@ mod tests {
.await .await
.expect("fixture should write"); .expect("fixture should write");
let removed = prune_observations_older_than_from_path(&observation_path, 15) let removed = prune_before(&observation_path, 15)
.await .await
.expect("prune should succeed"); .expect("prune should succeed");