#![doc(html_no_source)]
#![deny(missing_docs)]
#![forbid(unsafe_code)]
#![warn(rust_2018_idioms)]
#![warn(clippy::all)]
#![warn(clippy::pedantic)]
#![warn(clippy::nursery)]
#![warn(clippy::cargo)]
#![allow(clippy::missing_const_for_fn)]
#[cfg(test)]
mod tests;
mod parser {
pub mod socket_addr;
pub mod syst_group;
pub mod syst_user;
pub mod tls_certificate;
pub mod tls_private_key;
pub mod tracing_directive;
}
pub mod builder {
mod wants;
mod with;
pub(crate) mod validate;
pub use wants::*;
pub use with::*;
}
mod config;
mod default;
mod ensure;
mod rustls_helper;
mod virtual_tls;
mod dns_resolver;
use anyhow::Context;
use config::field::FieldServerVirtual;
pub use dns_resolver::DnsResolvers;
pub use config::{field, Config};
pub use rustls_helper::get_rustls_config;
use builder::{Builder, WantsVersion};
impl Config {
#[must_use]
pub const fn builder() -> Builder<WantsVersion> {
Builder {
state: WantsVersion(()),
}
}
pub fn from_vsl_file(path: impl AsRef<std::path::Path>) -> anyhow::Result<Self> {
let path = path.as_ref();
let vsmtp_config_dir = std::path::PathBuf::from(path.parent().ok_or_else(|| {
anyhow::anyhow!(
"File '{}' does not have a valid parent directory for configuration files",
path.display()
)
})?);
let script =
std::fs::read_to_string(path).context(format!("Cannot read file at {path:?}"))?;
let mut config = Self::from_vsl_script(script, Some(&vsmtp_config_dir))?;
config.path = Some(path.to_path_buf());
Ok(config)
}
pub fn from_vsl_script(
script: impl AsRef<str>,
resolve_path: Option<&std::path::PathBuf>,
) -> anyhow::Result<Self> {
#[derive(serde::Serialize, serde::Deserialize)]
struct VersionRequirement {
version_requirement: semver::VersionReq,
}
let script = script.as_ref();
let mut engine = rhai::Engine::new();
if let Some(resolve_path) = resolve_path.as_ref() {
engine.set_module_resolver(
rhai::module_resolvers::FileModuleResolver::new_with_path_and_extension(
resolve_path,
"vsl",
),
);
}
engine.register_global_module(vsmtp_plugin_vsl::unix_module().into());
let ast = engine
.compile(script)
.context("Failed to compile root configuration (config.vsl)")?;
let user_config: rhai::Map = engine
.call_fn(
&mut rhai::Scope::new(),
&ast,
"on_config",
(Self::default_json()?,),
)
.context("Could not get main configuration.")?;
let raw_config =
serde_json::to_string(&user_config).context("The main configuration is malformed")?;
let config = &mut serde_json::Deserializer::from_str(&raw_config);
let config = match serde_path_to_error::deserialize(config) {
Ok(config) => config,
Err(error) => anyhow::bail!(Self::format_error(&error)?),
};
let mut config = Self::ensure(config)?;
let pkg_version = semver::Version::parse(env!("CARGO_PKG_VERSION"))?;
if !config.version_requirement.matches(&pkg_version) {
anyhow::bail!(
"Version requirement not fulfilled: expected '{}' but got '{}'",
config.version_requirement,
pkg_version
);
}
config.get_domain_config(&engine)?;
Ok(config)
}
fn default_json() -> anyhow::Result<rhai::Map> {
let mut config = Self::default_with_current_user_and_group();
config
.server
.smtp
.codes
.remove(&vsmtp_common::CodeID::EhloPain);
config
.server
.smtp
.codes
.remove(&vsmtp_common::CodeID::EhloSecured);
let mut config_json =
rhai::Engine::new().parse_json(serde_json::to_string(&config)?, true)?;
{
let server = &mut *config_json
.get_mut("server")
.expect("server key should be present")
.write_lock::<rhai::Map>()
.expect("failed to lock server config option");
let system = &mut *server
.get_mut("system")
.expect("system key should be present")
.write_lock::<rhai::Map>()
.expect("failed to lock system config option");
system.remove("user");
system.remove("group");
}
Ok(config_json)
}
fn get_domain_config(&mut self, engine: &rhai::Engine) -> anyhow::Result<()> {
if let Some(domains_path) = &self.app.vsl.domain_dir {
for entry in std::fs::read_dir(domains_path).with_context(|| {
format!(
"Cannot read domain directory in '{}'",
domains_path.display()
)
})? {
let entry = entry?;
if entry.file_type()?.is_file() {
continue;
}
let domain_dir = entry.path();
let domain = entry.file_name().to_str().unwrap().to_owned();
let files = std::fs::read_dir(&domain_dir)
.with_context(|| {
format!(
"Cannot read configuration (config.vsl) for domain '{}'",
domain_dir.display()
)
})?
.filter_map(|i| i.map_or(None, |e| Some(e.path())))
.collect::<Vec<_>>();
if let Some(config_path) = files
.iter()
.find(|f| f.file_name().map_or(false, |f| f == "config.vsl"))
{
let ast = engine.compile_file(config_path.clone()).with_context(|| {
format!(
"Failed to compile configuration (config.vsl) for domain '{}'",
domain_dir.display()
)
})?;
let raw_domain_config: rhai::Map = match engine.call_fn(
&mut rhai::Scope::new(),
&ast,
"on_domain_config",
(FieldServerVirtual::default_json()?,),
) {
Ok(raw_domain_config) => raw_domain_config,
Err(err) => {
eprintln!("Could not get configuration for the '{domain}' domain because: {err}. The root domain config will be used by default.");
return Ok(());
}
};
let raw_domain_config = serde_json::to_string(&raw_domain_config)
.context("The configuration is malformed")?;
let domain_config = &mut serde_json::Deserializer::from_str(&raw_domain_config);
let domain_config = match serde_path_to_error::deserialize(domain_config) {
Ok(domain_config) => domain_config,
Err(error) => anyhow::bail!(Self::format_error(&error)?),
};
self.server.r#virtual.insert(domain.clone(), domain_config);
}
}
}
Ok(())
}
fn format_error(
error: &serde_path_to_error::Error<serde_json::Error>,
) -> anyhow::Result<String> {
let path = error.path();
let mut invalid_value_path =
serde_json::to_value(&Self::default_with_current_user_and_group())
.context("The configuration is malformed")?;
for segment in path.iter() {
if let serde_path_to_error::Segment::Map { key } = segment {
invalid_value_path = invalid_value_path
.get(key)
.cloned()
.unwrap_or(serde_json::Value::Null);
}
}
Ok(
if let serde_json::Value::Object(object) = invalid_value_path {
format!(
"In the 'config.{}' configuration, expected an object with the fields {}, at line {} column {}.",
error.path(),
object
.into_iter()
.map(|(key, _)| format!("'{key}'"))
.collect::<Vec<_>>()
.join(", "),
error.inner().line(),
error.inner().column(),
)
} else {
format!(
"In the 'config.{}' configuration, {}.",
error.path(),
error.inner()
)
},
)
}
}