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