diff --git a/rust/Cargo.toml b/rust/Cargo.toml index 479f4472..c8baf97a 100644 --- a/rust/Cargo.toml +++ b/rust/Cargo.toml @@ -81,6 +81,7 @@ features = ["trace", "rt-tokio"] [dependencies.reqwest] version = "0.11" +features = ["json"] [dependencies.serde] version = "1.0" diff --git a/rust/src/client.rs b/rust/src/client.rs index d07fd970..6a8296f6 100644 --- a/rust/src/client.rs +++ b/rust/src/client.rs @@ -14,4 +14,5 @@ /// or to create/store data (e.g. counting pedestrian, vehicles, etc. in a specific area) #[cfg(feature = "mobility")] pub mod application; +pub mod bootstrap; pub mod configuration; diff --git a/rust/src/client/bootstrap.rs b/rust/src/client/bootstrap.rs new file mode 100644 index 00000000..aa1e5a63 --- /dev/null +++ b/rust/src/client/bootstrap.rs @@ -0,0 +1,445 @@ +/* + * Software Name : libits-client + * SPDX-FileCopyrightText: Copyright (c) Orange SA + * SPDX-License-Identifier: MIT + * + * This software is distributed under the MIT license, + * see the "LICENSE.txt" file for more details or https://opensource.org/license/MIT/ + * + * Authors: see CONTRIBUTORS.md + */ + +use crate::client::bootstrap::bootstrap_error::BootstrapError; +use crate::client::configuration::bootstrap_configuration::BootstrapConfiguration; +use crate::client::configuration::configuration_error::ConfigurationError; +#[cfg(feature = "geo_routing")] +use crate::client::configuration::geo_configuration::GeoConfiguration; +#[cfg(feature = "telemetry")] +use crate::client::configuration::telemetry_configuration::TelemetryConfiguration; +use crate::client::configuration::{ + get_optional_from_section, pick_mandatory_section, Configuration, MqttOptionWrapper, +}; +#[cfg(feature = "mobility")] +use { + crate::client::configuration::{ + mobility_configuration::MobilityConfiguration, + node_configuration::{NodeConfiguration, NODE_SECTION}, + }, + std::sync::RwLock, +}; + +use crate::client::bootstrap::bootstrap_error::BootstrapError::{ + InvalidResponse, MissingField, NotAString, +}; +use crate::client::configuration::configuration_error::ConfigurationError::{ + BootstrapFailure, MissingMandatoryField, +}; +use ini::{Ini, Properties}; +use log::{debug, error, info, trace, warn}; +use reqwest::Url; +use rumqttc::v5::MqttOptions; +use serde_json::{json, Value}; +use std::collections::HashMap; +use std::ops::Deref; + +mod bootstrap_error; + +#[derive(Debug)] +struct Bootstrap { + id: String, + username: String, + password: String, + protocols: HashMap, +} + +impl TryFrom for Bootstrap { + type Error = BootstrapError; + + fn try_from(value: Value) -> Result { + if let Some(protocols) = value.get("protocols") { + if let Some(protocols) = protocols.as_object() { + let protocols: Result<_, _> = protocols.iter().map(extract_protocol_pair).collect(); + let protocols = protocols?; + + Ok(Bootstrap { + id: extract_str("iot3_id", &value)?, + username: extract_str("psk_run_login", &value)?, + password: extract_str("psk_run_password", &value)?, + protocols, + }) + } else { + warn!("Failed to convert {:?} as JSON object", protocols); + Err(InvalidResponse("'protocols' field is not a JSON object")) + } + } else { + Err(MissingField("protocols")) + } + } +} + +/// Calls the bootstrap API and builds a Configuration out of bootstrap information +/// +/// Bootstrap sequence will return the URL and credentials to use to connect to the services +/// (MQTT, OTLP collector, ...), any of these information already present in the configuration file +/// would be overridden +/// All the other fields that are unrelated to services connection are still read from +/// the configuration file and set in the Configuration object +/// +/// Example of the bootstrap configuration section: +/// ```ini +/// [bootstrap] +/// host="mydomain.com" +/// port=1234 +/// path="/bootstrap" +/// role="external-app" +/// station_id="example" +/// username="boot" +/// password="str4P!" +/// ``` +pub async fn bootstrap(mut ini: Ini) -> Result { + info!("Beginning bootstrap..."); + + let bootstrap_configuration = + BootstrapConfiguration::try_from(&pick_mandatory_section("bootstrap", &mut ini)?)?; + + match do_bootstrap(bootstrap_configuration).await { + Ok(b) => Ok(Configuration { + mqtt_options: mqtt_configuration_from_bootstrap( + &b, + ini.delete(Some("mqtt")).unwrap_or_default(), + )?, + #[cfg(feature = "geo_routing")] + geo: GeoConfiguration::try_from(&pick_mandatory_section( + crate::client::configuration::geo_configuration::GEO_SECTION, + &mut ini, + )?)?, + #[cfg(feature = "telemetry")] + telemetry: telemetry_configuration_from_bootstrap( + &b, + ini.delete(Some("telemetry")).unwrap_or_default(), + )?, + #[cfg(feature = "mobility")] + mobility: MobilityConfiguration::try_from(&pick_mandatory_section( + crate::client::configuration::mobility_configuration::STATION_SECTION, + &mut ini, + )?)?, + #[cfg(feature = "mobility")] + node: match ini.section(Some(NODE_SECTION)) { + Some(properties) => Some(RwLock::new(NodeConfiguration::try_from(properties)?)), + None => None, + }, + custom_settings: Some(ini), + }), + Err(e) => { + error!("Failed to proceed to bootstrap: {:?}", e); + Err(BootstrapFailure(format!("{}", e))) + } + } +} + +fn mqtt_configuration_from_bootstrap( + bootstrap: &Bootstrap, + mut mqtt_section: Properties, +) -> Result { + let tls = get_optional_from_section("use_tls", &mqtt_section)?.unwrap_or_default(); + let ws = get_optional_from_section("use_websocket", &mqtt_section)?.unwrap_or_default(); + + let uri = match (tls, ws) { + (true, true) => bootstrap + .protocols + .get("mqtt-wss") + .ok_or(MissingMandatoryField("mqtt-wss", "protocols")), + (false, true) => bootstrap + .protocols + .get("mqtt-ws") + .ok_or(MissingMandatoryField("mqtt-ws", "protocols")), + (true, false) => bootstrap + .protocols + .get("mqtts") + .ok_or(MissingMandatoryField("mqtts", "protocols")), + (false, false) => bootstrap + .protocols + .get("mqtt") + .ok_or(MissingMandatoryField("mqtt", "protocols")), + }?; + + let url: Url = { + if let Ok(url) = Url::parse(uri) { + Ok(url) + } else { + Err(BootstrapFailure(format!( + "Failed to convert '{}' as Url", + uri + ))) + } + }?; + + if ws { + mqtt_section.insert("host", url.authority()); + } else { + mqtt_section.insert( + "host", + url.host_str() + .ok_or(BootstrapFailure("URL must have a host".to_string()))?, + ); + } + + mqtt_section.insert( + "port", + url.port() + .ok_or(BootstrapFailure("URL must have a port".to_string()))? + .to_string(), + ); + mqtt_section.insert("client_id", &bootstrap.id); + mqtt_section.insert("username", &bootstrap.username); + mqtt_section.insert("password", &bootstrap.password); + + match MqttOptionWrapper::try_from(&mqtt_section) { + Ok(wrapper) => Ok(wrapper.deref().clone()), + Err(e) => Err(e), + } +} + +#[cfg(feature = "telemetry")] +fn telemetry_configuration_from_bootstrap( + bootstrap: &Bootstrap, + mut telemetry_section: Properties, +) -> Result { + let tls = get_optional_from_section("use_tls", &telemetry_section)?.unwrap_or_default(); + + let uri = if tls { + bootstrap + .protocols + .get("otlp-https") + .ok_or(MissingMandatoryField("otlp-https", "protocols")) + } else { + bootstrap + .protocols + .get("otlp-http") + .ok_or(MissingMandatoryField("otlp-http", "protocols")) + }?; + + let url = Url::parse(uri).expect("Not an URL"); + + // FIXME wouldn't it be more simple to use the endpoint directly... + telemetry_section.insert( + "host", + url.host_str() + .ok_or(BootstrapFailure("URL must have a host".to_string()))?, + ); + telemetry_section.insert( + "port", + url.port() + .ok_or(BootstrapFailure("URL must have a port".to_string()))? + .to_string(), + ); + telemetry_section.insert("path", url.path()); + telemetry_section.insert("username", &bootstrap.username); + telemetry_section.insert("password", &bootstrap.password); + + TelemetryConfiguration::try_from(&telemetry_section) +} + +async fn do_bootstrap( + bootstrap_configuration: BootstrapConfiguration, +) -> Result { + info!( + "Calling bootstrap on '{}'...", + bootstrap_configuration.endpoint + ); + + let client = reqwest::ClientBuilder::new() + .build() + .expect("Failed to create telemetry HTTP client"); + + let body = json!({ + "ue_id": bootstrap_configuration.station_id, + "psk_login": bootstrap_configuration.username, + "psk_password": bootstrap_configuration.password, + "role": bootstrap_configuration.role + }) + .to_string(); + + match client + .post(bootstrap_configuration.endpoint) + .basic_auth( + bootstrap_configuration.username, + Some(bootstrap_configuration.password), + ) + .body(body) + .send() + .await + { + Ok(response) => match response.text().await { + Ok(body) => { + trace!("Bootstrap body = {:?}", body); + match serde_json::from_str::(body.as_str()) { + Ok(json_value) => Bootstrap::try_from(json_value), + Err(e) => { + warn!("Error: {:?}", e); + Err(InvalidResponse("Failed to parse response as JSON")) + } + } + } + Err(e) => { + debug!("Error: {:?}", e); + Err(BootstrapError::ContentError(e.to_string())) + } + }, + Err(e) => { + debug!("Request error: {:?}", e); + Err(BootstrapError::NetworkError(e.to_string())) + } + } +} + +fn extract_str(field: &'static str, json_value: &Value) -> Result { + if let Some(value) = json_value.get(field) { + if let Some(as_str) = value.as_str() { + Ok(as_str.to_string()) + } else { + Err(NotAString(field.to_string())) + } + } else { + Err(MissingField(field)) + } +} + +fn extract_protocol_pair(entry: (&String, &Value)) -> Result<(String, String), BootstrapError> { + let key = entry.0.to_string(); + if let Some(value) = entry.1.as_str() { + Ok((key, value.to_string())) + } else { + Err(NotAString(key)) + } +} + +#[cfg(test)] +mod tests { + use crate::client::bootstrap::Bootstrap; + use serde_json::Value; + + #[test] + fn try_from_valid_response() { + let response = serde_json::from_str::( + r#" + { + "iot3_id": "cool_id", + "psk_run_login": "notadmin", + "psk_run_password": "!s3CuR3", + "protocols": { + "mqtt": "mqtt://mqtt.domain.com:1884", + "mqtt-ws": "http://domain.com:8000/message", + "otlp-http": "http://domain.com:8000/collector", + "jaeger-http": "http://domain.com:8000/jaeger" + } + }"#, + ) + .expect("Failed to create JSON from string"); + + let result = Bootstrap::try_from(response); + + assert!(result.is_ok()); + } + + macro_rules! try_from_invalid_response_returns_error { + ($test_name:ident, $response:expr) => { + #[test] + fn $test_name() { + let response = serde_json::from_str::($response) + .expect("Failed to create JSON from string"); + + let result = Bootstrap::try_from(response); + + assert!(result.is_err()); + } + }; + } + try_from_invalid_response_returns_error!( + iot3_id_is_not_a_string, + r#" + { + "iot3_id": ["cool_id"], + "psk_run_login": "notadmin", + "psk_run_password": "!s3CuR3", + "protocols": { + "mqtt": "mqtt://mqtt.domain.com:1884", + "mqtt-ws": "http://domain.com:8000/message", + "otlp-http": "http://domain.com:8000/collector", + "jaeger-http": "http://domain.com:8000/jaeger" + } + }"# + ); + try_from_invalid_response_returns_error!( + psk_login_is_not_a_string, + r#" + { + "iot3_id": "cool_id", + "psk_run_login": {"value": "notadmin"}, + "psk_run_password": "!s3CuR3", + "protocols": { + "mqtt": "mqtt://mqtt.domain.com:1884", + "mqtt-ws": "http://domain.com:8000/message", + "otlp-http": "http://domain.com:8000/collector", + "jaeger-http": "http://domain.com:8000/jaeger" + } + }"# + ); + try_from_invalid_response_returns_error!( + psk_password_is_not_a_string, + r#" + { + "iot3_id": "cool_id", + "psk_run_login": "notadmin", + "psk_run_password": {"plain": "!s3CuR3"}, + "protocols": { + "mqtt": "mqtt://mqtt.domain.com:1884", + "mqtt-ws": "http://domain.com:8000/message", + "otlp-http": "http://domain.com:8000/collector", + "jaeger-http": "http://domain.com:8000/jaeger" + } + }"# + ); + try_from_invalid_response_returns_error!( + missing_protocols, + r#" + { + "iot3_id": "cool_id", + "psk_run_login": "notadmin", + "psk_run_password": "!s3CuR3", + "protocol": { + "mqtt": "mqtt://mqtt.domain.com:1884", + "mqtt-ws": "http://domain.com:8000/message", + "otlp-http": "http://domain.com:8000/collector", + "jaeger-http": "http://domain.com:8000/jaeger" + } + }"# + ); + try_from_invalid_response_returns_error!( + protocols_is_not_an_object, + r#" + { + "iot3_id": "cool_id", + "psk_run_login": "notadmin", + "psk_run_password": "!s3CuR3", + "protocols": [ + "mqtt://mqtt.domain.com:1884", + "http://domain.com:8000/message", + "http://domain.com:8000/collector", + "http://domain.com:8000/jaeger" + ] + }"# + ); + try_from_invalid_response_returns_error!( + protocol_value_is_not_a_string, + r#" + { + "iot3_id": "cool_id", + "psk_run_login": "notadmin", + "psk_run_password": "!s3CuR3", + "protocols": { + "mqtt": ["mqtt://mqtt.domain.com:1884", "mqtts://mqtt.domain.com:8884"] + } + }"# + ); +} diff --git a/rust/src/client/bootstrap/bootstrap_error.rs b/rust/src/client/bootstrap/bootstrap_error.rs new file mode 100644 index 00000000..84dfa999 --- /dev/null +++ b/rust/src/client/bootstrap/bootstrap_error.rs @@ -0,0 +1,26 @@ +/* + * Software Name : libits-client + * SPDX-FileCopyrightText: Copyright (c) Orange SA + * SPDX-License-Identifier: MIT + * + * This software is distributed under the MIT license, + * see the "LICENSE.txt" file for more details or https://opensource.org/license/MIT/ + * + * Authors: see CONTRIBUTORS.md + */ + +use thiserror::Error; + +#[derive(Error, Debug, PartialEq)] +pub(crate) enum BootstrapError { + #[error("Bootstrap response is invalid: {0}")] + InvalidResponse(&'static str), + #[error("Boostrap response is missing required field '{0}'")] + MissingField(&'static str), + #[error("Could not convert bootstrap response as string: {0}")] + ContentError(String), + #[error("Bootstrap request failed: {0}")] + NetworkError(String), + #[error("Could not parse value of field '{0}' as a string")] + NotAString(String), +} diff --git a/rust/src/client/configuration.rs b/rust/src/client/configuration.rs index 2d02728e..52505f60 100644 --- a/rust/src/client/configuration.rs +++ b/rust/src/client/configuration.rs @@ -62,7 +62,7 @@ pub struct Configuration { pub mobility: MobilityConfiguration, #[cfg(feature = "mobility")] pub node: Option>, - custom_settings: Option, + pub(crate) custom_settings: Option, } impl Configuration { @@ -122,7 +122,7 @@ impl Configuration { } // FIXME maybe move this into a dedicated .rs file -struct MqttOptionWrapper(MqttOptions); +pub(crate) struct MqttOptionWrapper(MqttOptions); impl TryFrom<&Properties> for MqttOptionWrapper { type Error = ConfigurationError; @@ -218,12 +218,14 @@ impl TryFrom for Configuration { fn try_from(ini_config: Ini) -> Result { let mut ini_config = ini_config; - let mqtt_properties = pick_mandatory_section(MQTT_SECTION, &mut ini_config)?; Ok(Configuration { - mqtt_options: MqttOptionWrapper::try_from(&mqtt_properties)? - .deref() - .clone(), + mqtt_options: MqttOptionWrapper::try_from(&pick_mandatory_section( + MQTT_SECTION, + &mut ini_config, + )?)? + .deref() + .clone(), #[cfg(feature = "geo_routing")] geo: GeoConfiguration::try_from(&pick_mandatory_section( GEO_SECTION, diff --git a/rust/src/client/configuration/configuration_error.rs b/rust/src/client/configuration/configuration_error.rs index 4e91cb5c..e7df3bad 100644 --- a/rust/src/client/configuration/configuration_error.rs +++ b/rust/src/client/configuration/configuration_error.rs @@ -13,6 +13,8 @@ use thiserror::Error; #[derive(Error, Debug)] pub enum ConfigurationError { + #[error("{0}")] + BootstrapFailure(String), #[error("Could not found field '{0}'")] FieldNotFound(&'static str), #[error("Cannot parse '{0}' due to invalid file type")]