// SPDX-FileCopyrightText: 2025 Tomasz Kramkowski // SPDX-License-Identifier: GPL-3.0-or-later mod helpers; use std::{ collections::HashMap, ffi::OsString, fs::File, io::Read, os::unix::fs::PermissionsExt, path::Path, time::Duration, }; use anyhow::bail; use log::LevelFilter; use rumqttc::{AsyncClient, EventLoop, MqttOptions, QoS}; use serde::Deserialize; use crate::PROGRAM; use helpers::*; #[derive(Deserialize, Debug, PartialEq)] pub struct Logging { #[serde(default = "default_level_filter")] pub level: LevelFilter, #[serde(default)] // Off pub timestamps: bool, } impl Default for Logging { fn default() -> Self { Self { level: default_level_filter(), timestamps: bool::default(), } } } #[derive(Deserialize, Debug)] pub struct Credentials { pub username: String, pub password: String, } fn default_host() -> String { "localhost".to_string() } fn default_port() -> u16 { 1883 } fn default_qos() -> QoS { QoS::ExactlyOnce } fn default_id() -> String { PROGRAM.to_string() } fn default_timeout() -> Duration { Duration::from_secs(60) } fn default_level_filter() -> LevelFilter { LevelFilter::Info } #[derive(Debug, PartialEq, Clone)] pub struct Program { pub command: Box<[OsString]>, pub timeout: Option, } #[derive(Debug)] pub struct Route { pub programs: Box<[Program]>, pub qos: Option, } #[derive(Deserialize, Debug)] pub struct Config { #[serde(default = "default_host")] pub host: String, #[serde(default = "default_port")] pub port: u16, #[serde(with = "QoSDef", default = "default_qos")] pub qos: QoS, #[serde(default = "default_timeout", deserialize_with = "deserialize_timeout")] pub timeout: Duration, #[serde(default)] pub log: Logging, pub credentials: Option, #[serde(default = "default_id")] pub id: String, pub routes: HashMap, } impl Config { pub fn mqtt_client(&self) -> (AsyncClient, EventLoop) { let client_id = self.id.to_string(); let mut options = MqttOptions::new(client_id, &self.host, self.port); if let Some(credentials) = &self.credentials { options.set_credentials(&credentials.username, &credentials.password); } // TODO: Make configurable options.set_keep_alive(Duration::from_secs(5)); options.set_max_packet_size(10 * 1024 * 1024, 10 * 1024 * 1024); AsyncClient::new(options, 10) } } pub fn load>(path: P) -> anyhow::Result { let mut f = File::open(path)?; let mut config = String::new(); f.read_to_string(&mut config)?; let config: Config = toml::from_str(&config)?; if config.credentials.is_some() { let mode = f.metadata()?.permissions().mode(); if mode & 0o044 != 0o000 { bail!("Config file contains credentials while being group or world readable."); } } Ok(config) } #[cfg(test)] mod tests { use super::*; use rumqttc::QoS; use std::{ffi::OsStr, os::unix::ffi::OsStrExt, time::Duration}; impl Program { fn new(command: Vec<&[u8]>) -> Self { Program { command: command .into_iter() .map(|s| OsStr::from_bytes(s).to_owned()) .collect(), timeout: None, } } fn new_with_timeout(command: Vec<&[u8]>, timeout: Duration) -> Self { Program { command: command .into_iter() .map(|s| OsStr::from_bytes(s).to_owned()) .collect(), timeout: Some(timeout), } } } #[test] fn load_full_config() { let toml_str = r#" host = "foo.bar.baz" port = 1234 qos = "at-most-once" id = "custom-id" timeout = 15.5 [credentials] username = "testuser" password = "testpassword" [log] level = "trace" timestamps = true [routes] "topic/map" = { programs = [ ["/bin/program1"], ["/bin/program2", "arg"], { command = ["/bin/program3", "arg"]}, ], qos = "exactly-once" } "topic/seq" = [ ["/bin/program4", "arg", { b64 = "//9hYmP//w==" }], { command = ["/bin/program5"], timeout = 1.2 }, ] "#; let config: Config = toml::from_str(toml_str).expect("Failed to parse full config"); assert_eq!(config.host, "foo.bar.baz"); assert_eq!(config.port, 1234); assert_eq!(config.qos, QoS::AtMostOnce); assert_eq!(config.id, "custom-id"); assert_eq!(config.timeout, Duration::from_secs_f64(15.5)); let creds = config.credentials.expect("Credentials should be present"); assert_eq!(creds.username, "testuser"); assert_eq!(creds.password, "testpassword"); assert_eq!(config.log.level, LevelFilter::Trace); assert_eq!(config.log.timestamps, true); assert_eq!(config.routes.len(), 2); let route_map = config.routes.get("topic/map").unwrap(); assert_eq!( route_map.programs, vec![ Program::new(vec![b"/bin/program1"]), Program::new(vec![b"/bin/program2", b"arg"]), Program::new(vec![b"/bin/program3", b"arg"]), ] .into() ); assert_eq!(route_map.qos, Some(QoS::ExactlyOnce)); let route_seq = config.routes.get("topic/seq").unwrap(); assert_eq!( route_seq.programs, vec![ Program::new(vec![b"/bin/program4", b"arg", b"\xff\xffabc\xff\xff"]), Program::new_with_timeout(vec![b"/bin/program5"], Duration::from_secs_f64(1.2)), ] .into() ); assert_eq!(route_seq.qos, None); } #[test] fn load_minimal_config() { let config: Config = toml::from_str("[routes]").expect("Failed to parse minimal config"); assert_eq!(config.host, default_host()); assert_eq!(config.port, default_port()); assert_eq!(config.qos, default_qos()); assert_eq!(config.id, default_id()); assert_eq!(config.timeout, default_timeout()); assert!(config.credentials.is_none()); assert_eq!(config.log, Logging::default()); assert!(config.routes.is_empty()); } #[test] fn load_route_seq() { let toml_str = r#" [routes] "some/topic" = [["/foo/bar"], ["/baz/qux", "arg"]] "#; let config: Config = toml::from_str(toml_str).unwrap(); let route = config.routes.get("some/topic").unwrap(); assert_eq!( route.programs, vec![ Program::new(vec![b"/foo/bar"]), Program::new(vec![b"/baz/qux", b"arg"]) ] .into() ); assert_eq!(route.qos, None); } #[test] fn load_route_map() { let toml_str = r#" [routes] "topic/with_qos" = { programs = [["/foo/bar", "arg"]], qos = "at-least-once" } "topic/without_qos" = { programs = [["/baz/qux"]] } "#; let config: Config = toml::from_str(toml_str).unwrap(); let route_with_qos = config.routes.get("topic/with_qos").unwrap(); assert_eq!( route_with_qos.programs, vec![Program::new(vec![b"/foo/bar", b"arg"])].into() ); assert_eq!(route_with_qos.qos, Some(QoS::AtLeastOnce)); let route_without_qos = config.routes.get("topic/without_qos").unwrap(); assert_eq!( route_without_qos.programs, vec![Program::new(vec![b"/baz/qux"])].into() ); assert_eq!(route_without_qos.qos, None); } #[test] fn load_timeout() { let config_int: Config = toml::from_str("timeout = 10\n[routes]").unwrap(); assert_eq!(config_int.timeout, Duration::from_secs(10)); let config_float: Config = toml::from_str("timeout = 2.5\n[routes]").unwrap(); assert_eq!(config_float.timeout, Duration::from_secs_f64(2.5)); let config_zero: Config = toml::from_str("timeout = 0\n[routes]").unwrap(); assert_eq!(config_zero.timeout, Duration::MAX); let config_zero: Config = toml::from_str("timeout = 0.0\n[routes]").unwrap(); assert_eq!(config_zero.timeout, Duration::MAX); } #[test] fn load_timeout_negative() { let result = toml::from_str::("timeout = -10\n[routes]"); assert!(result.is_err()); let result = toml::from_str::("timeout = -1.0\n[routes]"); assert!(result.is_err()); } #[test] fn load_qos() { let toml_str = r#" [routes] "at-most-once" = { programs = [], qos = "at-most-once" } "at-least-once" = { programs = [], qos = "at-least-once" } "exactly-once" = { programs = [], qos = "exactly-once" } "#; let config: Config = toml::from_str(toml_str).unwrap(); assert_eq!( config.routes.get("at-most-once").unwrap().qos.unwrap(), QoS::AtMostOnce ); assert_eq!( config.routes.get("at-least-once").unwrap().qos.unwrap(), QoS::AtLeastOnce ); assert_eq!( config.routes.get("exactly-once").unwrap().qos.unwrap(), QoS::ExactlyOnce ); } }