krecik 1.0.4

Asynchronous, parallel external service checker (and reporter), using industry standard libraries: Curl, ngHTTP2 and OpenSSL.
Documentation
use crate::{
    checks::page::{Method, Page},
    configuration::{
        CHECK_CONNECTION_TIMEOUT, CHECK_DEFAULT_SUCCESSFUL_HTTP_CODE, CHECK_MAX_CONNECTIONS,
        CHECK_MAX_REDIRECTIONS, CHECK_TIMEOUT, DEFAULT_SLACK_NAME,
    },
    products::{
        expected::{DomainExpectation, Expected, PageExpectation, PageExpectations},
        story::*,
        unexpected::{Unexpected, UnexpectedMinor},
    },
    *,
};
use curl::{
    easy::{Easy2, List},
    multi::Multi,
    Error as CurlError,
};
use rayon::prelude::*;
use ssl_expiration2::SslExpiration;
use std::{
    env,
    io::{Error, ErrorKind},
    time::Duration,
};


/// Trait implementing all helper functions for Curl-driven checks
pub trait GenericChecker {
    /// Executes domain checks, returns Stories
    #[instrument(skip(checks))]
    fn check_domains(checks: &[Check]) -> Stories {
        checks
            .into_par_iter()
            .flat_map(|check| {
                let notifier = check.notifier.clone();
                check
                    .domains
                    .par_iter()
                    .flat_map(|domains| {
                        let mut all_domains = domains.clone();
                        all_domains.sort();
                        all_domains.dedup();
                        all_domains
                            .par_iter()
                            .flat_map(|domain| {
                                domain
                                    .expects
                                    .par_iter()
                                    .map(|expectation| {
                                        Self::check_ssl_expire(
                                            &domain.name,
                                            *expectation,
                                            notifier.clone(),
                                        )
                                    })
                                    .collect::<Stories>()
                            })
                            .collect::<Stories>()
                    })
                    .collect::<Stories>()
            })
            .collect()
    }


    /// Executes page checks, returns Stories
    #[instrument(skip(checks))]
    fn check_pages(checks: &[Check]) -> Stories {
        checks
            .iter()
            .flat_map(|check| {
                let notifier = check.notifier.clone();
                check.pages.iter().flat_map(move |pages| {
                    let mut all_pages = pages.clone();
                    all_pages.sort();
                    all_pages.dedup();
                    let mut multi = Multi::new();
                    multi.pipelining(false, true).unwrap_or_default(); // disable http1.1, enable http2-multiplex

                    // collect tuple of page-checks and Curl handler:
                    #[allow(clippy::needless_collect)] // Clippy BUG: not needless!
                    let process_handlers: Vec<_> = all_pages
                        .iter()
                        .map(|check| (check, Self::load_handler_for(check, &multi)))
                        .collect();

                    // perform all checks at once:
                    while multi.perform().unwrap_or_default() > 0 {
                        multi
                            .wait(&mut [], Duration::from_secs(CHECK_TIMEOUT))
                            .unwrap_or_default();
                    }

                    // Collect History of results:
                    process_handlers
                        .into_iter()
                        .flat_map(|(page, handler)| {
                            Self::process_page_handler(page, handler, &multi, notifier.clone())
                        })
                        .collect::<Stories>()
                })
            })
            .collect()
    }


    /// Check SSL certificate expiration using OpenSSL function
    #[instrument]
    fn check_ssl_expire(
        domain_name: &str,
        domain_expectation: DomainExpectation,
        notifier: Option<String>,
    ) -> Story {
        SslExpiration::from_domain_name_with_timeout(domain_name, CHECK_TIMEOUT)
            .map(|ssl_validator| {
                match domain_expectation {
                    DomainExpectation::ValidExpiryPeriod(expected_days)
                        if ssl_validator.days() < expected_days
                            || ssl_validator.is_expired() =>
                    {
                        Story::error(
                            Unexpected::TLSDomainExpired(
                                domain_name.to_string(),
                                ssl_validator.days(),
                            ),
                            notifier,
                        )
                    }

                    DomainExpectation::ValidExpiryPeriod(expected_days) => {
                        Story::success(
                            Expected::TLSCertificateFresh(
                                domain_name.to_string(),
                                ssl_validator.days(),
                                expected_days,
                            ),
                            notifier,
                        )
                    }
                }
            })
            .unwrap_or_else(|err| {
                Story::minor(UnexpectedMinor::InternalProtocolProblem(
                    domain_name.to_string(),
                    err.to_string(),
                ))
            })
    }

