ironpost_core/
config.rs

1//! 설정 관리 — ironpost.toml 파싱 및 런타임 설정
2//!
3//! [`IronpostConfig`]는 모든 모듈의 설정을 담는 최상위 구조체입니다.
4//!
5//! # 설정 로딩 우선순위
6//! 1. CLI 인자 (최고 우선)
7//! 2. 환경변수 (`IRONPOST_EBPF_INTERFACE=eth0` 형식)
8//! 3. 설정 파일 (`ironpost.toml`)
9//! 4. 기본값 (`Default` 구현)
10//!
11//! # 사용 예시
12//! ```no_run
13//! # async fn example() -> Result<(), ironpost_core::error::IronpostError> {
14//! use ironpost_core::config::IronpostConfig;
15//!
16//! // 파일에서 로드 + 환경변수 오버라이드
17//! let config = IronpostConfig::load("ironpost.toml").await?;
18//!
19//! // TOML 문자열에서 직접 파싱
20//! let config = IronpostConfig::parse("[general]\nlog_level = \"debug\"")?;
21//! # Ok(())
22//! # }
23//! ```
24
25use std::path::Path;
26
27use serde::{Deserialize, Serialize};
28use tracing::warn;
29
30use crate::error::{ConfigError, IronpostError};
31
32/// Ironpost 통합 설정
33///
34/// `ironpost.toml` 파일의 최상위 구조를 나타냅니다.
35/// 각 모듈은 자기 섹션만 읽어 사용합니다.
36#[derive(Debug, Clone, Default, Serialize, Deserialize)]
37pub struct IronpostConfig {
38    /// 일반 설정
39    #[serde(default)]
40    pub general: GeneralConfig,
41    /// 메트릭 수집 및 Prometheus 노출 설정
42    #[serde(default)]
43    pub metrics: MetricsConfig,
44    /// eBPF 엔진 설정
45    #[serde(default)]
46    pub ebpf: EbpfConfig,
47    /// 로그 파이프라인 설정
48    #[serde(default)]
49    pub log_pipeline: LogPipelineConfig,
50    /// 컨테이너 가드 설정
51    #[serde(default)]
52    pub container: ContainerConfig,
53    /// SBOM 스캐너 설정
54    #[serde(default)]
55    pub sbom: SbomConfig,
56}
57
58impl IronpostConfig {
59    /// TOML 파일에서 설정을 로드하고 환경변수 오버라이드를 적용합니다.
60    ///
61    /// 설정 로딩 순서:
62    /// 1. TOML 파일 파싱
63    /// 2. 환경변수 오버라이드 적용
64    ///
65    /// # Examples
66    ///
67    /// ```no_run
68    /// # async fn example() -> Result<(), ironpost_core::error::IronpostError> {
69    /// use ironpost_core::config::IronpostConfig;
70    ///
71    /// // 기본 설정 파일 로드
72    /// let config = IronpostConfig::load("ironpost.toml").await?;
73    ///
74    /// // 환경변수로 오버라이드 가능
75    /// // IRONPOST_EBPF_INTERFACE=eth0 ./ironpost
76    /// # Ok(())
77    /// # }
78    /// ```
79    ///
80    /// # Errors
81    ///
82    /// 다음의 경우 에러를 반환합니다:
83    /// - 파일이 존재하지 않을 때 ([`ConfigError::FileNotFound`])
84    /// - TOML 파싱 실패 시 ([`ConfigError::ParseFailed`])
85    /// - 설정 검증 실패 시 ([`ConfigError::InvalidValue`])
86    pub async fn load(path: impl AsRef<Path>) -> Result<Self, IronpostError> {
87        let mut config = Self::from_file(path).await?;
88        config.apply_env_overrides();
89        config.validate()?;
90        Ok(config)
91    }
92
93    /// TOML 파일에서 설정을 로드합니다 (환경변수 오버라이드 없음).
94    ///
95    /// # Errors
96    ///
97    /// 파일이 존재하지 않거나 TOML 파싱에 실패하면 에러를 반환합니다.
98    pub async fn from_file(path: impl AsRef<Path>) -> Result<Self, IronpostError> {
99        let path = path.as_ref();
100        let content = tokio::fs::read_to_string(path).await.map_err(|e| {
101            if e.kind() == std::io::ErrorKind::NotFound {
102                IronpostError::Config(ConfigError::FileNotFound {
103                    path: path.display().to_string(),
104                })
105            } else {
106                IronpostError::Io(e)
107            }
108        })?;
109        let config = Self::parse(&content)?;
110        config.validate()?;
111        Ok(config)
112    }
113
114    /// TOML 문자열에서 설정을 파싱합니다.
115    ///
116    /// # Errors
117    ///
118    /// TOML 문법이 잘못되었거나 필드 타입이 맞지 않으면 에러를 반환합니다.
119    pub fn parse(toml_str: &str) -> Result<Self, IronpostError> {
120        toml::from_str(toml_str).map_err(|e| {
121            IronpostError::Config(ConfigError::ParseFailed {
122                reason: e.to_string(),
123            })
124        })
125    }
126
127    /// 환경변수로 설정값을 오버라이드합니다.
128    ///
129    /// 환경변수 네이밍 규칙: `IRONPOST_{SECTION}_{FIELD}`
130    /// 예: `IRONPOST_EBPF_INTERFACE=eth0`
131    pub fn apply_env_overrides(&mut self) {
132        // General
133        override_string(&mut self.general.log_level, "IRONPOST_GENERAL_LOG_LEVEL");
134        override_string(&mut self.general.log_format, "IRONPOST_GENERAL_LOG_FORMAT");
135        override_string(&mut self.general.data_dir, "IRONPOST_GENERAL_DATA_DIR");
136        override_string(&mut self.general.pid_file, "IRONPOST_GENERAL_PID_FILE");
137
138        // Metrics
139        override_bool(&mut self.metrics.enabled, "IRONPOST_METRICS_ENABLED");
140        override_string(
141            &mut self.metrics.listen_addr,
142            "IRONPOST_METRICS_LISTEN_ADDR",
143        );
144        override_u16(&mut self.metrics.port, "IRONPOST_METRICS_PORT");
145        override_string(&mut self.metrics.endpoint, "IRONPOST_METRICS_ENDPOINT");
146
147        // eBPF
148        override_bool(&mut self.ebpf.enabled, "IRONPOST_EBPF_ENABLED");
149        override_string(&mut self.ebpf.interface, "IRONPOST_EBPF_INTERFACE");
150        override_string(&mut self.ebpf.xdp_mode, "IRONPOST_EBPF_XDP_MODE");
151        override_usize(
152            &mut self.ebpf.ring_buffer_size,
153            "IRONPOST_EBPF_RING_BUFFER_SIZE",
154        );
155        override_usize(
156            &mut self.ebpf.blocklist_max_entries,
157            "IRONPOST_EBPF_BLOCKLIST_MAX_ENTRIES",
158        );
159
160        // Log Pipeline
161        override_bool(
162            &mut self.log_pipeline.enabled,
163            "IRONPOST_LOG_PIPELINE_ENABLED",
164        );
165        override_csv(
166            &mut self.log_pipeline.sources,
167            "IRONPOST_LOG_PIPELINE_SOURCES",
168        );
169        override_string(
170            &mut self.log_pipeline.syslog_bind,
171            "IRONPOST_LOG_PIPELINE_SYSLOG_BIND",
172        );
173        override_string(
174            &mut self.log_pipeline.syslog_tcp_bind,
175            "IRONPOST_LOG_PIPELINE_SYSLOG_TCP_BIND",
176        );
177        override_csv(
178            &mut self.log_pipeline.watch_paths,
179            "IRONPOST_LOG_PIPELINE_WATCH_PATHS",
180        );
181        override_usize(
182            &mut self.log_pipeline.batch_size,
183            "IRONPOST_LOG_PIPELINE_BATCH_SIZE",
184        );
185        override_u64(
186            &mut self.log_pipeline.flush_interval_secs,
187            "IRONPOST_LOG_PIPELINE_FLUSH_INTERVAL_SECS",
188        );
189
190        // Storage
191        override_string(
192            &mut self.log_pipeline.storage.postgres_url,
193            "IRONPOST_STORAGE_POSTGRES_URL",
194        );
195        override_string(
196            &mut self.log_pipeline.storage.redis_url,
197            "IRONPOST_STORAGE_REDIS_URL",
198        );
199        override_u32(
200            &mut self.log_pipeline.storage.retention_days,
201            "IRONPOST_STORAGE_RETENTION_DAYS",
202        );
203
204        // Container
205        override_bool(&mut self.container.enabled, "IRONPOST_CONTAINER_ENABLED");
206        override_string(
207            &mut self.container.docker_socket,
208            "IRONPOST_CONTAINER_DOCKER_SOCKET",
209        );
210        override_u64(
211            &mut self.container.poll_interval_secs,
212            "IRONPOST_CONTAINER_POLL_INTERVAL_SECS",
213        );
214        override_string(
215            &mut self.container.policy_path,
216            "IRONPOST_CONTAINER_POLICY_PATH",
217        );
218        override_bool(
219            &mut self.container.auto_isolate,
220            "IRONPOST_CONTAINER_AUTO_ISOLATE",
221        );
222
223        // SBOM
224        override_bool(&mut self.sbom.enabled, "IRONPOST_SBOM_ENABLED");
225        override_csv(&mut self.sbom.scan_dirs, "IRONPOST_SBOM_SCAN_DIRS");
226        override_u32(
227            &mut self.sbom.vuln_db_update_hours,
228            "IRONPOST_SBOM_VULN_DB_UPDATE_HOURS",
229        );
230        override_string(&mut self.sbom.vuln_db_path, "IRONPOST_SBOM_VULN_DB_PATH");
231        override_string(&mut self.sbom.min_severity, "IRONPOST_SBOM_MIN_SEVERITY");
232        override_string(&mut self.sbom.output_format, "IRONPOST_SBOM_OUTPUT_FORMAT");
233    }
234
235    /// 설정값의 유효성을 검증합니다.
236    ///
237    /// # Errors
238    ///
239    /// 설정값이 유효하지 않을 때 [`ConfigError::InvalidValue`]를 반환합니다.
240    pub fn validate(&self) -> Result<(), IronpostError> {
241        // log_level 검증
242        let valid_levels = ["trace", "debug", "info", "warn", "error"];
243        if !valid_levels.contains(&self.general.log_level.as_str()) {
244            return Err(ConfigError::InvalidValue {
245                field: "general.log_level".to_owned(),
246                reason: format!("must be one of: {}", valid_levels.join(", ")),
247            }
248            .into());
249        }
250
251        // log_format 검증
252        let valid_formats = ["json", "pretty"];
253        if !valid_formats.contains(&self.general.log_format.as_str()) {
254            return Err(ConfigError::InvalidValue {
255                field: "general.log_format".to_owned(),
256                reason: format!("must be one of: {}", valid_formats.join(", ")),
257            }
258            .into());
259        }
260
261        // xdp_mode 검증
262        if self.ebpf.enabled {
263            let valid_modes = ["native", "skb", "hw"];
264            if !valid_modes.contains(&self.ebpf.xdp_mode.as_str()) {
265                return Err(ConfigError::InvalidValue {
266                    field: "ebpf.xdp_mode".to_owned(),
267                    reason: format!("must be one of: {}", valid_modes.join(", ")),
268                }
269                .into());
270            }
271
272            if self.ebpf.interface.is_empty() {
273                return Err(ConfigError::InvalidValue {
274                    field: "ebpf.interface".to_owned(),
275                    reason: "interface must not be empty when ebpf is enabled".to_owned(),
276                }
277                .into());
278            }
279        }
280
281        // SBOM output_format 검증
282        if self.sbom.enabled {
283            let valid_sbom_formats = ["spdx", "cyclonedx"];
284            if !valid_sbom_formats.contains(&self.sbom.output_format.as_str()) {
285                return Err(ConfigError::InvalidValue {
286                    field: "sbom.output_format".to_owned(),
287                    reason: format!("must be one of: {}", valid_sbom_formats.join(", ")),
288                }
289                .into());
290            }
291        }
292
293        // min_severity 검증
294        if self.sbom.enabled {
295            let valid_severities = ["info", "low", "medium", "high", "critical"];
296            if !valid_severities.contains(&self.sbom.min_severity.as_str()) {
297                return Err(ConfigError::InvalidValue {
298                    field: "sbom.min_severity".to_owned(),
299                    reason: format!("must be one of: {}", valid_severities.join(", ")),
300                }
301                .into());
302            }
303        }
304
305        // Metrics validation (if enabled)
306        if self.metrics.enabled {
307            self.metrics.validate()?;
308        }
309
310        // Module-specific validation (only for enabled modules)
311        if self.ebpf.enabled {
312            self.ebpf.validate()?;
313        }
314        if self.log_pipeline.enabled {
315            self.log_pipeline.validate()?;
316        }
317        if self.container.enabled {
318            self.container.validate()?;
319        }
320        if self.sbom.enabled {
321            self.sbom.validate()?;
322        }
323
324        Ok(())
325    }
326}
327
328// Default는 derive 매크로로 자동 생성 (각 필드가 Default를 구현하므로)
329
330/// 일반 설정
331#[derive(Debug, Clone, Serialize, Deserialize)]
332#[serde(default)]
333pub struct GeneralConfig {
334    /// 로그 레벨 (trace, debug, info, warn, error)
335    pub log_level: String,
336    /// 로그 형식 (json, pretty)
337    pub log_format: String,
338    /// 데이터 디렉토리
339    pub data_dir: String,
340    /// PID 파일 경로
341    pub pid_file: String,
342}
343
344impl Default for GeneralConfig {
345    fn default() -> Self {
346        Self {
347            log_level: "info".to_owned(),
348            log_format: "json".to_owned(),
349            data_dir: "/var/lib/ironpost".to_owned(),
350            pid_file: "/var/run/ironpost/ironpost.pid".to_owned(),
351        }
352    }
353}
354
355/// 메트릭 수집 및 Prometheus 노출 설정
356#[derive(Debug, Clone, Serialize, Deserialize)]
357#[serde(default)]
358pub struct MetricsConfig {
359    /// 메트릭 엔드포인트 활성화 여부
360    pub enabled: bool,
361    /// HTTP 리스너 바인드 주소
362    pub listen_addr: String,
363    /// HTTP 리스너 포트
364    pub port: u16,
365    /// 메트릭 엔드포인트 경로 (현재는 `/metrics`만 지원)
366    pub endpoint: String,
367}
368
369impl Default for MetricsConfig {
370    fn default() -> Self {
371        Self {
372            enabled: true,
373            listen_addr: "127.0.0.1".to_owned(),
374            port: 9100,
375            endpoint: "/metrics".to_owned(),
376        }
377    }
378}
379
380impl MetricsConfig {
381    /// Validate metrics configuration values.
382    pub fn validate(&self) -> Result<(), IronpostError> {
383        if self.port == 0 {
384            return Err(ConfigError::InvalidValue {
385                field: "metrics.port".to_owned(),
386                reason: "must be greater than 0".to_owned(),
387            }
388            .into());
389        }
390        if self.listen_addr.is_empty() {
391            return Err(ConfigError::InvalidValue {
392                field: "metrics.listen_addr".to_owned(),
393                reason: "must not be empty".to_owned(),
394            }
395            .into());
396        }
397        if !self.endpoint.starts_with('/') {
398            return Err(ConfigError::InvalidValue {
399                field: "metrics.endpoint".to_owned(),
400                reason: "must start with '/'".to_owned(),
401            }
402            .into());
403        }
404        if self.endpoint != "/metrics" {
405            return Err(ConfigError::InvalidValue {
406                field: "metrics.endpoint".to_owned(),
407                reason: "only '/metrics' is currently supported".to_owned(),
408            }
409            .into());
410        }
411        Ok(())
412    }
413}
414
415/// eBPF 엔진 설정
416#[derive(Debug, Clone, Serialize, Deserialize)]
417#[serde(default)]
418pub struct EbpfConfig {
419    /// 활성화 여부
420    pub enabled: bool,
421    /// 감시할 네트워크 인터페이스
422    pub interface: String,
423    /// XDP 모드 (native, skb, hw)
424    pub xdp_mode: String,
425    /// 이벤트 링 버퍼 크기 (바이트)
426    pub ring_buffer_size: usize,
427    /// 차단 목록 최대 엔트리 수
428    pub blocklist_max_entries: usize,
429}
430
431impl Default for EbpfConfig {
432    fn default() -> Self {
433        Self {
434            enabled: false,
435            interface: "eth0".to_owned(),
436            xdp_mode: "skb".to_owned(),
437            ring_buffer_size: 256 * 1024, // 256KB
438            blocklist_max_entries: 10_000,
439        }
440    }
441}
442
443impl EbpfConfig {
444    /// Validate eBPF configuration values.
445    pub fn validate(&self) -> Result<(), IronpostError> {
446        if self.ring_buffer_size == 0 {
447            return Err(ConfigError::InvalidValue {
448                field: "ebpf.ring_buffer_size".to_owned(),
449                reason: "must be greater than 0".to_owned(),
450            }
451            .into());
452        }
453        if self.blocklist_max_entries == 0 {
454            return Err(ConfigError::InvalidValue {
455                field: "ebpf.blocklist_max_entries".to_owned(),
456                reason: "must be greater than 0".to_owned(),
457            }
458            .into());
459        }
460        Ok(())
461    }
462}
463
464/// 로그 파이프라인 설정
465#[derive(Debug, Clone, Serialize, Deserialize)]
466#[serde(default)]
467pub struct LogPipelineConfig {
468    /// 활성화 여부
469    pub enabled: bool,
470    /// 수집 소스
471    pub sources: Vec<String>,
472    /// Syslog UDP 수신 주소
473    pub syslog_bind: String,
474    /// Syslog TCP 수신 주소
475    pub syslog_tcp_bind: String,
476    /// 파일 감시 경로
477    pub watch_paths: Vec<String>,
478    /// 배치 크기
479    pub batch_size: usize,
480    /// 배치 플러시 간격 (초)
481    pub flush_interval_secs: u64,
482    /// 스토리지 설정
483    #[serde(default)]
484    pub storage: StorageConfig,
485}
486
487impl Default for LogPipelineConfig {
488    fn default() -> Self {
489        Self {
490            enabled: true,
491            sources: vec!["syslog".to_owned(), "file".to_owned()],
492            syslog_bind: "0.0.0.0:514".to_owned(),
493            syslog_tcp_bind: "0.0.0.0:601".to_owned(),
494            watch_paths: vec!["/var/log/syslog".to_owned()],
495            batch_size: 100,
496            flush_interval_secs: 5,
497            storage: StorageConfig::default(),
498        }
499    }
500}
501
502impl LogPipelineConfig {
503    /// Validate log pipeline configuration values.
504    pub fn validate(&self) -> Result<(), IronpostError> {
505        if self.batch_size == 0 {
506            return Err(ConfigError::InvalidValue {
507                field: "log_pipeline.batch_size".to_owned(),
508                reason: "must be greater than 0".to_owned(),
509            }
510            .into());
511        }
512        if self.batch_size > 10_000 {
513            return Err(ConfigError::InvalidValue {
514                field: "log_pipeline.batch_size".to_owned(),
515                reason: "must not exceed 10,000 (performance limit)".to_owned(),
516            }
517            .into());
518        }
519        if self.flush_interval_secs == 0 {
520            return Err(ConfigError::InvalidValue {
521                field: "log_pipeline.flush_interval_secs".to_owned(),
522                reason: "must be greater than 0".to_owned(),
523            }
524            .into());
525        }
526        self.storage.validate()?;
527        Ok(())
528    }
529}
530
531/// 스토리지 설정
532#[derive(Debug, Clone, Serialize, Deserialize)]
533#[serde(default)]
534pub struct StorageConfig {
535    /// PostgreSQL 연결 문자열
536    pub postgres_url: String,
537    /// Redis 연결 문자열
538    pub redis_url: String,
539    /// 로그 보존 기간 (일)
540    pub retention_days: u32,
541}
542
543impl Default for StorageConfig {
544    fn default() -> Self {
545        Self {
546            postgres_url: "postgresql://localhost:5432/ironpost".to_owned(),
547            redis_url: "redis://localhost:6379".to_owned(),
548            retention_days: 30,
549        }
550    }
551}
552
553impl StorageConfig {
554    /// Validate storage configuration values.
555    pub fn validate(&self) -> Result<(), IronpostError> {
556        if self.retention_days == 0 {
557            return Err(ConfigError::InvalidValue {
558                field: "log_pipeline.storage.retention_days".to_owned(),
559                reason: "must be greater than 0".to_owned(),
560            }
561            .into());
562        }
563        if self.retention_days > 3650 {
564            return Err(ConfigError::InvalidValue {
565                field: "log_pipeline.storage.retention_days".to_owned(),
566                reason: "must not exceed 3,650 days (10 years)".to_owned(),
567            }
568            .into());
569        }
570        Ok(())
571    }
572}
573
574/// 컨테이너 가드 설정
575#[derive(Debug, Clone, Serialize, Deserialize)]
576#[serde(default)]
577pub struct ContainerConfig {
578    /// 활성화 여부
579    pub enabled: bool,
580    /// Docker 소켓 경로
581    pub docker_socket: String,
582    /// 모니터링 주기 (초)
583    pub poll_interval_secs: u64,
584    /// 격리 정책 파일 경로
585    pub policy_path: String,
586    /// 자동 격리 활성화
587    pub auto_isolate: bool,
588}
589
590impl Default for ContainerConfig {
591    fn default() -> Self {
592        Self {
593            enabled: false,
594            docker_socket: "/var/run/docker.sock".to_owned(),
595            poll_interval_secs: 10,
596            policy_path: "/etc/ironpost/policies".to_owned(),
597            auto_isolate: false,
598        }
599    }
600}
601
602impl ContainerConfig {
603    /// Validate container guard configuration values.
604    pub fn validate(&self) -> Result<(), IronpostError> {
605        if self.poll_interval_secs == 0 {
606            return Err(ConfigError::InvalidValue {
607                field: "container.poll_interval_secs".to_owned(),
608                reason: "must be greater than 0".to_owned(),
609            }
610            .into());
611        }
612        if self.poll_interval_secs > 3600 {
613            return Err(ConfigError::InvalidValue {
614                field: "container.poll_interval_secs".to_owned(),
615                reason: "must not exceed 3,600 seconds (1 hour)".to_owned(),
616            }
617            .into());
618        }
619        if self.docker_socket.is_empty() {
620            return Err(ConfigError::InvalidValue {
621                field: "container.docker_socket".to_owned(),
622                reason: "must not be empty".to_owned(),
623            }
624            .into());
625        }
626        Ok(())
627    }
628}
629
630/// SBOM 스캐너 설정
631#[derive(Debug, Clone, Serialize, Deserialize)]
632#[serde(default)]
633pub struct SbomConfig {
634    /// 활성화 여부
635    pub enabled: bool,
636    /// 스캔 대상 디렉토리
637    pub scan_dirs: Vec<String>,
638    /// 취약점 DB 업데이트 주기 (시간)
639    pub vuln_db_update_hours: u32,
640    /// 취약점 DB 경로
641    pub vuln_db_path: String,
642    /// 최소 심각도 알림 수준 (info, low, medium, high, critical)
643    pub min_severity: String,
644    /// SBOM 출력 형식 (spdx, cyclonedx)
645    pub output_format: String,
646}
647
648impl Default for SbomConfig {
649    fn default() -> Self {
650        Self {
651            enabled: false,
652            scan_dirs: vec![".".to_owned()],
653            vuln_db_update_hours: 24,
654            vuln_db_path: "/var/lib/ironpost/vuln-db".to_owned(),
655            min_severity: "medium".to_owned(),
656            output_format: "cyclonedx".to_owned(),
657        }
658    }
659}
660
661impl SbomConfig {
662    /// Validate SBOM scanner configuration values.
663    pub fn validate(&self) -> Result<(), IronpostError> {
664        if self.vuln_db_update_hours == 0 {
665            return Err(ConfigError::InvalidValue {
666                field: "sbom.vuln_db_update_hours".to_owned(),
667                reason: "must be greater than 0".to_owned(),
668            }
669            .into());
670        }
671        if self.vuln_db_update_hours > 8760 {
672            return Err(ConfigError::InvalidValue {
673                field: "sbom.vuln_db_update_hours".to_owned(),
674                reason: "must not exceed 8,760 hours (1 year)".to_owned(),
675            }
676            .into());
677        }
678        if self.scan_dirs.is_empty() {
679            return Err(ConfigError::InvalidValue {
680                field: "sbom.scan_dirs".to_owned(),
681                reason: "must have at least one directory".to_owned(),
682            }
683            .into());
684        }
685        Ok(())
686    }
687}
688
689// --- 환경변수 오버라이드 헬퍼 ---
690
691fn override_string(target: &mut String, env_key: &str) {
692    if let Ok(val) = std::env::var(env_key) {
693        *target = val;
694    }
695}
696
697fn override_bool(target: &mut bool, env_key: &str) {
698    if let Ok(val) = std::env::var(env_key) {
699        match val.parse::<bool>() {
700            Ok(parsed) => *target = parsed,
701            Err(_) => warn!(
702                env_key,
703                value = val.as_str(),
704                "failed to parse bool from env var, ignoring"
705            ),
706        }
707    }
708}
709
710fn override_usize(target: &mut usize, env_key: &str) {
711    if let Ok(val) = std::env::var(env_key) {
712        match val.parse::<usize>() {
713            Ok(parsed) => *target = parsed,
714            Err(_) => warn!(
715                env_key,
716                value = val.as_str(),
717                "failed to parse usize from env var, ignoring"
718            ),
719        }
720    }
721}
722
723fn override_u32(target: &mut u32, env_key: &str) {
724    if let Ok(val) = std::env::var(env_key) {
725        match val.parse::<u32>() {
726            Ok(parsed) => *target = parsed,
727            Err(_) => warn!(
728                env_key,
729                value = val.as_str(),
730                "failed to parse u32 from env var, ignoring"
731            ),
732        }
733    }
734}
735
736fn override_u64(target: &mut u64, env_key: &str) {
737    if let Ok(val) = std::env::var(env_key) {
738        match val.parse::<u64>() {
739            Ok(parsed) => *target = parsed,
740            Err(_) => warn!(
741                env_key,
742                value = val.as_str(),
743                "failed to parse u64 from env var, ignoring"
744            ),
745        }
746    }
747}
748
749fn override_u16(target: &mut u16, env_key: &str) {
750    if let Ok(val) = std::env::var(env_key) {
751        match val.parse::<u16>() {
752            Ok(parsed) => *target = parsed,
753            Err(_) => warn!(
754                env_key,
755                value = val.as_str(),
756                "failed to parse u16 from env var, ignoring"
757            ),
758        }
759    }
760}
761
762fn override_csv(target: &mut Vec<String>, env_key: &str) {
763    if let Ok(val) = std::env::var(env_key) {
764        *target = val.split(',').map(|s| s.trim().to_owned()).collect();
765    }
766}
767
768#[cfg(test)]
769mod tests {
770    use super::*;
771    use serial_test::serial;
772
773    #[test]
774    fn default_config_has_sane_values() {
775        let config = IronpostConfig::default();
776        assert_eq!(config.general.log_level, "info");
777        assert_eq!(config.general.log_format, "json");
778        assert!(!config.ebpf.enabled);
779        assert_eq!(config.ebpf.interface, "eth0");
780        assert!(config.log_pipeline.enabled);
781        assert!(!config.container.enabled);
782        assert!(!config.sbom.enabled);
783    }
784
785    #[test]
786    fn default_config_passes_validation() {
787        let config = IronpostConfig::default();
788        config.validate().unwrap();
789    }
790
791    #[test]
792    fn from_str_empty_toml_uses_defaults() {
793        let config = IronpostConfig::parse("").unwrap();
794        assert_eq!(config.general.log_level, "info");
795        assert_eq!(config.ebpf.interface, "eth0");
796    }
797
798    #[test]
799    fn from_str_partial_toml_merges_with_defaults() {
800        let toml = r#"
801[general]
802log_level = "debug"
803
804[ebpf]
805enabled = true
806interface = "ens3"
807"#;
808        let config = IronpostConfig::parse(toml).unwrap();
809        assert_eq!(config.general.log_level, "debug");
810        // log_format은 기본값 유지
811        assert_eq!(config.general.log_format, "json");
812        assert!(config.ebpf.enabled);
813        assert_eq!(config.ebpf.interface, "ens3");
814    }
815
816    #[test]
817    fn from_str_full_toml() {
818        let toml = r#"
819[general]
820log_level = "warn"
821log_format = "pretty"
822data_dir = "/opt/ironpost/data"
823pid_file = "/opt/ironpost/ironpost.pid"
824
825[ebpf]
826enabled = true
827interface = "ens3"
828xdp_mode = "native"
829ring_buffer_size = 524288
830blocklist_max_entries = 50000
831
832[log_pipeline]
833enabled = true
834sources = ["syslog", "file", "journald"]
835syslog_bind = "127.0.0.1:5140"
836watch_paths = ["/var/log/auth.log", "/var/log/kern.log"]
837batch_size = 200
838flush_interval_secs = 10
839
840[log_pipeline.storage]
841postgres_url = "postgresql://db:5432/ironpost"
842redis_url = "redis://cache:6379"
843retention_days = 90
844
845[container]
846enabled = true
847docker_socket = "/run/docker.sock"
848poll_interval_secs = 5
849policy_path = "/etc/ironpost/container-policies"
850auto_isolate = true
851
852[sbom]
853enabled = true
854scan_dirs = ["/app", "/opt"]
855vuln_db_update_hours = 12
856vuln_db_path = "/opt/ironpost/vuln-db"
857min_severity = "high"
858output_format = "spdx"
859"#;
860        let config = IronpostConfig::parse(toml).unwrap();
861        assert_eq!(config.general.log_level, "warn");
862        assert_eq!(config.ebpf.ring_buffer_size, 524288);
863        assert_eq!(config.log_pipeline.sources.len(), 3);
864        assert_eq!(config.log_pipeline.storage.retention_days, 90);
865        assert!(config.container.auto_isolate);
866        assert_eq!(config.sbom.output_format, "spdx");
867    }
868
869    #[test]
870    fn from_str_invalid_toml_returns_error() {
871        let result = IronpostConfig::parse("invalid = [[[toml");
872        assert!(result.is_err());
873        let err = result.unwrap_err();
874        assert!(matches!(
875            err,
876            IronpostError::Config(ConfigError::ParseFailed { .. })
877        ));
878    }
879
880    #[test]
881    fn validate_rejects_invalid_log_level() {
882        let mut config = IronpostConfig::default();
883        config.general.log_level = "verbose".to_owned();
884        let err = config.validate().unwrap_err();
885        assert!(err.to_string().contains("log_level"));
886    }
887
888    #[test]
889    fn validate_rejects_invalid_log_format() {
890        let mut config = IronpostConfig::default();
891        config.general.log_format = "xml".to_owned();
892        let err = config.validate().unwrap_err();
893        assert!(err.to_string().contains("log_format"));
894    }
895
896    #[test]
897    fn validate_rejects_invalid_xdp_mode_when_enabled() {
898        let mut config = IronpostConfig::default();
899        config.ebpf.enabled = true;
900        config.ebpf.xdp_mode = "turbo".to_owned();
901        let err = config.validate().unwrap_err();
902        assert!(err.to_string().contains("xdp_mode"));
903    }
904
905    #[test]
906    fn validate_accepts_invalid_xdp_mode_when_disabled() {
907        let mut config = IronpostConfig::default();
908        config.ebpf.enabled = false;
909        config.ebpf.xdp_mode = "turbo".to_owned();
910        // ebpf가 비활성화 상태면 xdp_mode 검증을 건너뜀
911        config.validate().unwrap();
912    }
913
914    #[test]
915    fn validate_rejects_empty_interface_when_enabled() {
916        let mut config = IronpostConfig::default();
917        config.ebpf.enabled = true;
918        config.ebpf.interface = String::new();
919        let err = config.validate().unwrap_err();
920        assert!(err.to_string().contains("interface"));
921    }
922
923    #[test]
924    fn validate_rejects_invalid_sbom_format_when_enabled() {
925        let mut config = IronpostConfig::default();
926        config.sbom.enabled = true;
927        config.sbom.output_format = "xml".to_owned();
928        let err = config.validate().unwrap_err();
929        assert!(err.to_string().contains("output_format"));
930    }
931
932    #[test]
933    #[serial]
934    fn env_override_string() {
935        let mut val = "original".to_owned();
936        // SAFETY: 테스트는 단일 스레드에서 실행되므로 환경변수 조작이 안전합니다.
937        unsafe { std::env::set_var("TEST_IRONPOST_STR", "overridden") };
938        override_string(&mut val, "TEST_IRONPOST_STR");
939        assert_eq!(val, "overridden");
940        // SAFETY: 테스트는 단일 스레드에서 실행되므로 환경변수 조작이 안전합니다.
941        unsafe { std::env::remove_var("TEST_IRONPOST_STR") };
942    }
943
944    #[test]
945    #[serial]
946    fn env_override_bool_valid() {
947        let mut val = false;
948        // SAFETY: 테스트는 단일 스레드에서 실행되므로 환경변수 조작이 안전합니다.
949        unsafe { std::env::set_var("TEST_IRONPOST_BOOL", "true") };
950        override_bool(&mut val, "TEST_IRONPOST_BOOL");
951        assert!(val);
952        // SAFETY: 테스트는 단일 스레드에서 실행되므로 환경변수 조작이 안전합니다.
953        unsafe { std::env::remove_var("TEST_IRONPOST_BOOL") };
954    }
955
956    #[test]
957    #[serial]
958    fn env_override_bool_invalid_keeps_original() {
959        let mut val = false;
960        // SAFETY: 테스트는 단일 스레드에서 실행되므로 환경변수 조작이 안전합니다.
961        unsafe { std::env::set_var("TEST_IRONPOST_BOOL_BAD", "not-a-bool") };
962        override_bool(&mut val, "TEST_IRONPOST_BOOL_BAD");
963        assert!(!val); // 원래 값 유지
964        // SAFETY: 테스트는 단일 스레드에서 실행되므로 환경변수 조작이 안전합니다.
965        unsafe { std::env::remove_var("TEST_IRONPOST_BOOL_BAD") };
966    }
967
968    #[test]
969    #[serial]
970    fn env_override_csv() {
971        let mut val = vec!["a".to_owned()];
972        // SAFETY: 테스트는 단일 스레드에서 실행되므로 환경변수 조작이 안전합니다.
973        unsafe { std::env::set_var("TEST_IRONPOST_CSV", "x, y, z") };
974        override_csv(&mut val, "TEST_IRONPOST_CSV");
975        assert_eq!(val, vec!["x", "y", "z"]);
976        // SAFETY: 테스트는 단일 스레드에서 실행되므로 환경변수 조작이 안전합니다.
977        unsafe { std::env::remove_var("TEST_IRONPOST_CSV") };
978    }
979
980    #[test]
981    fn env_override_missing_var_keeps_original() {
982        let mut val = "original".to_owned();
983        override_string(&mut val, "TEST_IRONPOST_NONEXISTENT_12345");
984        assert_eq!(val, "original");
985    }
986
987    #[test]
988    fn config_serialize_roundtrip() {
989        let config = IronpostConfig::default();
990        let toml_str = toml::to_string_pretty(&config).unwrap();
991        let parsed = IronpostConfig::parse(&toml_str).unwrap();
992        assert_eq!(config.general.log_level, parsed.general.log_level);
993        assert_eq!(config.ebpf.interface, parsed.ebpf.interface);
994        assert_eq!(
995            config.log_pipeline.storage.retention_days,
996            parsed.log_pipeline.storage.retention_days
997        );
998    }
999
1000    #[tokio::test]
1001    async fn from_file_not_found() {
1002        let result = IronpostConfig::from_file("/nonexistent/path/ironpost.toml").await;
1003        assert!(result.is_err());
1004        let err = result.unwrap_err();
1005        assert!(matches!(
1006            err,
1007            IronpostError::Config(ConfigError::FileNotFound { .. })
1008        ));
1009    }
1010
1011    // ─── MetricsConfig tests ───────────────────────────────────────────
1012
1013    #[test]
1014    fn metrics_config_default() {
1015        let config = MetricsConfig::default();
1016        assert!(config.enabled);
1017        assert_eq!(config.listen_addr, "127.0.0.1");
1018        assert_eq!(config.port, 9100);
1019        assert_eq!(config.endpoint, "/metrics");
1020    }
1021
1022    #[test]
1023    fn metrics_config_default_passes_validation() {
1024        let config = MetricsConfig::default();
1025        config.validate().unwrap();
1026    }
1027
1028    #[test]
1029    fn config_without_metrics_section_uses_defaults() {
1030        let toml = r#"
1031[general]
1032log_level = "info"
1033"#;
1034        let config = IronpostConfig::parse(toml).unwrap();
1035        assert!(config.metrics.enabled);
1036        assert_eq!(config.metrics.port, 9100);
1037        assert_eq!(config.metrics.endpoint, "/metrics");
1038    }
1039
1040    #[test]
1041    fn config_with_metrics_section() {
1042        let toml = r#"
1043[metrics]
1044enabled = false
1045listen_addr = "127.0.0.1"
1046port = 9101
1047endpoint = "/prometheus"
1048"#;
1049        let config = IronpostConfig::parse(toml).unwrap();
1050        assert!(!config.metrics.enabled);
1051        assert_eq!(config.metrics.listen_addr, "127.0.0.1");
1052        assert_eq!(config.metrics.port, 9101);
1053        assert_eq!(config.metrics.endpoint, "/prometheus");
1054    }
1055
1056    #[test]
1057    fn metrics_config_validate_rejects_zero_port() {
1058        let config = MetricsConfig {
1059            port: 0,
1060            ..MetricsConfig::default()
1061        };
1062        let err = config.validate().unwrap_err();
1063        assert!(err.to_string().contains("metrics.port"));
1064    }
1065
1066    #[test]
1067    fn metrics_config_validate_rejects_empty_listen_addr() {
1068        let config = MetricsConfig {
1069            listen_addr: String::new(),
1070            ..MetricsConfig::default()
1071        };
1072        let err = config.validate().unwrap_err();
1073        assert!(err.to_string().contains("metrics.listen_addr"));
1074    }
1075
1076    #[test]
1077    fn metrics_config_validate_rejects_endpoint_without_slash() {
1078        let config = MetricsConfig {
1079            endpoint: "metrics".to_owned(),
1080            ..MetricsConfig::default()
1081        };
1082        let err = config.validate().unwrap_err();
1083        assert!(err.to_string().contains("metrics.endpoint"));
1084        assert!(err.to_string().contains("start with '/'"));
1085    }
1086
1087    #[test]
1088    fn metrics_config_validate_rejects_non_default_endpoint() {
1089        let config = MetricsConfig {
1090            endpoint: "/prometheus/metrics".to_owned(),
1091            ..MetricsConfig::default()
1092        };
1093        let err = config.validate().unwrap_err();
1094        assert!(err.to_string().contains("metrics.endpoint"));
1095        assert!(err.to_string().contains("only '/metrics'"));
1096    }
1097
1098    #[test]
1099    fn ironpost_config_rejects_non_default_metrics_endpoint_when_enabled() {
1100        let mut config = IronpostConfig::default();
1101        config.metrics.enabled = true;
1102        config.metrics.endpoint = "/custom".to_owned();
1103
1104        let err = config.validate().unwrap_err();
1105        assert!(err.to_string().contains("metrics.endpoint"));
1106        assert!(err.to_string().contains("only '/metrics'"));
1107    }
1108
1109    #[test]
1110    #[serial]
1111    fn metrics_env_override_enabled() {
1112        let mut config = IronpostConfig::default();
1113        // SAFETY: テスト用の環境変数設定
1114        unsafe { std::env::set_var("IRONPOST_METRICS_ENABLED", "false") };
1115        config.apply_env_overrides();
1116        assert!(!config.metrics.enabled);
1117        // SAFETY: クリーンアップ
1118        unsafe { std::env::remove_var("IRONPOST_METRICS_ENABLED") };
1119    }
1120
1121    #[test]
1122    #[serial]
1123    fn metrics_env_override_listen_addr() {
1124        let mut config = IronpostConfig::default();
1125        // SAFETY: テスト用の環境変数設定
1126        unsafe { std::env::set_var("IRONPOST_METRICS_LISTEN_ADDR", "127.0.0.1") };
1127        config.apply_env_overrides();
1128        assert_eq!(config.metrics.listen_addr, "127.0.0.1");
1129        // SAFETY: クリーンアップ
1130        unsafe { std::env::remove_var("IRONPOST_METRICS_LISTEN_ADDR") };
1131    }
1132
1133    #[test]
1134    #[serial]
1135    fn metrics_env_override_port() {
1136        let mut config = IronpostConfig::default();
1137        // SAFETY: テスト用の環境変数設定
1138        unsafe { std::env::set_var("IRONPOST_METRICS_PORT", "9999") };
1139        config.apply_env_overrides();
1140        assert_eq!(config.metrics.port, 9999);
1141        // SAFETY: クリーンアップ
1142        unsafe { std::env::remove_var("IRONPOST_METRICS_PORT") };
1143    }
1144
1145    #[test]
1146    #[serial]
1147    fn metrics_env_override_endpoint() {
1148        let mut config = IronpostConfig::default();
1149        // SAFETY: テスト用の環境変数設定
1150        unsafe { std::env::set_var("IRONPOST_METRICS_ENDPOINT", "/custom") };
1151        config.apply_env_overrides();
1152        assert_eq!(config.metrics.endpoint, "/custom");
1153        // SAFETY: クリーンアップ
1154        unsafe { std::env::remove_var("IRONPOST_METRICS_ENDPOINT") };
1155    }
1156
1157    #[test]
1158    #[serial]
1159    fn metrics_env_override_port_invalid_keeps_original() {
1160        let mut config = IronpostConfig::default();
1161        let original_port = config.metrics.port;
1162        // SAFETY: テスト用の環境変数設定
1163        unsafe { std::env::set_var("IRONPOST_METRICS_PORT", "not-a-number") };
1164        config.apply_env_overrides();
1165        assert_eq!(config.metrics.port, original_port);
1166        // SAFETY: クリーンアップ
1167        unsafe { std::env::remove_var("IRONPOST_METRICS_PORT") };
1168    }
1169
1170    #[test]
1171    fn ironpost_config_validates_metrics_when_enabled() {
1172        let mut config = IronpostConfig::default();
1173        config.metrics.enabled = true;
1174        config.metrics.port = 0; // Invalid
1175        let err = config.validate().unwrap_err();
1176        assert!(err.to_string().contains("metrics.port"));
1177    }
1178
1179    #[test]
1180    fn ironpost_config_skips_metrics_validation_when_disabled() {
1181        let mut config = IronpostConfig::default();
1182        config.metrics.enabled = false;
1183        config.metrics.port = 0; // Invalid, but should be ignored
1184        config.validate().unwrap(); // Should pass
1185    }
1186}