1use crate::defaults::{
11 default_artifact_permissions, default_event_buffer_size, default_honeytoken_count,
12 default_log_format, default_log_level, default_max_log_files, default_rotate_size,
13 default_version,
14};
15use crate::secure_fs::{RestrictedInputKind, read_restricted_file};
16use crate::tags::RootTag;
17use crate::timing::{TimingOperation, enforce_operation_min_timing};
18use crate::validation::ValidationMode;
19use crate::{AgentError, CONFIG_VERSION, Result};
20use serde::{Deserialize, Deserializer, Serialize, Serializer};
21use std::path::{Path, PathBuf};
22use std::time::Instant;
23use zeroize::{Zeroize, ZeroizeOnDrop};
24
25const CFG_PARSE_FAILED: u16 = 100;
26const CFG_VALIDATION_FAILED: u16 = 101;
27const CFG_MISSING_REQUIRED: u16 = 102;
28const CFG_INVALID_VALUE: u16 = 103;
29const CFG_VERSION_MISMATCH: u16 = 106;
30const IO_WRITE_FAILED: u16 = 801;
31
32#[derive(Debug, Serialize, Deserialize, Zeroize, ZeroizeOnDrop)]
34pub struct Config {
35 #[serde(default = "default_version")]
37 pub version: u32,
38
39 pub agent: AgentConfig,
41
42 pub deception: DeceptionConfig,
44
45 pub telemetry: TelemetryConfig,
47
48 pub logging: LoggingConfig,
50}
51
52#[derive(Debug, Serialize, Deserialize, Zeroize, ZeroizeOnDrop)]
54pub struct AgentConfig {
55 #[serde(
57 serialize_with = "serialize_protected_string",
58 deserialize_with = "deserialize_protected_string"
59 )]
60 pub instance_id: ProtectedString,
61
62 #[serde(
64 serialize_with = "serialize_protected_path",
65 deserialize_with = "deserialize_protected_path"
66 )]
67 pub work_dir: ProtectedPath,
68
69 #[serde(default)]
71 pub environment: Option<String>,
72
73 #[serde(default)]
75 pub hostname: Option<String>,
76}
77
78#[derive(Debug, Serialize, Deserialize, Zeroize, ZeroizeOnDrop)]
80pub struct DeceptionConfig {
81 #[serde(default)]
83 #[zeroize(skip)]
84 pub decoy_paths: Box<[PathBuf]>,
85
86 #[serde(deserialize_with = "deserialize_boxed_strings")]
88 pub credential_types: Box<[String]>,
89
90 #[serde(default = "default_honeytoken_count")]
92 pub honeytoken_count: usize,
93
94 pub root_tag: RootTag,
96
97 #[serde(default = "default_artifact_permissions")]
99 pub artifact_permissions: u32,
100}
101
102fn deserialize_boxed_strings<'de, D>(
104 deserializer: D,
105) -> std::result::Result<Box<[String]>, D::Error>
106where
107 D: serde::Deserializer<'de>,
108{
109 let vec = Vec::<String>::deserialize(deserializer)?;
110 Ok(vec.into_boxed_slice())
111}
112
113#[derive(Debug, Serialize, Deserialize, Zeroize, ZeroizeOnDrop)]
115pub struct TelemetryConfig {
116 #[zeroize(skip)]
118 pub watch_paths: Box<[PathBuf]>,
119
120 #[serde(default = "default_event_buffer_size")]
122 pub event_buffer_size: usize,
123
124 #[serde(default)]
126 pub enable_syscall_monitor: bool,
127}
128
129#[derive(Debug, Serialize, Deserialize, Zeroize, ZeroizeOnDrop)]
131pub struct LoggingConfig {
132 #[zeroize(skip)]
134 pub log_path: PathBuf,
135
136 #[serde(default = "default_log_format")]
138 pub format: LogFormat,
139
140 #[serde(default = "default_rotate_size")]
142 pub rotate_size_bytes: u64,
143
144 #[serde(default = "default_max_log_files")]
146 pub max_log_files: usize,
147
148 #[serde(default = "default_log_level")]
150 pub level: LogLevel,
151}
152
153#[derive(Debug, PartialEq, Eq, Serialize, Deserialize, Zeroize, ZeroizeOnDrop)]
155#[serde(rename_all = "lowercase")]
156pub enum LogFormat {
157 Json,
159 Text,
161}
162
163#[derive(Debug, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, Zeroize, ZeroizeOnDrop)]
165#[serde(rename_all = "UPPERCASE")]
166pub enum LogLevel {
167 Debug,
169 Info,
171 Warn,
173 Error,
175}
176
177#[derive(Zeroize, ZeroizeOnDrop, Default)]
179pub struct ProtectedString {
180 #[zeroize(skip)]
181 inner: String,
182}
183
184impl ProtectedString {
185 #[inline]
187 #[must_use]
188 pub fn new(s: String) -> Self {
189 Self { inner: s }
190 }
191
192 #[inline]
194 #[must_use]
195 pub fn as_str(&self) -> &str {
196 &self.inner
197 }
198
199 #[inline]
201 #[must_use]
202 pub fn into_inner(mut self) -> String {
203 std::mem::take(&mut self.inner)
204 }
205}
206
207fn serialize_protected_string<S>(
208 value: &ProtectedString,
209 serializer: S,
210) -> std::result::Result<S::Ok, S::Error>
211where
212 S: Serializer,
213{
214 serializer.serialize_str(value.as_str())
215}
216
217fn deserialize_protected_string<'de, D>(deserializer: D) -> std::result::Result<ProtectedString, D::Error>
218where
219 D: Deserializer<'de>,
220{
221 let value = String::deserialize(deserializer)?;
222 Ok(ProtectedString::new(value))
223}
224
225impl std::fmt::Debug for ProtectedString {
226 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
227 write!(f, "ProtectedString([REDACTED])")
228 }
229}
230
231#[derive(Zeroize, ZeroizeOnDrop, Default)]
233pub struct ProtectedPath {
234 #[zeroize(skip)]
235 inner: PathBuf,
236}
237
238impl ProtectedPath {
239 #[inline]
241 #[must_use]
242 pub fn new(path: PathBuf) -> Self {
243 Self { inner: path }
244 }
245
246 #[inline]
248 #[must_use]
249 pub fn as_path(&self) -> &Path {
250 &self.inner
251 }
252
253 #[inline]
255 #[must_use]
256 pub fn into_inner(mut self) -> PathBuf {
257 std::mem::replace(&mut self.inner, PathBuf::new())
258 }
259}
260
261fn serialize_protected_path<S>(
262 value: &ProtectedPath,
263 serializer: S,
264) -> std::result::Result<S::Ok, S::Error>
265where
266 S: Serializer,
267{
268 serializer.serialize_str(&value.as_path().to_string_lossy())
269}
270
271fn deserialize_protected_path<'de, D>(deserializer: D) -> std::result::Result<ProtectedPath, D::Error>
272where
273 D: Deserializer<'de>,
274{
275 let value = String::deserialize(deserializer)?;
276 Ok(ProtectedPath::new(PathBuf::from(value)))
277}
278
279impl std::fmt::Debug for ProtectedPath {
280 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
281 write!(f, "ProtectedPath([REDACTED])")
282 }
283}
284
285impl Config {
286 pub async fn from_file<P: AsRef<Path>>(path: P) -> Result<Self> {
292 Self::from_file_with_mode(path, &ValidationMode::Standard).await
293 }
294
295 pub async fn from_file_with_mode<P: AsRef<Path>>(
301 path: P,
302 mode: &ValidationMode,
303 ) -> Result<Self> {
304 let started = Instant::now();
305 let path = path.as_ref();
306 let result = async {
307 let contents = read_restricted_file(path, RestrictedInputKind::Config).await?;
308 Self::from_toml_str_with_mode(&contents, mode)
309 }
310 .await;
311 enforce_operation_min_timing(started, TimingOperation::ConfigLoad);
312 result
313 }
314
315 pub(crate) fn from_toml_str_with_mode(contents: &str, mode: &ValidationMode) -> Result<Self> {
316 let config: Config = toml::from_str(contents).map_err(|e| {
317 let location = e.span().map_or_else(
318 || "unknown location".to_string(),
319 |s| format!("line {}", contents[..s.start].matches('\n').count() + 1),
320 );
321
322 AgentError::new(
323 CFG_PARSE_FAILED,
324 "Configuration input could not be parsed",
325 format!("operation=parse_config_toml; Invalid TOML syntax at {location}: {e}"),
326 "",
327 )
328 })?;
329
330 if config.version != CONFIG_VERSION {
331 let message = if config.version > CONFIG_VERSION {
332 "Configuration version too new - upgrade agent"
333 } else {
334 "Configuration version outdated - update config"
335 };
336
337 return Err(AgentError::new(
338 CFG_VERSION_MISMATCH,
339 "Configuration version is not supported",
340 format!(
341 "operation=validate_config_version; {message}; file_version={}; expected_version={CONFIG_VERSION}",
342 config.version
343 ),
344 "",
345 ));
346 }
347
348 config.validate_with_mode(mode)?;
349 Ok(config)
350 }
351
352 pub(crate) fn validate_with_mode(&self, mode: &ValidationMode) -> Result<()> {
354 let started = Instant::now();
355 let result = (|| {
356 self.validate_agent()?;
357 self.validate_deception(mode)?;
358 self.validate_telemetry(mode)?;
359 self.validate_logging(mode)?;
360 Ok(())
361 })();
362 enforce_operation_min_timing(
363 started,
364 match mode {
365 &ValidationMode::Standard => TimingOperation::ConfigValidateStandard,
366 &ValidationMode::Strict => TimingOperation::ConfigValidateStrict,
367 },
368 );
369 result
370 }
371
372 pub fn validate(&self) -> Result<()> {
374 self.validate_with_mode(&ValidationMode::Standard)
375 }
376
377 fn validate_agent(&self) -> Result<()> {
378 if self.agent.instance_id.as_str().is_empty() {
379 return Err(AgentError::new(
380 CFG_MISSING_REQUIRED,
381 "Required configuration is missing",
382 "operation=validate_agent; agent.instance_id cannot be empty; impact=no_telemetry_correlation",
383 "agent.instance_id",
384 ));
385 }
386
387 if !self.agent.work_dir.as_path().is_absolute() {
388 return Err(AgentError::new(
389 CFG_INVALID_VALUE,
390 "Configuration contains an invalid value",
391 "operation=validate_agent; field=agent.work_dir; agent.work_dir must be an absolute path",
392 "agent.work_dir",
393 ));
394 }
395
396 Ok(())
397 }
398
399 fn validate_deception(&self, mode: &ValidationMode) -> Result<()> {
400 if self.deception.decoy_paths.is_empty() {
401 return Err(AgentError::new(
402 CFG_MISSING_REQUIRED,
403 "Required configuration is missing",
404 "operation=validate_deception; deception.decoy_paths cannot be empty; impact=no_deception",
405 "deception.decoy_paths",
406 ));
407 }
408
409 for (idx, path) in self.deception.decoy_paths.iter().enumerate() {
410 if !path.is_absolute() {
411 return Err(AgentError::new(
412 CFG_INVALID_VALUE,
413 "Configuration contains an invalid value",
414 "operation=validate_deception; field=deception.decoy_paths; deception.decoy_paths must be an absolute path",
415 "deception.decoy_paths",
416 ));
417 }
418
419 if matches!(mode, ValidationMode::Strict)
420 && let Some(parent) = path.parent()
421 && !parent.exists()
422 {
423 return Err(AgentError::new(
424 CFG_VALIDATION_FAILED,
425 "Configuration validation failed",
426 format!(
427 "operation=validate_deception; deception.decoy_paths parent directory does not exist; path_index={idx}"
428 ),
429 "deception.decoy_paths",
430 ));
431 }
432 }
433
434 if self.deception.credential_types.is_empty() {
435 return Err(AgentError::new(
436 CFG_MISSING_REQUIRED,
437 "Required configuration is missing",
438 "operation=validate_deception; deception.credential_types cannot be empty; impact=no_credential_types",
439 "deception.credential_types",
440 ));
441 }
442
443 if self.deception.honeytoken_count == 0 || self.deception.honeytoken_count > 100 {
444 return Err(AgentError::new(
445 CFG_INVALID_VALUE,
446 "Configuration contains an invalid value",
447 format!(
448 "operation=validate_deception; field=deception.honeytoken_count; reason=deception.honeytoken_count must be within valid range; actual_value={}; expected_range=1-100",
449 self.deception.honeytoken_count
450 ),
451 "deception.honeytoken_count",
452 ));
453 }
454
455 if self.deception.artifact_permissions > 0o777 {
456 return Err(AgentError::new(
457 CFG_INVALID_VALUE,
458 "Configuration contains an invalid value",
459 format!(
460 "operation=validate_deception; field=deception.artifact_permissions; reason=deception.artifact_permissions exceeds maximum threshold; actual_value={:o}; expected_range=maximum: 0o777",
461 self.deception.artifact_permissions
462 ),
463 "deception.artifact_permissions",
464 ));
465 }
466
467 Ok(())
468 }
469
470 fn validate_telemetry(&self, mode: &ValidationMode) -> Result<()> {
471 if self.telemetry.watch_paths.is_empty() {
472 return Err(AgentError::new(
473 CFG_MISSING_REQUIRED,
474 "Required configuration is missing",
475 "operation=validate_telemetry; telemetry.watch_paths cannot be empty; impact=no_monitoring",
476 "telemetry.watch_paths",
477 ));
478 }
479
480 for (idx, path) in self.telemetry.watch_paths.iter().enumerate() {
481 if !path.is_absolute() {
482 return Err(AgentError::new(
483 CFG_INVALID_VALUE,
484 "Configuration contains an invalid value",
485 "operation=validate_telemetry; field=telemetry.watch_paths; telemetry.watch_paths must be an absolute path",
486 "telemetry.watch_paths",
487 ));
488 }
489
490 if matches!(mode, ValidationMode::Strict) && !path.exists() {
491 return Err(AgentError::new(
492 CFG_VALIDATION_FAILED,
493 "Configuration validation failed",
494 format!(
495 "operation=validate_telemetry; telemetry.watch_paths does not exist; path_index={idx}"
496 ),
497 "telemetry.watch_paths",
498 ));
499 }
500 }
501
502 if self.telemetry.event_buffer_size < 100 {
503 return Err(AgentError::new(
504 CFG_INVALID_VALUE,
505 "Configuration contains an invalid value",
506 format!(
507 "operation=validate_telemetry; field=telemetry.event_buffer_size; reason=telemetry.event_buffer_size below minimum threshold; actual_value={}; expected_range=minimum: 100",
508 self.telemetry.event_buffer_size
509 ),
510 "telemetry.event_buffer_size",
511 ));
512 }
513
514 Ok(())
515 }
516
517 fn validate_logging(&self, mode: &ValidationMode) -> Result<()> {
518 if !self.logging.log_path.is_absolute() {
519 return Err(AgentError::new(
520 CFG_INVALID_VALUE,
521 "Configuration contains an invalid value",
522 "operation=validate_logging; field=logging.log_path; logging.log_path must be an absolute path",
523 "logging.log_path",
524 ));
525 }
526
527 if matches!(mode, ValidationMode::Strict)
528 && let Some(parent) = self.logging.log_path.parent()
529 {
530 if !parent.exists() {
531 return Err(AgentError::new(
532 CFG_VALIDATION_FAILED,
533 "Configuration validation failed",
534 "operation=validate_logging; logging.log_path parent directory does not exist",
535 "logging.log_path",
536 ));
537 }
538
539 let test_file = parent.join(".palisade-write-test");
540 std::fs::write(&test_file, b"test").map_err(|e| {
541 AgentError::new(
542 IO_WRITE_FAILED,
543 "Configuration output could not be written",
544 format!(
545 "operation=test_log_directory_write; io_kind={}; write failed",
546 e.kind()
547 ),
548 test_file.display().to_string(),
549 )
550 })?;
551 let _ = std::fs::remove_file(&test_file);
552 }
553
554 if self.logging.rotate_size_bytes < 1024 * 1024 {
555 return Err(AgentError::new(
556 CFG_INVALID_VALUE,
557 "Configuration contains an invalid value",
558 format!(
559 "operation=validate_logging; field=logging.rotate_size_bytes; reason=logging.rotate_size_bytes below minimum threshold; actual_value={}; expected_range=minimum: {}",
560 self.logging.rotate_size_bytes,
561 1024 * 1024
562 ),
563 "logging.rotate_size_bytes",
564 ));
565 }
566
567 if self.logging.max_log_files == 0 {
568 return Err(AgentError::new(
569 CFG_INVALID_VALUE,
570 "Configuration contains an invalid value",
571 "operation=validate_logging; field=logging.max_log_files; logging.max_log_files cannot be zero",
572 "logging.max_log_files",
573 ));
574 }
575
576 Ok(())
577 }
578
579 #[must_use]
581 pub fn hostname(&self) -> std::borrow::Cow<'_, str> {
582 let started = Instant::now();
583 let hostname = if let Some(h) = &self.agent.hostname {
584 std::borrow::Cow::Borrowed(h.as_str())
585 } else {
586 let system_hostname = hostname::get()
588 .ok()
589 .and_then(|h| h.into_string().ok())
590 .unwrap_or_else(|| "unknown-host".to_string());
591 std::borrow::Cow::Owned(system_hostname)
592 };
593 enforce_operation_min_timing(started, TimingOperation::ConfigHostname);
594 hostname
595 }
596}
597
598impl Default for Config {
599 fn default() -> Self {
600 let mut default_instance_id = hostname::get()
601 .ok()
602 .and_then(|h| h.into_string().ok())
603 .unwrap_or_else(|| uuid::Uuid::new_v4().to_string());
604
605 Self {
606 version: CONFIG_VERSION,
607 agent: AgentConfig {
608 instance_id: ProtectedString::new(std::mem::take(&mut default_instance_id)),
609 work_dir: ProtectedPath::new(PathBuf::from("/var/lib/palisade-agent")),
610 environment: None,
611 hostname: None,
612 },
613 deception: DeceptionConfig {
614 decoy_paths: vec![
615 PathBuf::from("/tmp/.credentials"),
616 PathBuf::from("/opt/.backup"),
617 ]
618 .into_boxed_slice(),
619 credential_types: vec!["aws".to_string(), "ssh".to_string()].into_boxed_slice(),
620 honeytoken_count: 5,
621 root_tag: RootTag::generate()
622 .expect("Failed to generate root tag - system entropy failure"),
623 artifact_permissions: 0o600,
624 },
625 telemetry: TelemetryConfig {
626 watch_paths: vec![PathBuf::from("/tmp")].into_boxed_slice(),
627 event_buffer_size: 10_000,
628 enable_syscall_monitor: false,
629 },
630 logging: LoggingConfig {
631 log_path: PathBuf::from("/var/log/palisade-agent.log"),
632 format: LogFormat::Json,
633 rotate_size_bytes: 100 * 1024 * 1024,
634 max_log_files: 10,
635 level: LogLevel::Info,
636 },
637 }
638 }
639}
640
641#[cfg(test)]
642mod tests {
643 use super::*;
644
645 #[test]
646 fn default_config_validates() {
647 let config = Config::default();
648 assert!(config.validate().is_ok());
649 }
650
651 #[test]
652 fn hostname_fallback() {
653 let config = Config::default();
654 let hostname = config.hostname();
655 assert!(!hostname.is_empty());
656 }
657
658 #[test]
659 fn protected_string_redacts_in_debug() {
660 let protected = ProtectedString::new("secret123".to_string());
661 let debug = format!("{:?}", protected);
662 assert!(!debug.contains("secret123"));
663 assert!(debug.contains("REDACTED"));
664 }
665
666 #[test]
667 fn protected_path_redacts_in_debug() {
668 let protected = ProtectedPath::new(PathBuf::from("/etc/shadow"));
669 let debug = format!("{:?}", protected);
670 assert!(!debug.contains("shadow"));
671 assert!(debug.contains("REDACTED"));
672 }
673
674 #[test]
675 fn validation_catches_empty_instance_id() {
676 let mut config = Config::default();
677 config.agent.instance_id = ProtectedString::new(String::new());
678
679 let result = config.validate();
680 assert!(result.is_err());
681
682 if let Err(err) = result {
683 assert_eq!(err.to_string(), "Required configuration is missing");
684 }
685 }
686
687 #[test]
688 fn validation_catches_relative_work_dir() {
689 let mut config = Config::default();
690 config.agent.work_dir = ProtectedPath::new(PathBuf::from("relative/path"));
691
692 let result = config.validate();
693 assert!(result.is_err());
694 }
695
696 #[test]
697 fn validation_catches_empty_decoy_paths() {
698 let mut config = Config::default();
699 config.deception.decoy_paths = Box::new([]);
700 assert!(config.validate().is_err());
701 }
702
703 #[test]
704 fn validation_catches_invalid_honeytoken_count() {
705 let mut config = Config::default();
706 config.deception.honeytoken_count = 0;
707 assert!(config.validate().is_err());
708
709 let mut config = Config::default();
710 config.deception.honeytoken_count = 101;
711 assert!(config.validate().is_err());
712 }
713}