    /// Build a Story from a Length PageExpectation
    #[instrument]
    fn handle_page_length_expectation(
        url: &str,
        raw_page_content: &str,
        expected_content_length: &PageExpectation,
        notifier: Option<String>,
    ) -> Story {
        match expected_content_length {
            &PageExpectation::ValidLength(ref requested_length)
                if raw_page_content.len() >= *requested_length =>
            {
                Story::success(
                    Expected::ContentLength(url.to_string(), *requested_length),
                    notifier,
                )
            }

            &PageExpectation::ValidLength(ref requested_length) => {
                Story::error(
                    Unexpected::ContentLengthInvalid(
                        url.to_string(),
                        raw_page_content.len(),
                        *requested_length,
                    ),
                    notifier,
                )
            }

            &PageExpectation::ValidNoLength => {
                Story::success(Expected::NoContentLength(url.to_string()), notifier)
            }

            edge_case => {
                Story::error(
                    Unexpected::UnmatchedValidationCase(
                        url.to_string(),
                        edge_case.to_string(),
                    ),
                    notifier,
                )
            }
        }
    }


    /// Find and extract code validation from validations
    #[instrument]
    fn find_code_validation(page_expectations: &[PageExpectation]) -> &PageExpectation {
        page_expectations
            .par_iter()
            .find_any(|exp| matches!(exp, PageExpectation::ValidCode(_)))
            .unwrap_or(&PageExpectation::ValidCode(
                CHECK_DEFAULT_SUCCESSFUL_HTTP_CODE,
            ))
    }


    /// Find and extract content validations
    #[instrument]
    fn find_content_validations(page_expectations: &[PageExpectation]) -> PageExpectations {
        page_expectations
            .par_iter()
            .filter(|exp| matches!(exp, PageExpectation::ValidContent(_)))
            .cloned()
            .collect()
    }


    /// Find and extract content length validation from validations
    #[instrument]
    fn find_content_length_validation(
        page_expectations: &[PageExpectation],
    ) -> &PageExpectation {
        page_expectations
            .par_iter()
            .find_any(|exp| matches!(exp, PageExpectation::ValidLength(_)))
            .unwrap_or(&PageExpectation::ValidNoLength)
    }


    /// Find and extract address validation from validations
    #[instrument]
    fn find_address_validation(page_expectations: &[PageExpectation]) -> &PageExpectation {
        page_expectations
            .par_iter()
            .find_any(|exp| matches!(exp, PageExpectation::ValidAddress(_)))
            .unwrap_or(&PageExpectation::ValidNoAddress)
    }


