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