Skip to main content

edgehog_device_runtime/data/
astarte_device_sdk_lib.rs

1/*
2 * This file is part of Edgehog.
3 *
4 * Copyright 2022 SECO Mind Srl
5 *
6 * Licensed under the Apache License, Version 2.0 (the "License");
7 * you may not use this file except in compliance with the License.
8 * You may obtain a copy of the License at
9 *
10 *   http://www.apache.org/licenses/LICENSE-2.0
11 *
12 * Unless required by applicable law or agreed to in writing, software
13 * distributed under the License is distributed on an "AS IS" BASIS,
14 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15 * See the License for the specific language governing permissions and
16 * limitations under the License.
17 *
18 * SPDX-License-Identifier: Apache-2.0
19 */
20
21use std::path::Path;
22
23use astarte_device_sdk::builder::{BuilderError, DeviceBuilder};
24use astarte_device_sdk::error::Error as AstarteError;
25use astarte_device_sdk::introspection::AddInterfaceError;
26use astarte_device_sdk::prelude::*;
27use astarte_device_sdk::store::SqliteStore;
28use astarte_device_sdk::transport::mqtt::{Credential, Mqtt, MqttConfig};
29use astarte_device_sdk::DeviceClient;
30use serde::Deserialize;
31use tokio::task::JoinSet;
32use url::Url;
33
34use crate::repository::file_state_repository::{FileStateError, FileStateRepository};
35use crate::repository::StateRepository;
36
37/// Error returned by the [`astarte_device_sdk`].
38#[derive(Debug, thiserror::Error, displaydoc::Display)]
39pub enum DeviceSdkError {
40    /// missing device ID
41    MissingDeviceId,
42    /// couldn't get the hardware id from DBus
43    #[cfg(all(feature = "zbus", target_os = "linux"))]
44    Zbus(#[from] zbus::Error),
45    /// couldn't read credential secret
46    ReadSecret(#[source] FileStateError),
47    /// couldn't get credential secret or pairing token
48    MissingCredentialSecret,
49    /// couldn't add interfaces directory
50    Interfaces(#[from] AddInterfaceError),
51    /// couldn't build Astarte device
52    Builder(#[from] BuilderError),
53    /// couldn't connect to Astarte
54    Connect(#[source] AstarteError),
55}
56
57#[derive(Debug, Deserialize, Clone)]
58pub struct AstarteDeviceSdkConfigOptions {
59    pub realm: String,
60    pub device_id: Option<String>,
61    pub credentials_secret: Option<String>,
62    pub pairing_url: Url,
63    pub pairing_token: Option<String>,
64    #[serde(default)]
65    pub ignore_ssl: bool,
66}
67
68impl AstarteDeviceSdkConfigOptions {
69    async fn device_id_or_from_dbus(&self) -> Result<String, DeviceSdkError> {
70        if let Some(id) = self.device_id.as_ref().filter(|id| !id.is_empty()) {
71            return Ok(id.clone());
72        }
73
74        cfg_if::cfg_if! {
75            if #[cfg(all(feature = "zbus", target_os = "linux"))] {
76                hardware_id_from_dbus()
77                    .await?
78                    .ok_or(DeviceSdkError::MissingDeviceId)
79            } else {
80                Err(DeviceSdkError::MissingDeviceId)
81            }
82        }
83    }
84
85    async fn credentials_secret(
86        &self,
87        device_id: &str,
88        store_directory: impl AsRef<Path>,
89    ) -> Result<Credential, DeviceSdkError> {
90        let cred = self.credentials_secret.as_ref().filter(|id| !id.is_empty());
91
92        if let Some(secret) = cred {
93            return Ok(Credential::secret(secret));
94        }
95
96        let registry = FileStateRepository::new(
97            store_directory.as_ref(),
98            format!("credentials_{device_id}.json"),
99        );
100
101        if StateRepository::<String>::exists(&registry).await {
102            return registry
103                .read()
104                .await
105                .map(Credential::secret)
106                .map_err(DeviceSdkError::ReadSecret);
107        }
108
109        if let Some(token) = &self.pairing_token {
110            return Ok(Credential::paring_token(token));
111        }
112
113        Err(DeviceSdkError::MissingCredentialSecret)
114    }
115
116    pub async fn connect<P>(
117        &self,
118        tasks: &mut JoinSet<stable_eyre::Result<()>>,
119        store: SqliteStore,
120        store_dir: P,
121        interface_dir: P,
122    ) -> Result<DeviceClient<Mqtt<SqliteStore>>, DeviceSdkError>
123    where
124        P: AsRef<Path>,
125    {
126        let device_id = self.device_id_or_from_dbus().await?;
127
128        let credentials_secret = self.credentials_secret(&device_id, &store_dir).await?;
129
130        let mut mqtt_cfg = MqttConfig::new(
131            &self.realm,
132            &device_id,
133            credentials_secret,
134            self.pairing_url.clone(),
135        );
136
137        if self.ignore_ssl {
138            mqtt_cfg.ignore_ssl_errors();
139        }
140
141        let (client, connection) = DeviceBuilder::new()
142            .store(store)
143            .interface_directory(interface_dir)?
144            .writable_dir(store_dir)?
145            .connection(mqtt_cfg)
146            .build()
147            .await
148            .map_err(DeviceSdkError::Connect)?;
149
150        tasks.spawn(async move { connection.handle_events().await.map_err(Into::into) });
151
152        Ok(client)
153    }
154}
155
156#[cfg(all(feature = "zbus", target_os = "linux"))]
157pub async fn hardware_id_from_dbus() -> Result<Option<String>, DeviceSdkError> {
158    let connection = zbus::Connection::system().await?;
159    let proxy = crate::device::DeviceProxy::new(&connection).await?;
160    let hardware_id: String = proxy.get_hardware_id("").await?;
161
162    if hardware_id.is_empty() {
163        return Ok(None);
164    }
165
166    Ok(Some(hardware_id))
167}
168
169#[cfg(test)]
170mod tests {
171    use super::*;
172
173    use tempdir::TempDir;
174
175    #[tokio::test]
176    async fn device_id_test() {
177        let opts = AstarteDeviceSdkConfigOptions {
178            realm: "foo".to_string(),
179            device_id: Some("target".to_string()),
180            credentials_secret: None,
181            pairing_url: Url::parse("http://[::]").unwrap(),
182            pairing_token: None,
183            ignore_ssl: false,
184        };
185
186        let id = opts.device_id_or_from_dbus().await.unwrap();
187        assert_eq!(id, "target");
188    }
189
190    #[tokio::test]
191    async fn credentials_secret_test() {
192        let _dir = TempDir::new("sdk_cred").unwrap();
193        let path = _dir.path().to_owned();
194
195        let options = AstarteDeviceSdkConfigOptions {
196            realm: "".to_string(),
197            device_id: None,
198            credentials_secret: Some("credentials_secret".to_string()),
199            pairing_url: Url::parse("http://[::]").unwrap(),
200            pairing_token: None,
201            ignore_ssl: false,
202        };
203
204        let secret = options.credentials_secret("device_id", path).await.unwrap();
205
206        assert_eq!(secret, Credential::secret("credentials_secret"));
207    }
208
209    #[tokio::test]
210    async fn credentials_pairing_token() {
211        let _dir = TempDir::new("sdk_cred").unwrap();
212        let path = _dir.path().to_owned();
213
214        let options = AstarteDeviceSdkConfigOptions {
215            realm: "".to_string(),
216            device_id: None,
217            credentials_secret: None,
218            pairing_url: Url::parse("http://[::]").unwrap(),
219            pairing_token: Some("pairing_token".to_string()),
220            ignore_ssl: false,
221        };
222
223        let secret = options.credentials_secret("device_id", path).await.unwrap();
224
225        assert_eq!(secret, Credential::paring_token("pairing_token"));
226    }
227
228    #[tokio::test]
229    async fn not_enough_arguments_credentials_secret_test() {
230        let _dir = TempDir::new("sdk_cred").unwrap();
231        let path = _dir.path().to_owned();
232
233        let options = AstarteDeviceSdkConfigOptions {
234            realm: "".to_string(),
235            device_id: None,
236            credentials_secret: None,
237            pairing_url: Url::parse("http://[::]").unwrap(),
238            pairing_token: None,
239            ignore_ssl: false,
240        };
241
242        let res = options.credentials_secret("device_id", &path).await;
243
244        assert!(res.is_err());
245    }
246
247    #[tokio::test]
248    async fn get_credentials_secret_persistence_fail() {
249        let _dir = TempDir::new("sdk_cred").unwrap();
250        let path = _dir.path().to_owned();
251
252        let device_id = "device_id";
253
254        let path = path.join(format!("credentials_{device_id}.json"));
255
256        tokio::fs::write(&path, b"\0").await.unwrap();
257
258        let options = AstarteDeviceSdkConfigOptions {
259            realm: "".to_string(),
260            device_id: Some(device_id.to_owned()),
261            credentials_secret: None,
262            pairing_url: Url::parse("http://[::]").unwrap(),
263            pairing_token: None,
264            ignore_ssl: true,
265        };
266
267        let res = options.credentials_secret(device_id, path).await;
268        assert!(res.is_err());
269    }
270
271    #[tokio::test]
272    async fn get_credentials_secret_persistence_success() {
273        let _dir = TempDir::new("sdk_cred").unwrap();
274        let path = _dir.path().to_owned();
275
276        let device_id = "device_id";
277
278        let full_path = path.join(format!("credentials_{device_id}.json"));
279
280        let exp = "credential_secret";
281
282        tokio::fs::write(&full_path, format!("\"{exp}\""))
283            .await
284            .unwrap();
285
286        let options = AstarteDeviceSdkConfigOptions {
287            realm: "".to_string(),
288            device_id: Some(device_id.to_owned()),
289            credentials_secret: None,
290            pairing_url: Url::parse("http://[::]").unwrap(),
291            pairing_token: None,
292            ignore_ssl: false,
293        };
294
295        let secret = options.credentials_secret(device_id, path).await.unwrap();
296
297        assert_eq!(secret, Credential::secret(exp));
298    }
299}