    /// Converts CurlError to Error
    #[instrument]
    fn produce_curl_response_error(err: CurlError) -> Error {
        let mut reason = "";
        if err.is_failed_init() {
            reason = "CURLE_FAILED_INIT"
        } // Returns whether this error corresponds to CURLE_FAILED_INIT.
        if err.is_unsupported_protocol() {
            reason = "CURLE_UNSUPPORTED_PROTOCOL"
        } // Returns whether this error corresponds to CURLE_UNSUPPORTED_PROTOCOL.
        if err.is_couldnt_resolve_proxy() {
            reason = "CURLE_COULDNT_RESOLVE_PROXY"
        } // Returns whether this error corresponds to CURLE_COULDNT_RESOLVE_PROXY.
        if err.is_couldnt_resolve_host() {
            reason = "CURLE_COULDNT_RESOLVE_HOST"
        } // Returns whether this error corresponds to CURLE_COULDNT_RESOLVE_HOST.
        if err.is_couldnt_connect() {
            reason = "CURLE_COULDNT_CONNECT"
        } // Returns whether this error corresponds to CURLE_COULDNT_CONNECT.
        if err.is_remote_access_denied() {
            reason = "CURLE_REMOTE_ACCESS_DENIED"
        } // whether this error corresponds to CURLE_REMOTE_ACCESS_DENIED.
        if err.is_partial_file() {
            reason = "CURLE_PARTIAL_FILE"
        } // Returns whether this error corresponds to CURLE_PARTIAL_FILE.
        if err.is_quote_error() {
            reason = "CURLE_QUOTE_ERROR"
        } // Returns whether this error corresponds to CURLE_QUOTE_ERROR.
        if err.is_http_returned_error() {
            reason = "CURLE_HTTP_RETURNED_ERROR"
        } // Returns whether this error corresponds to CURLE_HTTP_RETURNED_ERROR.
        if err.is_read_error() {
            reason = "CURLE_READ_ERROR"
        } // Returns whether this error corresponds to CURLE_READ_ERROR.
        if err.is_write_error() {
            reason = "CURLE_WRITE_ERROR"
        } // Returns whether this error corresponds to CURLE_WRITE_ERROR.
        if err.is_out_of_memory() {
            reason = "CURLE_OUT_OF_MEMORY"
        } // Returns whether this error corresponds to CURLE_OUT_OF_MEMORY.
        if err.is_operation_timedout() {
            reason = "CURLE_OPERATION_TIMEDOUT"
        } // Returns whether this error corresponds to CURLE_OPERATION_TIMEDOUT.
        if err.is_ssl_connect_error() {
            reason = "CURLE_SSL_CONNECT_ERROR"
        } // Returns whether this error corresponds to CURLE_SSL_CONNECT_ERROR.
        if err.is_ssl_certproblem() {
            reason = "CURLE_SSL_CERTPROBLEM"
        } // Returns whether this error corresponds to CURLE_SSL_CERTPROBLEM.
        if err.is_ssl_cipher() {
            reason = "CURLE_SSL_CIPHER"
        } // Returns whether this error corresponds to CURLE_SSL_CIPHER.
        if err.is_ssl_cacert() {
            reason = "CURLE_SSL_CACERT"
        } // Returns whether this error corresponds to CURLE_SSL_CACERT.
        if err.is_ssl_engine_initfailed() {
            reason = "CURLE_SSL_ENGINE_INITFAILED"
        } // Returns whether this error corresponds to CURLE_SSL_ENGINE_INITFAILED.
        if err.is_ssl_issuer_error() {
            reason = "CURLE_SSL_ISSUER_ERROR"
        } // Returns whether this error corresponds to CURLE_SSL_ISSUER_ERROR.
        if err.is_too_many_redirects() {
            reason = "CURLE_TOO_MANY_REDIRECTS"
        } // Returns whether this error corresponds to CURLE_TOO_MANY_REDIRECTS.
        if err.is_peer_failed_verification() {
            reason = "CURLE_PEER_FAILED_VERIFICATION"
        } // Returns whether this error corresponds to CURLE_PEER_FAILED_VERIFICATION.
        if err.is_got_nothing() {
            reason = "CURLE_GOT_NOTHING"
        } // Returns whether this error corresponds to CURLE_GOT_NOTHING.
        if err.is_ssl_engine_notfound() {
            reason = "CURLE_SSL_ENGINE_NOTFOUND"
        } // Returns whether this error corresponds to CURLE_SSL_ENGINE_NOTFOUND.
        if err.is_ssl_engine_setfailed() {
            reason = "CURLE_SSL_ENGINE_SETFAILED"
        } // Returns whether this error corresponds to CURLE_SSL_ENGINE_SETFAILED.
        if err.is_send_error() {
            reason = "CURLE_SEND_ERROR"
        } // Returns whether this error corresponds to CURLE_SEND_ERROR.
        if err.is_recv_error() {
            reason = "CURLE_RECV_ERROR"
        } // Returns whether this error corresponds to CURLE_RECV_ERROR.
        if err.is_http2_stream_error() {
            reason = "CURLE_HTTP2_STREAM"
        } // Returns whether this error corresponds to CURLE_HTTP2_STREAM.
        if err.is_http2_error() {
            reason = "CURLE_HTTP2"
        } // Returns whether this error corresponds to CURLE_HTTP2.

        Error::new(ErrorKind::Other, format!("{} ({})", err, reason))
    }


