ant_core/node/daemon/forward/
config.rs1use std::path::{Path, PathBuf};
9
10use serde::{Deserialize, Serialize};
11
12use crate::config;
13use crate::error::{Error, Result};
14
15pub const DEFAULT_ENDPOINT: &str = "https://logs.autonomi.com";
21
22pub const DEFAULT_INDEX_PREFIX: &str = "beta-nodes";
27
28const CONFIG_FILENAME: &str = "log_forward.json";
30
31#[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 #[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 #[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
96const 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#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
114pub struct LogForwardConfig {
115 pub enabled: bool,
118
119 #[serde(default)]
123 pub token: String,
124
125 #[serde(default = "default_endpoint")]
127 pub endpoint: String,
128
129 #[serde(default = "default_index_prefix")]
131 pub index_prefix: String,
132
133 #[serde(default = "default_min_level")]
135 pub min_level: LogLevel,
136
137 #[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 #[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 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 pub fn default_path() -> Result<PathBuf> {
188 Ok(config::config_dir()?.join(CONFIG_FILENAME))
189 }
190
191 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 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 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 #[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 #[must_use]
261 pub fn endpoint_base(&self) -> &str {
262 self.endpoint.trim_end_matches('/')
263 }
264}
265
266fn 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#[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 #[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 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 #[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}