use std::{collections::HashMap, fs, path::Path};
use serde::{Deserialize, Serialize};
use thiserror::Error;
#[derive(Debug, Error)]
pub enum ConfigError {
#[error("cannot read config file '{path}': {source}")]
Io {
path: String,
#[source]
source: std::io::Error,
},
#[error("cannot parse config file '{path}': {source}")]
Parse {
path: String,
#[source]
source: toml::de::Error,
},
#[error("invalid configuration: {0}")]
Invalid(String),
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct Config {
pub daemon: DaemonConfig,
pub rsyslog: RsyslogConfig,
#[serde(default)]
pub validation: ValidationConfig,
#[serde(default)]
pub logging: LoggingConfig,
#[serde(default)]
pub metrics: MetricsConfig,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct DaemonConfig {
#[serde(default = "DaemonConfig::default_listen_socket")]
pub listen_socket: String,
#[serde(default = "DaemonConfig::default_socket_mode")]
pub socket_mode: String,
#[serde(default)]
pub socket_group: Option<String>,
#[serde(default = "DaemonConfig::default_max_connections")]
pub max_connections: usize,
#[serde(default = "DaemonConfig::default_max_message_size")]
pub max_message_size: usize,
#[serde(default)]
pub listen_transport: ListenTransport,
#[serde(default)]
pub framing: FramingMode,
#[serde(default)]
pub sender: SenderMode,
}
impl DaemonConfig {
fn default_listen_socket() -> String {
"/run/logfenced/logfenced.sock".to_owned()
}
fn default_socket_mode() -> String {
"0660".to_owned()
}
fn default_max_connections() -> usize {
256
}
fn default_max_message_size() -> usize {
65_536
}
}
impl Default for DaemonConfig {
fn default() -> Self {
Self {
listen_socket: Self::default_listen_socket(),
socket_mode: Self::default_socket_mode(),
socket_group: None,
max_connections: Self::default_max_connections(),
max_message_size: Self::default_max_message_size(),
listen_transport: ListenTransport::default(),
framing: FramingMode::default(),
sender: SenderMode::default(),
}
}
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum ListenTransport {
#[default]
UnixStream,
UnixDgram,
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum FramingMode {
#[default]
OctetCount,
Newline,
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum SenderMode {
#[default]
Original,
Logfenced,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct RsyslogConfig {
#[serde(default)]
pub transport: ForwardTransport,
#[serde(default = "RsyslogConfig::default_socket")]
pub socket: String,
#[serde(default = "RsyslogConfig::default_dgram_max_attempts")]
pub dgram_max_attempts: u32,
#[serde(default)]
pub dgram_exhausted: DgramExhausted,
}
impl RsyslogConfig {
fn default_socket() -> String {
"/run/syslog".to_owned()
}
const fn default_dgram_max_attempts() -> u32 {
4
}
}
impl Default for RsyslogConfig {
fn default() -> Self {
Self {
transport: ForwardTransport::default(),
socket: Self::default_socket(),
dgram_max_attempts: Self::default_dgram_max_attempts(),
dgram_exhausted: DgramExhausted::default(),
}
}
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum ForwardTransport {
#[default]
UnixDgram,
UnixStream,
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum DgramExhausted {
#[default]
Drop,
Terminate,
}
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
pub struct ValidationConfig {
#[serde(default)]
pub mode: ValidationMode,
#[serde(default)]
pub schemas: Vec<String>,
#[serde(default)]
pub discriminator: Option<String>,
#[serde(default)]
pub schema_map: HashMap<String, String>,
#[serde(default)]
pub input_cee: CeeCookieMode,
#[serde(default)]
pub output_cee: CeeCookieMode,
#[serde(default)]
pub canonical_json: bool,
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize, Serialize)]
#[serde(rename_all = "lowercase")]
pub enum CeeCookieMode {
#[default]
Never,
Optional,
Always,
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum ValidationMode {
#[default]
Strict,
Warn,
Off,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct MetricsConfig {
#[serde(default)]
pub enabled: bool,
#[serde(default = "MetricsConfig::default_socket")]
pub socket: String,
}
impl MetricsConfig {
fn default_socket() -> String {
"/run/logfenced/logfenced.stats.sock".to_owned()
}
}
impl Default for MetricsConfig {
fn default() -> Self {
Self {
enabled: false,
socket: Self::default_socket(),
}
}
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct LoggingConfig {
#[serde(default = "LoggingConfig::default_level")]
pub level: String,
#[serde(default = "LoggingConfig::default_output")]
pub output: String,
}
impl LoggingConfig {
fn default_level() -> String {
"info".to_owned()
}
fn default_output() -> String {
"stderr".to_owned()
}
}
impl Default for LoggingConfig {
fn default() -> Self {
Self {
level: Self::default_level(),
output: Self::default_output(),
}
}
}
pub fn load(path: &Path) -> Result<Config, ConfigError> {
let path_str = path.display().to_string();
let text = fs::read_to_string(path).map_err(|source| ConfigError::Io {
path: path_str.clone(),
source,
})?;
let cfg: Config = toml::from_str(&text).map_err(|source| ConfigError::Parse {
path: path_str,
source,
})?;
validate(&cfg)?;
Ok(cfg)
}
fn validate(cfg: &Config) -> Result<(), ConfigError> {
let mode_str = cfg.daemon.socket_mode.trim_start_matches('0');
let mode_str = if mode_str.is_empty() { "0" } else { mode_str };
u32::from_str_radix(mode_str, 8).map_err(|_| {
ConfigError::Invalid(format!(
"daemon.socket_mode '{}' is not a valid octal permission string",
cfg.daemon.socket_mode
))
})?;
if cfg.daemon.max_connections == 0 {
return Err(ConfigError::Invalid(
"daemon.max_connections must be at least 1".to_owned(),
));
}
if cfg.daemon.max_message_size == 0 {
return Err(ConfigError::Invalid(
"daemon.max_message_size must be at least 1".to_owned(),
));
}
if !cfg.validation.schema_map.is_empty() && cfg.validation.discriminator.is_none() {
return Err(ConfigError::Invalid(
"validation.schema_map requires validation.discriminator to be set".to_owned(),
));
}
Ok(())
}
#[cfg(test)]
#[allow(
clippy::unwrap_used,
reason = "unwrap is appropriate in test assertions"
)]
mod tests {
use std::io::Write;
use tempfile::NamedTempFile;
use super::*;
fn write_toml(content: &str) -> NamedTempFile {
let mut f = NamedTempFile::new().unwrap();
f.write_all(content.as_bytes()).unwrap();
f
}
#[test]
fn load_minimal_config() {
let f = write_toml(
r#"
[daemon]
listen_socket = "/tmp/test.sock"
[rsyslog]
"#,
);
let cfg = load(f.path()).unwrap();
assert_eq!(cfg.daemon.listen_socket, "/tmp/test.sock");
assert_eq!(cfg.rsyslog.transport, ForwardTransport::UnixDgram);
assert_eq!(cfg.validation.mode, ValidationMode::Strict);
assert!(cfg.validation.schemas.is_empty());
}
#[test]
fn load_full_config() {
let f = write_toml(
r#"
[daemon]
listen_socket = "/run/logfenced/logfenced.sock"
socket_mode = "0660"
max_connections = 128
max_message_size = 32768
framing = "newline"
[rsyslog]
transport = "unix_stream"
socket = "/run/syslog"
[validation]
mode = "warn"
schemas = ["/etc/logfenced/schemas/audit.json"]
[logging]
level = "debug"
output = "stderr"
"#,
);
let cfg = load(f.path()).unwrap();
assert_eq!(cfg.daemon.max_connections, 128);
assert_eq!(cfg.daemon.framing, FramingMode::Newline);
assert_eq!(cfg.rsyslog.transport, ForwardTransport::UnixStream);
assert_eq!(cfg.rsyslog.socket, "/run/syslog");
assert_eq!(cfg.validation.mode, ValidationMode::Warn);
assert_eq!(cfg.validation.schemas.len(), 1);
assert_eq!(cfg.logging.level, "debug");
}
#[test]
fn load_rejects_missing_file() {
let err = load(Path::new("/nonexistent/path.toml")).unwrap_err();
assert!(matches!(err, ConfigError::Io { .. }));
}
#[test]
fn load_rejects_invalid_toml() {
let f = write_toml("not valid toml ][");
let err = load(f.path()).unwrap_err();
assert!(matches!(err, ConfigError::Parse { .. }));
}
#[test]
fn validate_rejects_zero_connections() {
let f = write_toml(
r"
[daemon]
max_connections = 0
[rsyslog]
",
);
let err = load(f.path()).unwrap_err();
assert!(matches!(err, ConfigError::Invalid(_)));
}
#[test]
fn validate_rejects_bad_socket_mode() {
let f = write_toml("[daemon]\nsocket_mode = \"0999\"\n\n[rsyslog]\n");
let err = load(f.path()).unwrap_err();
assert!(matches!(err, ConfigError::Invalid(_)));
}
#[test]
fn load_discriminator_config() {
let f = write_toml(
r#"
[daemon]
listen_socket = "/tmp/test.sock"
[rsyslog]
[validation]
mode = "strict"
discriminator = "service"
[validation.schema_map]
api-gateway = "/etc/logfenced/schemas/api.json"
auth-service = "/etc/logfenced/schemas/auth.json"
"#,
);
let cfg = load(f.path()).unwrap();
assert_eq!(cfg.validation.discriminator.as_deref(), Some("service"));
assert_eq!(cfg.validation.schema_map.len(), 2);
assert_eq!(
cfg.validation
.schema_map
.get("api-gateway")
.map(String::as_str),
Some("/etc/logfenced/schemas/api.json")
);
}
#[test]
fn validate_rejects_schema_map_without_discriminator() {
let f = write_toml(
r#"
[daemon]
listen_socket = "/tmp/test.sock"
[rsyslog]
[validation.schema_map]
svc = "/etc/logfenced/schemas/svc.json"
"#,
);
let err = load(f.path()).unwrap_err();
assert!(matches!(err, ConfigError::Invalid(_)));
}
#[test]
fn default_config_is_valid() {
let cfg = Config {
daemon: DaemonConfig::default(),
rsyslog: RsyslogConfig::default(),
validation: ValidationConfig::default(),
logging: LoggingConfig::default(),
metrics: MetricsConfig::default(),
};
validate(&cfg).unwrap();
}
#[test]
fn load_cee_cookie_config() {
let f = write_toml(
r#"
[daemon]
listen_socket = "/tmp/test.sock"
[rsyslog]
[validation]
mode = "off"
input_cee = "always"
output_cee = "optional"
"#,
);
let cfg = load(f.path()).unwrap();
assert_eq!(cfg.validation.input_cee, CeeCookieMode::Always);
assert_eq!(cfg.validation.output_cee, CeeCookieMode::Optional);
}
#[test]
fn cee_cookie_mode_defaults_to_never() {
let f = write_toml("[daemon]\nlisten_socket = \"/tmp/t.sock\"\n\n[rsyslog]\n");
let cfg = load(f.path()).unwrap();
assert_eq!(cfg.validation.input_cee, CeeCookieMode::Never);
assert_eq!(cfg.validation.output_cee, CeeCookieMode::Never);
}
#[test]
fn load_canonical_json_config() {
let f = write_toml(
"[daemon]\nlisten_socket = \"/tmp/t.sock\"\n\n[rsyslog]\n\
\n[validation]\ncanonical_json = true\n",
);
let cfg = load(f.path()).unwrap();
assert!(cfg.validation.canonical_json);
}
#[test]
fn canonical_json_defaults_to_false() {
let f = write_toml("[daemon]\nlisten_socket = \"/tmp/t.sock\"\n\n[rsyslog]\n");
let cfg = load(f.path()).unwrap();
assert!(!cfg.validation.canonical_json);
}
#[test]
fn sender_defaults_to_original() {
let f = write_toml("[daemon]\nlisten_socket = \"/tmp/t.sock\"\n\n[rsyslog]\n");
let cfg = load(f.path()).unwrap();
assert_eq!(cfg.daemon.sender, SenderMode::Original);
}
#[test]
fn sender_logfenced_parses() {
let f = write_toml(
"[daemon]\nlisten_socket = \"/tmp/t.sock\"\nsender = \"logfenced\"\n\n[rsyslog]\n",
);
let cfg = load(f.path()).unwrap();
assert_eq!(cfg.daemon.sender, SenderMode::Logfenced);
}
#[test]
fn sender_original_parses() {
let f = write_toml(
"[daemon]\nlisten_socket = \"/tmp/t.sock\"\nsender = \"original\"\n\n[rsyslog]\n",
);
let cfg = load(f.path()).unwrap();
assert_eq!(cfg.daemon.sender, SenderMode::Original);
}
#[test]
fn listen_transport_defaults_to_unix_stream() {
let f = write_toml("[daemon]\nlisten_socket = \"/tmp/t.sock\"\n\n[rsyslog]\n");
let cfg = load(f.path()).unwrap();
assert_eq!(cfg.daemon.listen_transport, ListenTransport::UnixStream);
}
#[test]
fn listen_transport_unix_dgram_parses() {
let f = write_toml(
"[daemon]\nlisten_socket = \"/tmp/t.sock\"\nlisten_transport = \"unix_dgram\"\n\n[rsyslog]\n",
);
let cfg = load(f.path()).unwrap();
assert_eq!(cfg.daemon.listen_transport, ListenTransport::UnixDgram);
}
#[test]
fn metrics_enabled_defaults_to_false() {
let f = write_toml("[daemon]\nlisten_socket = \"/tmp/t.sock\"\n\n[rsyslog]\n");
let cfg = load(f.path()).unwrap();
assert!(!cfg.metrics.enabled);
}
#[test]
fn metrics_enabled_parses() {
let f = write_toml(
"[daemon]\nlisten_socket = \"/tmp/t.sock\"\n\n[rsyslog]\n\n[metrics]\nenabled = true\n",
);
let cfg = load(f.path()).unwrap();
assert!(cfg.metrics.enabled);
}
#[test]
fn dgram_max_attempts_defaults_to_4() {
let f = write_toml("[daemon]\nlisten_socket = \"/tmp/t.sock\"\n\n[rsyslog]\n");
let cfg = load(f.path()).unwrap();
assert_eq!(cfg.rsyslog.dgram_max_attempts, 4);
}
#[test]
fn dgram_max_attempts_parses() {
let f = write_toml(
"[daemon]\nlisten_socket = \"/tmp/t.sock\"\n\n[rsyslog]\ndgram_max_attempts = 0\n",
);
let cfg = load(f.path()).unwrap();
assert_eq!(cfg.rsyslog.dgram_max_attempts, 0);
}
#[test]
fn dgram_exhausted_defaults_to_drop() {
let f = write_toml("[daemon]\nlisten_socket = \"/tmp/t.sock\"\n\n[rsyslog]\n");
let cfg = load(f.path()).unwrap();
assert_eq!(cfg.rsyslog.dgram_exhausted, DgramExhausted::Drop);
}
#[test]
fn dgram_exhausted_terminate_parses() {
let f = write_toml(
"[daemon]\nlisten_socket = \"/tmp/t.sock\"\n\
\n[rsyslog]\ndgram_exhausted = \"terminate\"\n",
);
let cfg = load(f.path()).unwrap();
assert_eq!(cfg.rsyslog.dgram_exhausted, DgramExhausted::Terminate);
}
}