    /// Build a Story from a Length PageExpectation
    #[instrument]
    fn handle_page_content_expectations(
        url: &str,
        raw_page_content: &str,
        expected_contents: &[PageExpectation],
        notifier: Option<String>,
    ) -> Stories {
        expected_contents
            .par_iter()
            .map(|expectation| {
                match expectation {
                    PageExpectation::ValidContent(ref content)
                        if raw_page_content.contains(content) =>
                    {
                        Story::success(
                            Expected::Content(url.to_string(), content.to_string()),
                            notifier.clone(),
                        )
                    }

                    PageExpectation::ValidContent(ref content) => {
                        Story::error(
                            Unexpected::ContentInvalid(url.to_string(), content.to_string()),
                            notifier.clone(),
                        )
                    }

                    PageExpectation::ValidNoContent => {
                        Story::success(
                            Expected::EmptyContent(url.to_string()),
                            notifier.clone(),
                        )
                    }

                    edge_case => {
                        Story::error(
                            Unexpected::UnmatchedValidationCase(
                                url.to_string(),
                                format!("{:?}", edge_case),
                            ),
                            notifier.clone(),
                        )
                    }
                }
            })
            .collect::<Stories>()
    }


    /// Build a Story from a Address PageExpectation
    #[instrument]
    fn handle_page_address_expectation(
        url: &str,
        address: &str,
        expected_address: &PageExpectation,
        notifier: Option<String>,
    ) -> Story {
        match expected_address {
            &PageExpectation::ValidAddress(ref an_address) if address.contains(an_address) => {
                Story::success(
                    Expected::Address(url.to_string(), address.to_string()),
                    notifier,
                )
            }

            &PageExpectation::ValidAddress(ref an_address) => {
                Story::error(
                    Unexpected::AddressInvalid(
                        url.to_string(),
                        address.to_string(),
                        an_address.to_string(),
                    ),
                    notifier,
                )
            }

            &PageExpectation::ValidNoAddress => {
                Story::success(
                    Expected::Address(url.to_string(), url.to_string()),
                    notifier,
                )
            }

            edge_case => {
                Story::error(
                    Unexpected::UnmatchedValidationCase(
                        url.to_string(),
                        edge_case.to_string(),
                    ),
                    notifier,
                )
            }
        }
    }


    /// Build a Story from a HttpCode PageExpectation
    #[instrument]
    fn handle_page_httpcode_expectation(
        url: &str,
        connect_oserror: Option<Error>,
        response_code: Result<u32, Error>,
        expected_code: &PageExpectation,
        notifier: Option<String>,
    ) -> Story {
        match response_code {
            Ok(responded_code) => {
                match expected_code {
                    &PageExpectation::ValidCode(the_code) if responded_code == the_code => {
                        Story::success(Expected::HttpCode(url.to_string(), the_code), notifier)
                    }

                    &PageExpectation::ValidCode(the_code)
                        if responded_code > 0 && responded_code != the_code =>
                    {
                        Story::error(
                            Unexpected::HttpCodeInvalid(
                                url.to_string(),
                                responded_code,
                                the_code,
                            ),
                            notifier,
                        )
                    }

                    &PageExpectation::ValidCode(_the_code) if responded_code == 0 => {
                        match connect_oserror {
                            Some(error) => {
                                Story::minor(UnexpectedMinor::OSError(
                                    url.to_string(),
                                    error.to_string(),
                                ))
                            }
                            None => {
                                Story::error(
                                    Unexpected::HttpConnectionFailed(
                                        url.to_string(),
                                        CHECK_CONNECTION_TIMEOUT,
                                    ),
                                    notifier,
                                )
                            }
                        }
                    }

                    edge_case => {
                        Story::error(
                            Unexpected::UnmatchedValidationCase(
                                url.to_string(),
                                edge_case.to_string(),
                            ),
                            notifier,
                        )
                    }
                }
            }

            Err(err) => {
                Story::error(
                    Unexpected::URLConnectionProblem(url.to_string(), err.to_string()),
                    notifier,
                )
            }
        }
    }


