Skip to main content

ant_core/node/daemon/forward/
config.rs

1//! Persisted opt-in state for beta log forwarding.
2//!
3//! Running `ant node logs forward enable` is the consent act, and this file is where that consent
4//! lives. It holds the write-only Elasticsearch API key, so it is written with owner-only
5//! permissions and its token is never returned by the status API — callers get a fingerprint
6//! instead (see [`LogForwardStatus`](super::LogForwardStatus)).
7
8use std::path::{Path, PathBuf};
9
10use serde::{Deserialize, Serialize};
11
12use crate::config;
13use crate::error::{Error, Result};
14
15/// Default beta-channel ingest endpoint (V2-1016).
16///
17/// A Caddy proxy fronts an Elasticsearch instance on loopback and allowlists the bulk/document
18/// paths; it is the Elasticsearch API rather than a translation layer, so the sink speaks plain
19/// `_bulk`. Overridable with `--endpoint` for testing against a local mock.
20pub const DEFAULT_ENDPOINT: &str = "https://logs.autonomi.com";
21
22/// Prefix of the daily index events are written to: `beta-nodes-YYYY.MM.DD`.
23///
24/// The write-only API key is scoped to `beta-nodes-*`; anything outside that is rejected with a
25/// per-item 403.
26pub const DEFAULT_INDEX_PREFIX: &str = "beta-nodes";
27
28/// Filename of the persisted forwarding config within [`config::config_dir`].
29const CONFIG_FILENAME: &str = "log_forward.json";
30
31/// Severity of a log event, ordered so that filtering is a comparison.
32///
33/// The ingest endpoint enforces its own minimum level (currently INFO) and silently drops anything
34/// below it while reporting success, so filtering here is not about correctness — it is about not
35/// spending the user's bandwidth and disk on events that are discarded on arrival.
36#[derive(
37    Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, utoipa::ToSchema,
38)]
39#[serde(rename_all = "lowercase")]
40pub enum LogLevel {
41    Trace,
42    Debug,
43    Info,
44    Warn,
45    Error,
46}
47
48impl LogLevel {
49    /// Parse a level as it appears in an ant-node log line, in either log format.
50    ///
51    /// Accepts any case: the text layer emits `INFO`, the JSON layer emits `INFO` in its `level`
52    /// field, and hand-written configs use `info`.
53    #[must_use]
54    pub fn parse(s: &str) -> Option<Self> {
55        match s.trim().to_ascii_uppercase().as_str() {
56            "TRACE" => Some(Self::Trace),
57            "DEBUG" => Some(Self::Debug),
58            "INFO" => Some(Self::Info),
59            "WARN" | "WARNING" => Some(Self::Warn),
60            "ERROR" => Some(Self::Error),
61            _ => None,
62        }
63    }
64
65    /// The level as it should appear in a forwarded document's `level` field.
66    #[must_use]
67    pub fn as_str(self) -> &'static str {
68        match self {
69            Self::Trace => "TRACE",
70            Self::Debug => "DEBUG",
71            Self::Info => "INFO",
72            Self::Warn => "WARN",
73            Self::Error => "ERROR",
74        }
75    }
76}
77
78impl std::fmt::Display for LogLevel {
79    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
80        write!(f, "{}", self.as_str())
81    }
82}
83
84impl std::str::FromStr for LogLevel {
85    type Err = Error;
86
87    fn from_str(s: &str) -> Result<Self> {
88        Self::parse(s).ok_or_else(|| {
89            Error::LogForward(format!(
90                "unknown log level '{s}' (expected one of: trace, debug, info, warn, error)"
91            ))
92        })
93    }
94}
95
96/// The default minimum level: INFO and above, matching both ant-node's own default and the
97/// server-side ingest filter.
98const fn default_min_level() -> LogLevel {
99    LogLevel::Info
100}
101
102fn default_endpoint() -> String {
103    DEFAULT_ENDPOINT.to_string()
104}
105
106fn default_index_prefix() -> String {
107    DEFAULT_INDEX_PREFIX.to_string()
108}
109
110/// Persisted forwarding configuration.
111///
112/// Absent file means "never enabled", which loads as [`LogForwardConfig::disabled`].
113#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
114pub struct LogForwardConfig {
115    /// Whether the user has opted in. `disable` clears this but keeps the rest, so a later
116    /// `enable` with no arguments resumes with the same token and endpoint.
117    pub enabled: bool,
118
119    /// Write-only Elasticsearch API key, sent as `Authorization: ApiKey <token>`.
120    ///
121    /// Never serialized into an API response — see [`Self::token_fingerprint`].
122    #[serde(default)]
123    pub token: String,
124
125    /// Ingest endpoint. Defaults to [`DEFAULT_ENDPOINT`].
126    #[serde(default = "default_endpoint")]
127    pub endpoint: String,
128
129    /// Daily index prefix. Defaults to [`DEFAULT_INDEX_PREFIX`].
130    #[serde(default = "default_index_prefix")]
131    pub index_prefix: String,
132
133    /// Drop events below this level before batching. Defaults to [`LogLevel::Info`].
134    #[serde(default = "default_min_level")]
135    pub min_level: LogLevel,
136
137    /// Stable, randomly generated namespace for this installation's document ids.
138    ///
139    /// Every participant writes into the same shared `beta-nodes-YYYY.MM.DD` indices, so a document
140    /// id built only from node id, filename and byte offset is not unique across machines: node 1's
141    /// first log line sits at offset 0 of the same daily filename on *every* installation. Since a
142    /// duplicate id is answered with a 409 that the sink counts as delivered, the second machine's
143    /// event would be silently discarded rather than stored.
144    ///
145    /// Prefixing the id with this value removes that collision. It is random rather than derived
146    /// from anything about the machine — not the hostname, MAC or username — so it identifies an
147    /// installation only in the sense of separating it from other installations.
148    ///
149    /// It must stay stable for the lifetime of the install: the deterministic id is what makes a
150    /// replayed batch idempotent, and regenerating this would make a replay look like a new
151    /// document and duplicate it.
152    #[serde(default)]
153    pub installation_id: String,
154}
155
156impl Default for LogForwardConfig {
157    fn default() -> Self {
158        Self::disabled()
159    }
160}
161
162impl LogForwardConfig {
163    /// The state of a machine that has never opted in.
164    #[must_use]
165    pub fn disabled() -> Self {
166        Self {
167            enabled: false,
168            token: String::new(),
169            endpoint: default_endpoint(),
170            index_prefix: default_index_prefix(),
171            min_level: default_min_level(),
172            installation_id: String::new(),
173        }
174    }
175
176    /// Generate the installation namespace if this config does not have one yet.
177    ///
178    /// Called when forwarding is enabled. Configs written before this field existed load with an
179    /// empty value and are filled in on their next enable.
180    pub fn ensure_installation_id(&mut self) {
181        if self.installation_id.is_empty() {
182            self.installation_id = generate_installation_id();
183        }
184    }
185
186    /// Path of the persisted config for this machine.
187    pub fn default_path() -> Result<PathBuf> {
188        Ok(config::config_dir()?.join(CONFIG_FILENAME))
189    }
190
191    /// Load the config, returning [`Self::disabled`] when the file does not exist.
192    ///
193    /// A corrupt file is an error rather than a silent reset: forwarding is opt-in, and silently
194    /// falling back to "disabled" would look identical to a user who had opted in and would leave
195    /// them believing logs were flowing when they were not.
196    pub fn load(path: &Path) -> Result<Self> {
197        if !path.exists() {
198            return Ok(Self::disabled());
199        }
200        let contents = std::fs::read_to_string(path)?;
201        let config: Self = serde_json::from_str(&contents)?;
202        Ok(config)
203    }
204
205    /// Write the config atomically with owner-only permissions.
206    ///
207    /// Permissions are set on the temporary file *before* the rename, so the token is never
208    /// readable by other users on the machine, even briefly.
209    pub fn save(&self, path: &Path) -> Result<()> {
210        if let Some(parent) = path.parent() {
211            std::fs::create_dir_all(parent)?;
212        }
213        let contents = serde_json::to_string_pretty(self)?;
214        let tmp_path = path.with_extension("tmp");
215        std::fs::write(&tmp_path, &contents)?;
216        restrict_to_owner(&tmp_path)?;
217        std::fs::rename(&tmp_path, path)?;
218        Ok(())
219    }
220
221    /// Reject a configuration that cannot possibly ship anything.
222    pub fn validate(&self) -> Result<()> {
223        if self.installation_id.is_empty() {
224            return Err(Error::LogForward(
225                "internal: installation id was not generated before enabling".into(),
226            ));
227        }
228        if self.token.trim().is_empty() {
229            return Err(Error::LogForward(
230                "a write token is required: ant node logs forward enable --token <token>".into(),
231            ));
232        }
233        if !self.endpoint.starts_with("http://") && !self.endpoint.starts_with("https://") {
234            return Err(Error::LogForward(format!(
235                "endpoint must be an http(s) URL, got '{}'",
236                self.endpoint
237            )));
238        }
239        if self.index_prefix.trim().is_empty() {
240            return Err(Error::LogForward("index prefix must not be empty".into()));
241        }
242        Ok(())
243    }
244
245    /// A non-reversible short identifier for the configured token, safe to show in status output.
246    ///
247    /// Returns `None` when no token is set. This exists so a user can confirm *which* key is in
248    /// use — after re-enrolling, say — without the daemon ever handing the key back out over its
249    /// HTTP API.
250    #[must_use]
251    pub fn token_fingerprint(&self) -> Option<String> {
252        if self.token.trim().is_empty() {
253            return None;
254        }
255        let digest = blake3::hash(self.token.as_bytes());
256        Some(digest.to_hex()[..12].to_string())
257    }
258
259    /// The endpoint with any trailing slash removed, so path joining is unambiguous.
260    #[must_use]
261    pub fn endpoint_base(&self) -> &str {
262        self.endpoint.trim_end_matches('/')
263    }
264}
265
266/// 64 bits of randomness, rendered as hex.
267///
268/// Ample for separating a beta cohort — collisions become likely somewhere around a billion
269/// installations — while keeping the document id short and readable.
270fn generate_installation_id() -> String {
271    use rand::Rng;
272    let bytes: [u8; 8] = rand::thread_rng().gen();
273    bytes.iter().fold(String::with_capacity(16), |mut acc, b| {
274        use std::fmt::Write;
275        let _ = write!(acc, "{b:02x}");
276        acc
277    })
278}
279
280#[cfg(unix)]
281fn restrict_to_owner(path: &Path) -> Result<()> {
282    use std::os::unix::fs::PermissionsExt;
283    std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600))?;
284    Ok(())
285}
286
287/// On Windows the config lands in the per-user `%APPDATA%` tree, which is already
288/// user-scoped by the default ACL; there is no portable mode bit to set.
289#[cfg(not(unix))]
290fn restrict_to_owner(_path: &Path) -> Result<()> {
291    Ok(())
292}
293
294#[cfg(test)]
295mod tests {
296    use super::*;
297
298    fn enabled_config() -> LogForwardConfig {
299        LogForwardConfig {
300            enabled: true,
301            token: "test-api-key".to_string(),
302            installation_id: "0123456789abcdef".to_string(),
303            ..LogForwardConfig::disabled()
304        }
305    }
306
307    #[test]
308    fn levels_order_by_severity() {
309        assert!(LogLevel::Trace < LogLevel::Debug);
310        assert!(LogLevel::Debug < LogLevel::Info);
311        assert!(LogLevel::Info < LogLevel::Warn);
312        assert!(LogLevel::Warn < LogLevel::Error);
313    }
314
315    #[test]
316    fn level_parses_both_log_formats_and_config_casing() {
317        assert_eq!(LogLevel::parse("INFO"), Some(LogLevel::Info));
318        assert_eq!(LogLevel::parse("info"), Some(LogLevel::Info));
319        assert_eq!(LogLevel::parse(" WARN "), Some(LogLevel::Warn));
320        assert_eq!(LogLevel::parse("WARNING"), Some(LogLevel::Warn));
321        assert_eq!(LogLevel::parse("nonsense"), None);
322    }
323
324    #[test]
325    fn level_from_str_reports_the_accepted_values() {
326        let err = "verbose".parse::<LogLevel>().unwrap_err().to_string();
327        assert!(err.contains("trace, debug, info, warn, error"), "{err}");
328    }
329
330    #[test]
331    fn missing_file_loads_as_disabled() {
332        let tmp = tempfile::tempdir().unwrap();
333        let config = LogForwardConfig::load(&tmp.path().join("absent.json")).unwrap();
334        assert_eq!(config, LogForwardConfig::disabled());
335        assert!(!config.enabled);
336    }
337
338    #[test]
339    fn corrupt_file_is_an_error_rather_than_a_silent_reset() {
340        let tmp = tempfile::tempdir().unwrap();
341        let path = tmp.path().join("log_forward.json");
342        std::fs::write(&path, "{ not json").unwrap();
343        assert!(LogForwardConfig::load(&path).is_err());
344    }
345
346    #[test]
347    fn save_then_load_round_trips() {
348        let tmp = tempfile::tempdir().unwrap();
349        let path = tmp.path().join("nested").join("log_forward.json");
350        let config = enabled_config();
351        config.save(&path).unwrap();
352        assert_eq!(LogForwardConfig::load(&path).unwrap(), config);
353    }
354
355    /// An older config written before a field existed must still load, taking the defaults.
356    #[test]
357    fn load_tolerates_a_config_missing_the_optional_fields() {
358        let tmp = tempfile::tempdir().unwrap();
359        let path = tmp.path().join("log_forward.json");
360        std::fs::write(&path, r#"{"enabled":true,"token":"k"}"#).unwrap();
361        let config = LogForwardConfig::load(&path).unwrap();
362        assert!(config.enabled);
363        assert_eq!(config.endpoint, DEFAULT_ENDPOINT);
364        assert_eq!(config.index_prefix, DEFAULT_INDEX_PREFIX);
365        assert_eq!(config.min_level, LogLevel::Info);
366    }
367
368    #[cfg(unix)]
369    #[test]
370    fn saved_config_is_owner_only() {
371        use std::os::unix::fs::PermissionsExt;
372        let tmp = tempfile::tempdir().unwrap();
373        let path = tmp.path().join("log_forward.json");
374        enabled_config().save(&path).unwrap();
375        let mode = std::fs::metadata(&path).unwrap().permissions().mode();
376        assert_eq!(
377            mode & 0o777,
378            0o600,
379            "token file must not be group/world readable"
380        );
381    }
382
383    #[cfg(unix)]
384    #[test]
385    fn overwriting_an_existing_config_keeps_owner_only_permissions() {
386        use std::os::unix::fs::PermissionsExt;
387        let tmp = tempfile::tempdir().unwrap();
388        let path = tmp.path().join("log_forward.json");
389        std::fs::write(&path, "{}").unwrap();
390        std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o644)).unwrap();
391
392        enabled_config().save(&path).unwrap();
393
394        let mode = std::fs::metadata(&path).unwrap().permissions().mode();
395        assert_eq!(mode & 0o777, 0o600);
396    }
397
398    #[test]
399    fn validate_rejects_a_missing_token() {
400        let config = LogForwardConfig {
401            token: "   ".to_string(),
402            ..enabled_config()
403        };
404        assert!(config.validate().unwrap_err().to_string().contains("token"));
405    }
406
407    #[test]
408    fn an_installation_id_is_generated_once_and_then_left_alone() {
409        let mut config = LogForwardConfig::disabled();
410        assert!(config.installation_id.is_empty());
411
412        config.ensure_installation_id();
413        let first = config.installation_id.clone();
414        assert_eq!(first.len(), 16, "64 bits rendered as hex");
415        assert!(first.chars().all(|c| c.is_ascii_hexdigit()));
416
417        // Stability is what keeps a replayed batch idempotent.
418        config.ensure_installation_id();
419        assert_eq!(config.installation_id, first);
420    }
421
422    #[test]
423    fn separate_installations_get_different_ids() {
424        let mut a = LogForwardConfig::disabled();
425        let mut b = LogForwardConfig::disabled();
426        a.ensure_installation_id();
427        b.ensure_installation_id();
428        assert_ne!(a.installation_id, b.installation_id);
429    }
430
431    #[test]
432    fn the_installation_id_survives_a_save_and_reload() {
433        let tmp = tempfile::tempdir().unwrap();
434        let path = tmp.path().join("log_forward.json");
435        let config = enabled_config();
436        config.save(&path).unwrap();
437        assert_eq!(
438            LogForwardConfig::load(&path).unwrap().installation_id,
439            config.installation_id
440        );
441    }
442
443    #[test]
444    fn validate_rejects_a_non_http_endpoint() {
445        let config = LogForwardConfig {
446            endpoint: "logs.autonomi.com".to_string(),
447            ..enabled_config()
448        };
449        assert!(config
450            .validate()
451            .unwrap_err()
452            .to_string()
453            .contains("http(s) URL"));
454    }
455
456    #[test]
457    fn validate_accepts_the_defaults_with_a_token() {
458        enabled_config().validate().unwrap();
459    }
460
461    #[test]
462    fn fingerprint_is_stable_absent_for_no_token_and_leaks_nothing() {
463        let config = enabled_config();
464        let fingerprint = config.token_fingerprint().unwrap();
465        assert_eq!(fingerprint.len(), 12);
466        assert_eq!(config.token_fingerprint().unwrap(), fingerprint);
467        assert!(!fingerprint.contains("test-api-key"));
468
469        let other = LogForwardConfig {
470            token: "a-different-key".to_string(),
471            ..enabled_config()
472        };
473        assert_ne!(other.token_fingerprint().unwrap(), fingerprint);
474        assert_eq!(LogForwardConfig::disabled().token_fingerprint(), None);
475    }
476
477    #[test]
478    fn endpoint_base_strips_a_trailing_slash() {
479        let config = LogForwardConfig {
480            endpoint: "https://logs.autonomi.com/".to_string(),
481            ..enabled_config()
482        };
483        assert_eq!(config.endpoint_base(), "https://logs.autonomi.com");
484    }
485
486    /// The token must never reach an API response body. Status is built from a dedicated type, but
487    /// this pins the underlying expectation that nothing serializes the config itself outward.
488    #[test]
489    fn fingerprint_rather_than_token_is_what_status_can_show() {
490        let config = enabled_config();
491        let fingerprint = config.token_fingerprint().unwrap();
492        assert!(!fingerprint.contains(&config.token));
493    }
494}