feat(config): support human-readable durations with legacy compatibility
This commit is contained in:
22 files changed
+715
-151
No files matched your search
Generated
+19
@@ -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",
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
|
||||
@@ -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",
|
||||
|
||||
+221
-51
@@ -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<AgentConfig> = 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", &"<redacted>")
|
||||
.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<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 {
|
||||
/// 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<AgentConfig> {
|
||||
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::<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)
|
||||
}
|
||||
|
||||
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<()> {
|
||||
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::<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]
|
||||
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());
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
+29
-17
@@ -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<S>(
|
||||
@@ -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]
|
||||
|
||||
@@ -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(),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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))) => {
|
||||
|
||||
@@ -72,6 +72,7 @@ pub struct ServeArgs {
|
||||
#[arg(long)]
|
||||
pub enroll_token_ttl_seconds: Option<u64>,
|
||||
|
||||
/// Audit/alert history retention in seconds (legacy CLI name; not device retention).
|
||||
#[arg(long)]
|
||||
pub observation_retention_seconds: Option<u64>,
|
||||
|
||||
@@ -129,6 +130,7 @@ pub struct InitConfigArgs {
|
||||
#[arg(long)]
|
||||
pub enroll_token_ttl_seconds: Option<u64>,
|
||||
|
||||
/// Audit/alert history retention in seconds (legacy CLI name; not device retention).
|
||||
#[arg(long)]
|
||||
pub observation_retention_seconds: Option<u64>,
|
||||
|
||||
|
||||
@@ -87,28 +87,31 @@ pub fn write_init_config(args: &InitConfigArgs) -> Result<Option<PathBuf>> {
|
||||
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())
|
||||
|
||||
@@ -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<Issue
|
||||
&args.target,
|
||||
)?;
|
||||
|
||||
let ttl = Duration::from_secs(
|
||||
args.ttl_seconds
|
||||
.or(file.enroll_token_ttl_seconds)
|
||||
.unwrap_or(defaults.enroll_token_ttl.as_secs())
|
||||
.max(1),
|
||||
);
|
||||
let ttl = args
|
||||
.ttl_seconds
|
||||
.map(Duration::from_secs)
|
||||
.or(file.enroll_token_ttl)
|
||||
.unwrap_or(defaults.enroll_token_ttl);
|
||||
validate_durations(defaults.command_timeout, ttl, defaults.history_retention)?;
|
||||
|
||||
Ok(IssueTokenSettings {
|
||||
data_dir: state.data_dir,
|
||||
@@ -206,8 +202,52 @@ pub(crate) fn load_file_config(path: &Path) -> Result<FileConfig> {
|
||||
|
||||
let raw = std::fs::read_to_string(path)
|
||||
.with_context(|| format!("failed to read config file {}", path.display()))?;
|
||||
toml::from_str::<FileConfig>(&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()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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<String>,
|
||||
@@ -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<PathBuf>,
|
||||
pub(crate) bind: Option<String>,
|
||||
pub(crate) public_url: Option<String>,
|
||||
pub(crate) state_file: Option<PathBuf>,
|
||||
pub(crate) command_timeout_ms: Option<u64>,
|
||||
pub(crate) enroll_token_ttl_seconds: Option<u64>,
|
||||
pub(crate) observation_retention_seconds: Option<u64>,
|
||||
#[serde(default, with = "humantime_serde::option")]
|
||||
pub(crate) command_timeout: Option<Duration>,
|
||||
#[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) ui_dist_dir: Option<PathBuf>,
|
||||
#[serde(alias = "enroll_tokens")]
|
||||
@@ -39,6 +44,35 @@ pub(crate) struct FileConfig {
|
||||
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)]
|
||||
pub(crate) struct FileTelemetryConfig {
|
||||
// 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(),
|
||||
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::<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());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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!(
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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,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;
|
||||
|
||||
|
||||
@@ -165,17 +165,27 @@ pub async fn prune_stale_observations_from_path(
|
||||
path: impl AsRef<Path>,
|
||||
retention_days: u64,
|
||||
) -> io::Result<usize> {
|
||||
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<Path>,
|
||||
cutoff_unix: u64,
|
||||
retention: std::time::Duration,
|
||||
) -> 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 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");
|
||||
|
||||
|
||||
Reference in new issue
Block a user