libits/client/
bootstrap.rs

1/*
2 * Software Name : libits-client
3 * SPDX-FileCopyrightText: Copyright (c) Orange SA
4 * SPDX-License-Identifier: MIT
5 *
6 * This software is distributed under the MIT license,
7 * see the "LICENSE.txt" file for more details or https://opensource.org/license/MIT/
8 *
9 * Authors: see CONTRIBUTORS.md
10 */
11
12use crate::client::bootstrap::bootstrap_error::BootstrapError;
13use crate::client::configuration::bootstrap_configuration::BootstrapConfiguration;
14use crate::client::configuration::configuration_error::ConfigurationError;
15#[cfg(feature = "geo_routing")]
16use crate::client::configuration::geo_configuration::GeoConfiguration;
17use crate::client::configuration::mqtt_configuration::MqttConfiguration;
18#[cfg(feature = "telemetry")]
19use crate::client::configuration::telemetry_configuration::TelemetryConfiguration;
20use crate::client::configuration::{Configuration, get_optional_from_section};
21#[cfg(feature = "mobility")]
22use crate::client::configuration::{
23    mobility_configuration::MOBILITY_SECTION, mobility_configuration::MobilityConfiguration,
24    pick_mandatory_section,
25};
26
27use crate::client::bootstrap::bootstrap_error::BootstrapError::{
28    InvalidResponse, MissingField, NotAString,
29};
30use crate::client::configuration::configuration_error::ConfigurationError::{
31    BootstrapFailure, MissingMandatoryField,
32};
33use ini::{Ini, Properties};
34use log::{debug, error, info, trace, warn};
35use reqwest::Url;
36use serde_json::{Value, json};
37use std::collections::HashMap;
38
39mod bootstrap_error;
40
41#[derive(Debug)]
42struct Bootstrap {
43    id: String,
44    username: String,
45    password: String,
46    protocols: HashMap<String, String>,
47}
48
49impl TryFrom<Value> for Bootstrap {
50    type Error = BootstrapError;
51
52    fn try_from(value: Value) -> Result<Self, Self::Error> {
53        if let Some(protocols) = value.get("protocols") {
54            if let Some(protocols) = protocols.as_object() {
55                let protocols: Result<_, _> = protocols.iter().map(extract_protocol_pair).collect();
56                let protocols = protocols?;
57
58                Ok(Bootstrap {
59                    id: extract_str("iot3_id", &value)?,
60                    username: extract_str("psk_run_login", &value)?,
61                    password: extract_str("psk_run_password", &value)?,
62                    protocols,
63                })
64            } else {
65                warn!("Failed to convert {protocols:?} as JSON object");
66                Err(InvalidResponse("'protocols' field is not a JSON object"))
67            }
68        } else {
69            Err(MissingField("protocols"))
70        }
71    }
72}
73
74/// Calls the bootstrap API and builds a Configuration out of bootstrap information
75///
76/// Bootstrap sequence will return the URL and credentials to use to connect to the services
77/// (MQTT, OTLP collector, ...), any of these information already present in the configuration file
78/// would be overridden  
79/// All the other fields that are unrelated to services connection are still read from
80/// the configuration file and set in the Configuration object
81///
82/// Example of the bootstrap configuration section:
83/// ```ini
84/// [bootstrap]
85/// host="mydomain.com"
86/// port=1234
87/// path="/bootstrap"
88/// role="external-app"
89/// username="boot"
90/// password="str4P!"
91/// ```
92pub async fn bootstrap(mut ini: Ini) -> Result<Configuration, ConfigurationError> {
93    info!("Beginning bootstrap...");
94
95    let bootstrap_configuration = BootstrapConfiguration::try_from(&mut ini)?;
96    #[cfg(feature = "mobility")]
97    let mobility_configuration =
98        MobilityConfiguration::try_from(&pick_mandatory_section(MOBILITY_SECTION, &mut ini)?)?;
99    #[cfg(feature = "mobility")]
100    let id = mobility_configuration.source_uuid.as_str();
101    #[cfg(not(feature = "mobility"))]
102    let id = "iot3";
103    match do_bootstrap(bootstrap_configuration, id).await {
104        Ok(b) => {
105            info!("Bootstrap call successful");
106            debug!("Bootstrap received: {b:?}");
107
108            Ok(Configuration {
109                mqtt: mqtt_configuration_from_bootstrap(
110                    &b,
111                    ini.delete(Some("mqtt")).unwrap_or_default(),
112                )?,
113                #[cfg(feature = "geo_routing")]
114                geo: GeoConfiguration::try_from(&pick_mandatory_section(
115                    crate::client::configuration::geo_configuration::GEO_SECTION,
116                    &mut ini,
117                )?)?,
118                #[cfg(feature = "telemetry")]
119                telemetry: telemetry_configuration_from_bootstrap(
120                    &b,
121                    ini.delete(Some("telemetry")).unwrap_or_default(),
122                )?,
123                #[cfg(feature = "mobility")]
124                mobility: mobility_configuration,
125                custom_settings: Some(ini),
126            })
127        }
128        Err(e) => {
129            error!("Failed to proceed to bootstrap: {e:?}");
130            Err(BootstrapFailure(format!("{e}")))
131        }
132    }
133}
134
135fn mqtt_configuration_from_bootstrap(
136    bootstrap: &Bootstrap,
137    mut mqtt_section: Properties,
138) -> Result<MqttConfiguration, ConfigurationError> {
139    let tls = get_optional_from_section("use_tls", &mqtt_section)?.unwrap_or_default();
140    let ws = get_optional_from_section("use_websocket", &mqtt_section)?.unwrap_or_default();
141
142    let uri = match (tls, ws) {
143        (true, true) => bootstrap
144            .protocols
145            .get("mqtt-wss")
146            .ok_or(MissingMandatoryField("mqtt-wss", "protocols")),
147        (false, true) => bootstrap
148            .protocols
149            .get("mqtt-ws")
150            .ok_or(MissingMandatoryField("mqtt-ws", "protocols")),
151        (true, false) => bootstrap
152            .protocols
153            .get("mqtts")
154            .ok_or(MissingMandatoryField("mqtts", "protocols")),
155        (false, false) => bootstrap
156            .protocols
157            .get("mqtt")
158            .ok_or(MissingMandatoryField("mqtt", "protocols")),
159    }?;
160
161    let url: Url = {
162        if let Ok(url) = Url::parse(uri) {
163            Ok(url)
164        } else {
165            Err(BootstrapFailure(format!(
166                "Failed to convert '{uri}' as Url"
167            )))
168        }
169    }?;
170
171    if ws {
172        mqtt_section.insert("host", url.authority());
173    } else {
174        mqtt_section.insert(
175            "host",
176            url.host_str()
177                .ok_or(BootstrapFailure("URL must have a host".to_string()))?,
178        );
179    }
180
181    mqtt_section.insert(
182        "port",
183        url.port()
184            .ok_or(BootstrapFailure("URL must have a port".to_string()))?
185            .to_string(),
186    );
187    mqtt_section.insert("client_id", &bootstrap.id);
188    mqtt_section.insert("username", &bootstrap.username);
189    mqtt_section.insert("password", &bootstrap.password);
190
191    MqttConfiguration::try_from(&mqtt_section)
192}
193
194#[cfg(feature = "telemetry")]
195fn telemetry_configuration_from_bootstrap(
196    bootstrap: &Bootstrap,
197    mut telemetry_section: Properties,
198) -> Result<TelemetryConfiguration, ConfigurationError> {
199    let tls = get_optional_from_section("use_tls", &telemetry_section)?.unwrap_or_default();
200
201    let uri = if tls {
202        bootstrap
203            .protocols
204            .get("otlp-https")
205            .ok_or(MissingMandatoryField("otlp-https", "protocols"))
206    } else {
207        bootstrap
208            .protocols
209            .get("otlp-http")
210            .ok_or(MissingMandatoryField("otlp-http", "protocols"))
211    }?;
212
213    let url = Url::parse(uri).expect("Not an URL");
214
215    // FIXME wouldn't it be more simple to use the endpoint directly...
216    telemetry_section.insert(
217        "host",
218        url.host_str()
219            .ok_or(BootstrapFailure("URL must have a host".to_string()))?,
220    );
221    telemetry_section.insert(
222        "port",
223        url.port()
224            .ok_or(BootstrapFailure("URL must have a port".to_string()))?
225            .to_string(),
226    );
227    telemetry_section.insert("path", url.path());
228    telemetry_section.insert("username", &bootstrap.username);
229    telemetry_section.insert("password", &bootstrap.password);
230
231    TelemetryConfiguration::try_from(&telemetry_section)
232}
233
234async fn do_bootstrap(
235    bootstrap_configuration: BootstrapConfiguration,
236    id: &str,
237) -> Result<Bootstrap, BootstrapError> {
238    info!(
239        "Calling bootstrap on '{}'...",
240        bootstrap_configuration.endpoint
241    );
242
243    let client = reqwest::ClientBuilder::new()
244        .build()
245        .expect("Failed to create telemetry HTTP client");
246
247    let body = json!({
248        "ue_id": id,
249        "psk_login": bootstrap_configuration.username,
250        "psk_password": bootstrap_configuration.password,
251        "role": bootstrap_configuration.role
252    })
253    .to_string();
254
255    match client
256        .post(bootstrap_configuration.endpoint)
257        .basic_auth(
258            bootstrap_configuration.username,
259            Some(bootstrap_configuration.password),
260        )
261        .body(body)
262        .send()
263        .await
264    {
265        Ok(response) => match response.text().await {
266            Ok(body) => {
267                trace!("Bootstrap body = {body:?}");
268                match serde_json::from_str::<Value>(body.as_str()) {
269                    Ok(json_value) => Bootstrap::try_from(json_value),
270                    Err(e) => {
271                        warn!("Unable to parse the JSon {body}");
272                        debug!("Parsing error: {e:?}");
273                        Err(InvalidResponse("Failed to parse response as JSON"))
274                    }
275                }
276            }
277            Err(e) => {
278                debug!("Error: {e:?}");
279                Err(BootstrapError::ContentError(e.to_string()))
280            }
281        },
282        Err(e) => {
283            debug!("Request error: {e:?}");
284            Err(BootstrapError::NetworkError(e.to_string()))
285        }
286    }
287}
288
289fn extract_str(field: &'static str, json_value: &Value) -> Result<String, BootstrapError> {
290    if let Some(value) = json_value.get(field) {
291        if let Some(as_str) = value.as_str() {
292            Ok(as_str.to_string())
293        } else {
294            Err(NotAString(field.to_string()))
295        }
296    } else {
297        Err(MissingField(field))
298    }
299}
300
301fn extract_protocol_pair(entry: (&String, &Value)) -> Result<(String, String), BootstrapError> {
302    let key = entry.0.to_string();
303    if let Some(value) = entry.1.as_str() {
304        Ok((key, value.to_string()))
305    } else {
306        Err(NotAString(key))
307    }
308}
309
310#[cfg(test)]
311mod tests {
312    use crate::client::bootstrap::Bootstrap;
313    use serde_json::Value;
314
315    #[test]
316    fn try_from_valid_response() {
317        let response = serde_json::from_str::<Value>(
318            r#"
319            {
320                "iot3_id": "cool_id",
321                "psk_run_login": "notadmin",
322                "psk_run_password": "!s3CuR3",
323                "protocols": {
324                    "mqtt": "mqtt://mqtt.domain.com:1884",
325                    "mqtt-ws": "http://domain.com:8000/message",
326                    "otlp-http": "http://domain.com:8000/collector",
327                    "jaeger-http": "http://domain.com:8000/jaeger"
328                }
329            }"#,
330        )
331        .expect("Failed to create JSON from string");
332
333        let result = Bootstrap::try_from(response);
334
335        assert!(result.is_ok());
336    }
337
338    macro_rules! try_from_invalid_response_returns_error {
339        ($test_name:ident, $response:expr) => {
340            #[test]
341            fn $test_name() {
342                let response = serde_json::from_str::<Value>($response)
343                    .expect("Failed to create JSON from string");
344
345                let result = Bootstrap::try_from(response);
346
347                assert!(result.is_err());
348            }
349        };
350    }
351    try_from_invalid_response_returns_error!(
352        iot3_id_is_not_a_string,
353        r#"
354        {
355            "iot3_id": ["cool_id"],
356            "psk_run_login": "notadmin",
357            "psk_run_password": "!s3CuR3",
358            "protocols": {
359                "mqtt": "mqtt://mqtt.domain.com:1884",
360                "mqtt-ws": "http://domain.com:8000/message",
361                "otlp-http": "http://domain.com:8000/collector",
362                "jaeger-http": "http://domain.com:8000/jaeger"
363            }
364        }"#
365    );
366    try_from_invalid_response_returns_error!(
367        psk_login_is_not_a_string,
368        r#"
369        {
370            "iot3_id": "cool_id",
371            "psk_run_login": {"value": "notadmin"},
372            "psk_run_password": "!s3CuR3",
373            "protocols": {
374                "mqtt": "mqtt://mqtt.domain.com:1884",
375                "mqtt-ws": "http://domain.com:8000/message",
376                "otlp-http": "http://domain.com:8000/collector",
377                "jaeger-http": "http://domain.com:8000/jaeger"
378            }
379        }"#
380    );
381    try_from_invalid_response_returns_error!(
382        psk_password_is_not_a_string,
383        r#"
384        {
385            "iot3_id": "cool_id",
386            "psk_run_login": "notadmin",
387            "psk_run_password": {"plain": "!s3CuR3"},
388            "protocols": {
389                "mqtt": "mqtt://mqtt.domain.com:1884",
390                "mqtt-ws": "http://domain.com:8000/message",
391                "otlp-http": "http://domain.com:8000/collector",
392                "jaeger-http": "http://domain.com:8000/jaeger"
393            }
394        }"#
395    );
396    try_from_invalid_response_returns_error!(
397        missing_protocols,
398        r#"
399        {
400            "iot3_id": "cool_id",
401            "psk_run_login": "notadmin",
402            "psk_run_password": "!s3CuR3",
403            "protocol": {
404                "mqtt": "mqtt://mqtt.domain.com:1884",
405                "mqtt-ws": "http://domain.com:8000/message",
406                "otlp-http": "http://domain.com:8000/collector",
407                "jaeger-http": "http://domain.com:8000/jaeger"
408            }
409        }"#
410    );
411    try_from_invalid_response_returns_error!(
412        protocols_is_not_an_object,
413        r#"
414        {
415            "iot3_id": "cool_id",
416            "psk_run_login": "notadmin",
417            "psk_run_password": "!s3CuR3",
418            "protocols": [
419                "mqtt://mqtt.domain.com:1884",
420                "http://domain.com:8000/message",
421                "http://domain.com:8000/collector",
422                "http://domain.com:8000/jaeger"
423            ]
424        }"#
425    );
426    try_from_invalid_response_returns_error!(
427        protocol_value_is_not_a_string,
428        r#"
429        {
430            "iot3_id": "cool_id",
431            "psk_run_login": "notadmin",
432            "psk_run_password": "!s3CuR3",
433            "protocols": {
434                "mqtt": ["mqtt://mqtt.domain.com:1884", "mqtts://mqtt.domain.com:8884"]
435            }
436        }"#
437    );
438}