prometheus-weathermen 0.5.0

A prometheus exporter for weather data from various weather APIs
use crate::config;
use config::NAME;
use log::{debug, error, info, trace};
use rocket::http::{Header, Status};
use rocket::{get, Either, Responder, State};
use rocket_basicauth::BasicAuth;

use crate::config::CredentialsStore;
use crate::config::ProviderTasks;

use crate::prometheus::format;
use crate::providers::Weather;
use rocket::tokio::task;
use rocket::tokio::task::JoinSet;
use tokio::task::JoinError;

#[get("/")]
#[allow(clippy::needless_pass_by_value)]
pub fn index(
    credentials_store: &State<Option<CredentialsStore>>,
    credentials_presented: Option<BasicAuth>,
) -> Result<(Status, &'static str), Either<UnauthorizedResponse, ForbiddenResponse>> {
    match maybe_authenticate(credentials_store, &credentials_presented) {
        Ok(_) => Ok((Status::NotFound, "Check /metrics")),
        Err(e) => auth_error_to_response(&e),
    }
}

#[get("/metrics")]
pub async fn metrics(
    unscheduled_tasks: &State<ProviderTasks>,
    credentials_store: &State<Option<CredentialsStore>>,
    credentials_presented: Option<BasicAuth>,
) -> Result<(Status, String), Either<UnauthorizedResponse, ForbiddenResponse>> {
    match maybe_authenticate(credentials_store, &credentials_presented) {
        Ok(_) => Ok(serve_metrics(unscheduled_tasks).await),
        Err(e) => auth_error_to_response(&e),
    }
}

async fn serve_metrics(unscheduled_tasks: &State<ProviderTasks>) -> (Status, String) {
    let mut join_set = JoinSet::new();

    #[allow(clippy::unnecessary_to_owned)]
    for task in unscheduled_tasks.to_vec() {
        join_set.spawn(task::spawn_blocking(move || {
            info!(
                "Requesting weather data for {:?} from {:?} ({:?})",
                task.request.name,
                task.provider.id(),
                task.request.query,
            );
            task.provider
                .for_coordinates(&task.client, &task.cache, &task.request)
        }));
    }

    wait_for_metrics(join_set).await.map_or_else(
        |e| {
            error!("General error while fetching weather data: {e}");
            (
                Status::InternalServerError,
                "Error while fetching weather data. Check the logs".into(),
            )
        },
        |metrics| (Status::Ok, metrics),
    )
}

async fn wait_for_metrics(
    mut join_set: JoinSet<Result<anyhow::Result<Weather>, JoinError>>,
) -> anyhow::Result<String> {
    let mut weather = vec![];

    while let Some(result) = join_set.join_next().await {
        result??.map_or_else(
            |e| error!("Provider error while fetching weather data: {e}"),
            |w| weather.push(w),
        );
    }

    format(weather)
}

fn auth_error_to_response<T>(
    error: &Denied,
) -> Result<T, Either<UnauthorizedResponse, ForbiddenResponse>> {
    match error {
        Denied::Unauthorized => Err(Either::Left(UnauthorizedResponse::new())),
        Denied::Forbidden => Err(Either::Right(ForbiddenResponse::new())),
    }
}

#[derive(Responder, Debug, PartialEq, Eq)]
#[response(content_type = "text/plain")]
pub struct UnauthorizedResponse {
    response: (Status, &'static str),
    authenticate: Header<'static>,
}

impl UnauthorizedResponse {
    fn new() -> Self {
        Self {
            response: (
                Status::Unauthorized,
                "Authentication required. No credentials provided",
            ),
            authenticate: Header::new(
                "www-authenticate",
                format!(r##"Basic realm="{NAME}", charset="UTF-8""##),
            ),
        }
    }
}

#[derive(Responder, Debug, PartialEq, Eq)]
#[response(content_type = "text/plain")]
pub struct ForbiddenResponse {
    response: (Status, &'static str),
}

impl ForbiddenResponse {
    const fn new() -> Self {
        Self {
            response: (Status::Forbidden, "Access denied. Invalid credentials"),
        }
    }
}

#[derive(Debug, PartialEq, Eq)]
pub enum Granted {
    NotRequired,
    Succeeded,
}

#[derive(Debug, PartialEq, Eq)]
pub enum Denied {
    Unauthorized,
    Forbidden,
}

pub fn maybe_authenticate(
    credentials_store: &Option<CredentialsStore>,
    credentials_presented: &Option<BasicAuth>,
) -> Result<Granted, Denied> {
    match (credentials_store, credentials_presented) {
        (Some(credentials_store), Some(credentials_presented)) => {
            authenticate(credentials_store, credentials_presented)
        }
        (Some(_), None) => {
            trace!("No credentials presented. Unauthorized");
            Err(Denied::Unauthorized)
        }
        (None, _) => {
            trace!("No credentials store configured, skipping authentication");
            Ok(Granted::NotRequired)
        }
    }
}

fn authenticate(credentials: &CredentialsStore, auth: &BasicAuth) -> Result<Granted, Denied> {
    for (username, hash) in credentials.0.clone() {
        if username != auth.username {
            continue;
        }

        return match bcrypt::verify(auth.password.as_bytes(), &hash) {
            Ok(r) => {
                if r {
                    debug!("Username {username:?} successfully authenticated");
                    Ok(Granted::Succeeded)
                } else {
                    debug!("Invalid password for {username:?}");
                    Err(Denied::Forbidden)
                }
            }
            Err(e) => {
                error!("Error verifying bcrypt hash for {username:?}: {e:?}");
                Err(Denied::Forbidden)
            }
        };
    }

    Err(Denied::Forbidden)
}

#[cfg(test)]
mod tests {
    use crate::config::CredentialsStore;
    use crate::http_server::{maybe_authenticate, Denied, Granted};
    use rocket_basicauth::BasicAuth;

    #[test]
    fn false_if_no_authentication_required() {
        assert_eq!(Ok(Granted::NotRequired), maybe_authenticate(&None, &None))
    }

    #[test]
    fn unauthorized_if_no_auth_information_provided() {
        assert_eq!(
            Err(Denied::Unauthorized),
            maybe_authenticate(&Some(CredentialsStore::empty()), &None)
        );
    }

    #[test]
    fn forbidden_if_username_not_found() {
        assert_eq!(
            Err(Denied::Forbidden),
            maybe_authenticate(
                &Some(CredentialsStore::empty()),
                &Some(BasicAuth {
                    username: "joanna".into(),
                    password: "secret".into()
                })
            )
        );
    }

    #[test]
    fn forbidden_if_incorrect_password() {
        assert_eq!(
            Err(Denied::Forbidden),
            maybe_authenticate(
                &Some(CredentialsStore::from([(
                    "joanna".into(),
                    "$2a$12$KR9glOH.QnpZ8TTZzkRFfO2GejbHoPFyBtViBgPWND764MQy735Q6".into()
                )])),
                &Some(BasicAuth {
                    username: "joanna".into(),
                    password: "incorrect".into()
                })
            )
        );
    }

    #[test]
    fn unit_if_authentication_successful() {
        assert_eq!(
            Ok(Granted::Succeeded),
            maybe_authenticate(
                &Some(CredentialsStore::from([(
                    "joanna".into(),
                    "$2a$04$58bTU55Vh8w9N5NX/DCCT.FY7ugMX06E1fFK.vtVVxOUdJYrAUlna".into()
                )])),
                &Some(BasicAuth {
                    username: "joanna".into(),
                    password: "secret".into()
                })
            )
        );
    }
}