spf-milter 0.6.0

Milter for SPF verification
Documentation
// SPF Milter – milter for SPF verification
// Copyright © 2020–2023 David Bürgin <dbuergin@gluet.ch>
//
// This program is free software: you can redistribute it and/or modify it under
// the terms of the GNU General Public License as published by the Free Software
// Foundation, either version 3 of the License, or (at your option) any later
// version.
//
// This program is distributed in the hope that it will be useful, but WITHOUT
// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
// FOR A PARTICULAR PURPOSE. See the GNU General Public License for more
// details.
//
// You should have received a copy of the GNU General Public License along with
// this program. If not, see <https://www.gnu.org/licenses/>.

//! The SPF Milter application library.
//!
//! This library was published to facilitate integration testing of the [SPF
//! Milter application][SPF Milter]. No backwards compatibility guarantees are
//! made for the public API in this library. Please look into the application
//! instead.
//!
//! [SPF Milter]: https://crates.io/crates/spf-milter

mod auth;
mod callbacks;
mod config;
mod header;
mod resolver;
mod verify;

pub use crate::config::{
    cli_opts::{CliOptions, CliOptionsBuilder},
    model::{
        LogDestination, LogLevel, ParseLogDestinationError, ParseLogLevelError, ParseSocketError,
        ParseSyslogFacilityError, Socket, SyslogFacility,
    },
};
use crate::config::{read, SessionConfig};
use indymilter::IntoListener;
use log::{error, info, LevelFilter, Log, Metadata, Record, SetLoggerError};
use std::{
    future::Future,
    io::{self, stderr, ErrorKind, Write},
    sync::{Arc, RwLock},
};
use tokio::sync::mpsc;
use viaspf::lookup::Lookup;

/// The SPF Milter application name.
pub const MILTER_NAME: &str = "SPF Milter";

/// The SPF Milter version string.
pub const VERSION: &str = env!("CARGO_PKG_VERSION");

/// Initial configuration read from the file system.
pub struct Config {
    cli_opts: CliOptions,
    config: config::Config,
    mock_resolver: Option<Box<dyn Lookup>>,
}

impl Config {
    /// Reads configuration from the file system.
    ///
    /// # Errors
    ///
    /// If no valid configuration could be read, an error is returned.
    pub async fn read(opts: CliOptions) -> io::Result<Self> {
        Self::read_internal(opts, None).await
    }

    /// Reads configuration from the file system, and sets up the supplied
    /// `Lookup` to be used for all DNS queries.
    ///
    /// This method can be used to run SPF Milter with a mock DNS resolver,
    /// especially for testing.
    ///
    /// # Errors
    ///
    /// If no valid configuration could be read, an error is returned.
    pub async fn read_with_lookup(
        opts: CliOptions,
        lookup: impl Lookup + 'static,
    ) -> io::Result<Self> {
        let mock_resolver = Box::new(lookup);
        Self::read_internal(opts, Some(mock_resolver)).await
    }

    async fn read_internal(
        opts: CliOptions,
        mock_resolver: Option<Box<dyn Lookup>>,
    ) -> io::Result<Self> {
        let config = read::read_config(&opts).await.map_err(|e| {
            io::Error::new(
                ErrorKind::Other,
                format!(
                    "failed to load configuration from {}: {}",
                    opts.config_file().display(),
                    read::focus_error(&e)
                ),
            )
        })?;

        Ok(Self {
            cli_opts: opts,
            config,
            mock_resolver,
        })
    }

    /// Returns the configured socket.
    pub fn socket(&self) -> &Socket {
        self.config.socket()
    }
}

/// Starts SPF Milter listening on the given socket using the supplied
/// configuration.
///
/// Calling this function installs a global logger as a side effect. Therefore,
/// this function must not be called more than once.
///
/// # Errors
///
/// If execution of the milter fails, an error is returned.
///
/// # Examples
///
/// ```
/// # async fn f() -> std::io::Result<()> {
/// use spf_milter::Config;
/// use std::process;
/// use tokio::{net::TcpListener, signal, sync::mpsc};
///
/// let listener = TcpListener::bind("127.0.0.1:3000").await?;
/// let opts = Default::default();
/// let config = Config::read(opts).await?;
/// let (_, reload) = mpsc::channel(1);
/// let shutdown = signal::ctrl_c();
///
/// if let Err(e) = spf_milter::run(listener, config, reload, shutdown).await {
///     eprintln!("failed to run spf-milter: {e}");
///     process::exit(1);
/// }
/// # Ok(())
/// # }
/// ```
pub async fn run(
    listener: impl IntoListener,
    config: Config,
    reload: mpsc::Receiver<()>,
    shutdown: impl Future,
) -> io::Result<()> {
    let Config { cli_opts, config, mock_resolver } = config;

    match config.log_destination() {
        LogDestination::Syslog => {
            syslog::init_unix(config.syslog_facility().into(), config.log_level().into())
                .map_err(|e| {
                    io::Error::new(
                        ErrorKind::Other,
                        format!("could not initialize syslog: {e}"),
                    )
                })?;
        }
        LogDestination::Stderr => {
            StderrLog::init(config.log_level()).map_err(|e| {
                io::Error::new(
                    ErrorKind::Other,
                    format!("could not initialize stderr log: {e}"),
                )
            })?;
        }
    }

    // Note: No logging until this point.

    let session_config = match mock_resolver {
        Some(resolver) => SessionConfig::with_mock_resolver(config, resolver),
        None => SessionConfig::new(config),
    };
    let session_config = Arc::new(RwLock::new(Arc::new(session_config)));

    spawn_reload_task(session_config.clone(), cli_opts, reload);

    let callbacks = callbacks::make_callbacks(session_config);
    let config = Default::default();

    info!("{MILTER_NAME} {VERSION} starting");

    let result = indymilter::run(listener, callbacks, config, shutdown).await;

    match &result {
        Ok(()) => info!("{MILTER_NAME} {VERSION} shut down"),
        Err(e) => error!("{MILTER_NAME} {VERSION} terminated with error: {e}"),
    }

    result
}

fn spawn_reload_task(
    session_config: Arc<RwLock<Arc<SessionConfig>>>,
    opts: CliOptions,
    mut reload: mpsc::Receiver<()>,
) {
    tokio::spawn(async move {
        while let Some(()) = reload.recv().await {
            config::reload(&session_config, &opts).await;
        }
    });
}

/// A minimal log implementation that uses `writeln!` for logging.
struct StderrLog {
    level: LevelFilter,
}

impl StderrLog {
    fn init<L: Into<LevelFilter>>(level: L) -> Result<(), SetLoggerError> {
        let level = level.into();
        log::set_boxed_logger(Box::new(Self { level }))
            .map(|_| log::set_max_level(level))
    }
}

impl Log for StderrLog {
    fn enabled(&self, metadata: &Metadata) -> bool {
        metadata.level() <= self.level
    }

    fn log(&self, record: &Record) {
        if self.enabled(record.metadata()) {
            let _ = writeln!(stderr(), "{}", record.args());
        }
    }

    fn flush(&self) {}
}