    /// Process Curl page requests using given handler
    #[instrument]
    fn process_page_handler(
        page_check: &Page,
        handler: CurlHandler,
        multi: &Multi,
        notifier: Option<String>,
    ) -> Stories {
        let page_expectations = page_check.clone().expects;
        let url = &page_check.url;

        // take control over curl handler, perform validations, produce stories…
        let a_handler = match handler {
            Ok(handle) => {
                if handle.get_ref().0.is_empty() {
                    let fail = format!("Site is down: {url}");
                    error!(target: "checks", "{fail}");
                    return vec![Story::error(Unexpected::HandlerFailed(fail), notifier)];
                } else {
                    handle
                }
            }
            Err(err) => {
                error!("Couldn't connect to: {url}. Error details: {err}",);
                return vec![Story::error(
                    Unexpected::HandlerFailed(err.description().to_string()),
                    notifier,
                )];
            }
        };

        let handle = a_handler.get_ref().0.to_owned();
        let raw_page_content = String::from_utf8(handle).unwrap_or_default();
        let expected_code = Self::find_code_validation(&page_expectations);
        let expected_contents = Self::find_content_validations(&page_expectations);
        let expected_content_length = Self::find_content_length_validation(&page_expectations);
        let expected_final_address = Self::find_address_validation(&page_expectations);

        // Gather Story from expectations
        let content_stories = Self::handle_page_content_expectations(
            &page_check.url,
            &raw_page_content,
            &expected_contents,
            notifier.clone(),
        );
        let content_length_story = vec![Self::handle_page_length_expectation(
            &page_check.url,
            &raw_page_content,
            expected_content_length,
            notifier.clone(),
        )];

        let mut result_handler = match multi.remove2(a_handler) {
            Ok(res_handler) => res_handler,
            Err(err) => {
                error!(
                    "Couldn't get URL: {}. Error details: {:?}",
                    page_check.url,
                    err.to_string()
                );
                return vec![Story::error(
                    Unexpected::HandlerFailed(err.description().to_string()),
                    notifier,
                )];
            }
        };
        let result_final_address = result_handler.effective_url().unwrap_or_default();
        let result_final_address_story = vec![Self::handle_page_address_expectation(
            &page_check.url,
            result_final_address.unwrap_or_default(),
            expected_final_address,
            notifier.clone(),
        )];
        let connect_oserror = match result_handler.os_errno() {
            Ok(0) => None,
            Ok(err_code) => Some(Error::from_raw_os_error(err_code)),
            Err(error) => Some(Error::from_raw_os_error(error.code() as i32)),
        };
        let result_handler_story = vec![Self::handle_page_httpcode_expectation(
            &page_check.url,
            connect_oserror,
            result_handler
                .response_code()
                .map_err(Self::produce_curl_response_error),
            expected_code,
            notifier,
        )];

        trace!(
            "process_page_handler::page_expectations: {page_expectations:?}. process_page_handler::expected_code: {expected_code:?}. process_page_handler::expected_contents: {expected_contents:?}. process_page_handler::expected_content_length: {expected_content_length:?}. process_page_handler::expected_final_address: {expected_final_address:?}. process_page_handler::content_story: {content_stories:?}. process_page_handler::content_length_story: {content_length_story:?}. process_page_handler::result_final_address_story: {result_final_address_story:?}. process_page_handler::handle_page_httpcode_expectation: {result_handler_story:?}. process_page_handler::raw_page_content: {raw_page_content:?}."
        );

        // Collect the history results
        [
            content_stories,
            content_length_story,
            result_handler_story,
            result_final_address_story,
        ]
        .concat()
    }


    /// Build headers List for Curl
    #[instrument]
    fn list_of_headers(headers: Option<Vec<String>>) -> List {
        // Build List of HTTP headers
        // ex. header:
        //         list.append("Authorization: Basic QWxhZGRpbjpvcGVuIHNlc2FtZQ==").unwrap();
        let mut list = List::new();
        for header in headers.unwrap_or_default() {
            debug!("Setting Curl header: {}", header);
            list.append(&header.to_owned()).unwrap_or_default();
        }
        list
    }


    /// Build cookies list for Curl
    #[instrument]
    fn list_of_cookies(headers: Option<Vec<String>>) -> String {
        let mut cookies = vec![];
        for cookie in headers.unwrap_or_default() {
            debug!("Setting cookie: {}", cookie);
            cookies.push(cookie);
        }
        cookies.join(";")
    }


    /// Load page check handler
    #[instrument]
    fn load_handler_for(page_check: &Page, multi: &Multi) -> CurlHandler {
        // Initialize Curl, set URL
        let mut curl = Easy2::new(Collector(Vec::new()));
        curl.url(&page_check.url).unwrap_or_default();
        trace!("Curl URL: {}", page_check.url);

        // Load Curl request options from check:
        let curl_options = page_check.clone().options.unwrap_or_default();

        // Set agent
        match curl_options.agent.clone() {
            Some(new_agent) => {
                trace!("Setting useragent: {}", &new_agent);
                curl.useragent(&new_agent).unwrap_or_default()
            }
            None => {
                trace!("Setting Krecik default useragent");
                curl.useragent(&format!(
                    "{name}/{version} (+github.com/verknowsys/krecik)",
                    name = DEFAULT_SLACK_NAME,
                    version = env!("CARGO_PKG_VERSION")
                ))
                .unwrap_or_default();
            }
        }

        trace!("Curl options: {}", curl_options.to_string());

        // Setup Curl configuration based on given options
        if curl_options.follow_redirects.unwrap_or(true) {
            trace!("Enabled following redirects.");
            curl.follow_location(true).unwrap_or_default();
        } else {
            trace!("Disabled following redirects.");
            curl.follow_location(false).unwrap_or_default();
        }

        if curl_options.verbose.unwrap_or(false) {
            trace!("Enabling Verbose mode.");
            curl.verbose(true).unwrap_or_default();
        } else {
            trace!("Disabling Verbose mode.");
            curl.verbose(false).unwrap_or_default();
        }

        // Setup Curl configuration based on given options
        match curl_options.method {
            Some(Method::Put) => {
                trace!("Curl method: {}", "PUT");
                let post_data = curl_options.post_data.unwrap_or_default();
                curl.get(false).unwrap_or_default();
                curl.post(false).unwrap_or_default();
                curl.put(true).unwrap_or_default();
                curl.post_field_size(post_data.len() as u64)
                    .unwrap_or_default();
            }
            Some(Method::Post) => {
                trace!("Curl method: {}", "POST");
                let post_data = curl_options.post_data.unwrap_or_default();
                curl.get(false).unwrap_or_default();
                curl.put(false).unwrap_or_default();
                curl.post(true).unwrap_or_default();
                curl.post_field_size(post_data.len() as u64)
                    .unwrap_or_default();
            }

            // fallbacks to GET
            Some(_) | None => {
                trace!("Curl method: {}", "GET");
                curl.put(false).unwrap_or_default();
                curl.post(false).unwrap_or_default();
                curl.get(true).unwrap_or_default();
            }
        };

        // Pass headers and cookies
        curl.http_headers(Self::list_of_headers(curl_options.headers))
            .unwrap_or_default();
        curl.cookie(&Self::list_of_cookies(curl_options.cookies))
            .unwrap_or_default();

        // Set connection and request timeout with default fallback to 30s for each
        curl.connect_timeout(Duration::from_secs(
            curl_options
                .connection_timeout
                .unwrap_or(CHECK_CONNECTION_TIMEOUT),
        ))
        .unwrap_or_default();
        curl.timeout(Duration::from_secs(
            curl_options.timeout.unwrap_or(CHECK_TIMEOUT),
        ))
        .unwrap_or_default();

        // Verify SSL PEER
        if curl_options.ssl_verify_peer.unwrap_or(true) {
            trace!("Enabled TLS-PEER verification.");
            curl.ssl_verify_peer(true).unwrap_or_default();
        } else {
            trace!("Disabled TLS-PEER verification!");
            curl.ssl_verify_peer(false).unwrap_or_default();
        }

        // Verify SSL HOST
        if curl_options.ssl_verify_host.unwrap_or(true) {
            trace!("Enabled TLS-HOST verification.");
            curl.ssl_verify_host(true).unwrap_or_default();
        } else {
            trace!("Disabled TLS-HOST verification!");
            curl.ssl_verify_host(false).unwrap_or_default();
        }

        // Max connections is 10 per check
        curl.max_connects(CHECK_MAX_CONNECTIONS).unwrap_or_default();

        // Max reconnections is 10 per check
        curl.max_redirections(CHECK_MAX_REDIRECTIONS)
            .unwrap_or_default();

        // let handler: CurlHandler = multi.add2(curl);
        multi.add2(curl)
    }
}