Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: support SmartREST1.0 for Cumulocity #3196

Merged
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

11 changes: 11 additions & 0 deletions crates/common/certificate/src/parse_root_certificate.rs
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,17 @@ where
.with_no_client_auth())
}

pub fn create_tls_config_without_client_cert(
root_certificates: impl AsRef<Path>,
) -> Result<ClientConfig, CertificateError> {
let root_cert_store = new_root_store(root_certificates.as_ref())?;

Ok(ClientConfig::builder()
.with_safe_defaults()
.with_root_certificates(root_cert_store)
.with_no_client_auth())
}

pub fn add_certs_from_file(
root_store: &mut RootCertStore,
cert_file: impl AsRef<Path>,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
use camino::Utf8Path;
use std::str::FromStr;
use strum_macros::Display;

#[derive(
Debug, Display, Clone, Copy, Eq, PartialEq, doku::Document, serde::Serialize, serde::Deserialize,
)]
#[serde(rename_all = "kebab-case")]
#[strum(serialize_all = "kebab-case")]
pub enum AuthMethod {
Certificate,
Basic,
Auto,
}

#[derive(thiserror::Error, Debug)]
#[error("Failed to parse flag: {input}. Supported values are: 'certificate', 'basic' or 'auto'")]
pub struct InvalidRegistrationMode {
input: String,
}

impl FromStr for AuthMethod {
type Err = InvalidRegistrationMode;

fn from_str(input: &str) -> Result<Self, Self::Err> {
match input {
"certificate" => Ok(AuthMethod::Certificate),
"basic" => Ok(AuthMethod::Basic),
"auto" => Ok(AuthMethod::Auto),
_ => Err(InvalidRegistrationMode {
input: input.to_string(),
}),
}
}
}

pub enum AuthType {
Certificate,
Basic,
}

impl AuthMethod {
pub fn is_basic(self, credentials_path: &Utf8Path) -> bool {
matches!(self.to_type(credentials_path), AuthType::Basic)
}

pub fn is_certificate(self, credentials_path: &Utf8Path) -> bool {
matches!(self.to_type(credentials_path), AuthType::Certificate)
}

pub fn to_type(self, credentials_path: &Utf8Path) -> AuthType {
match self {
AuthMethod::Certificate => AuthType::Certificate,
AuthMethod::Basic => AuthType::Basic,
AuthMethod::Auto if credentials_path.exists() => AuthType::Basic,
AuthMethod::Auto => AuthType::Certificate,
}
}
}
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
pub mod apt_config;
pub mod auth_method;
pub mod auto;
pub mod c8y_software_management;
pub mod connect_url;
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
use super::models::timestamp::TimeFormat;
use crate::auth_method::AuthMethod;
use crate::AptConfig;
use crate::AutoFlag;
use crate::AutoLogUpload;
Expand Down Expand Up @@ -379,7 +380,8 @@ impl_append_remove_for_single_value!(
SecondsOrHumanTime,
u32,
AptConfig,
MqttPayloadLimit
MqttPayloadLimit,
AuthMethod
);

impl AppendRemoveItem for TemplatesSet {
Expand Down Expand Up @@ -470,6 +472,17 @@ define_tedge_config! {
#[doku(as = "PathBuf")]
root_cert_path: Utf8PathBuf,

/// The authentication method used to connect Cumulocity
#[tedge_config(note = "In the auto mode, basic auth is used if c8y.credentials_path is set")]
#[tedge_config(example = "certificate", example = "basic", example = "auto", default(variable = AuthMethod::Certificate))]
rina23q marked this conversation as resolved.
Show resolved Hide resolved
auth_method: AuthMethod,

/// The path where Cumulocity username/password are stored
#[tedge_config(note = "The value must be the path of the credentials file.")]
#[tedge_config(example = "/etc/tedge/credentials", default(value = "/etc/tedge/credentials"))]
#[doku(as = "PathBuf")]
credentials_path: Utf8PathBuf,

smartrest: {
/// Set of SmartREST template IDs the device should subscribe to
#[tedge_config(example = "templateId1,templateId2", default(function = "TemplatesSet::default"))]
Expand All @@ -480,6 +493,11 @@ define_tedge_config! {
use_operation_id: bool,
},

smartrest1: {
/// Set of SmartREST 1.0 template IDs the device should subscribe to
#[tedge_config(example = "templateId1,templateId2", default(function = "TemplatesSet::default"))]
templates: TemplatesSet,
},

/// HTTP Endpoint for the Cumulocity tenant, with optional port.
#[tedge_config(example = "http.your-tenant.cumulocity.com:1234")]
Expand Down
2 changes: 2 additions & 0 deletions crates/core/c8y_api/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ repository = { workspace = true }

[dependencies]
anyhow = { workspace = true }
base64 = { workspace = true }
camino = { workspace = true }
clock = { workspace = true }
csv = { workspace = true }
download = { workspace = true }
Expand Down
151 changes: 119 additions & 32 deletions crates/core/c8y_api/src/http_proxy.rs
Original file line number Diff line number Diff line change
@@ -1,18 +1,25 @@
use crate::smartrest::error::SmartRestDeserializerError;
use crate::smartrest::smartrest_deserializer::SmartRestJwtResponse;
use camino::Utf8Path;
use camino::Utf8PathBuf;
use mqtt_channel::Connection;
use mqtt_channel::PubChannel;
use mqtt_channel::StreamExt;
use mqtt_channel::Topic;
use mqtt_channel::TopicFilter;
use reqwest::header::HeaderMap;
use reqwest::header::HeaderValue;
use reqwest::header::InvalidHeaderValue;
use reqwest::Url;
use std::collections::HashMap;
use std::path::PathBuf;
use std::time::Duration;
use tedge_config::auth_method::AuthType;
use tedge_config::mqtt_config::MqttConfigBuildError;
use tedge_config::MultiError;
use tedge_config::TEdgeConfig;
use tedge_config::TopicPrefix;
use tracing::debug;
use tracing::error;
use tracing::info;

Expand Down Expand Up @@ -131,54 +138,106 @@ impl C8yEndPoint {
}
}

pub struct C8yMqttJwtTokenRetriever {
mqtt_config: mqtt_channel::Config,
topic_prefix: TopicPrefix,
pub enum C8yAuthRetriever {
Basic {
credentials_path: Utf8PathBuf,
},
Jwt {
mqtt_config: Box<mqtt_channel::Config>,
topic_prefix: TopicPrefix,
},
}

/// The credential file representation. e.g.:
/// ```toml
/// [c8y]
/// username = "t1234/octocat"
/// password = "abcd1234"
/// ```
#[derive(Debug, serde::Deserialize)]
struct Credentials {
c8y: BasicCredentials,
}

#[derive(Debug, serde::Deserialize)]
struct BasicCredentials {
username: String,
password: String,
}

#[derive(thiserror::Error, Debug)]
pub enum JwtRetrieverError {
pub enum C8yAuthRetrieverError {
#[error(transparent)]
MqttConfigBuild(#[from] MqttConfigBuildError),

#[error(transparent)]
ConfigMulti(#[from] MultiError),

#[error(transparent)]
JwtError(#[from] JwtError),

#[error(transparent)]
InvalidHeaderValue(#[from] InvalidHeaderValue),

#[error(transparent)]
CredentialsFileError(#[from] CredentialsFileError),
}

impl C8yMqttJwtTokenRetriever {
impl C8yAuthRetriever {
pub fn from_tedge_config(
tedge_config: &TEdgeConfig,
c8y_profile: Option<&str>,
) -> Result<Self, JwtRetrieverError> {
let mqtt_config = tedge_config
.mqtt_config()
.map_err(MqttConfigBuildError::from)?;

Ok(Self::new(
mqtt_config,
tedge_config
.c8y
.try_get(c8y_profile)?
.bridge
.topic_prefix
.clone(),
))
) -> Result<Self, C8yAuthRetrieverError> {
let c8y_config = tedge_config.c8y.try_get(c8y_profile)?;
let topic_prefix = c8y_config.bridge.topic_prefix.clone();

match c8y_config.auth_method.to_type(&c8y_config.credentials_path) {
AuthType::Basic => Ok(Self::Basic {
credentials_path: c8y_config.credentials_path.clone(),
}),
AuthType::Certificate => {
let mqtt_config = tedge_config
.mqtt_config()
.map_err(MqttConfigBuildError::from)?;

let topic = TopicFilter::new_unchecked(&format!("{topic_prefix}/s/dat"));
let mqtt_config = mqtt_config
.with_no_session() // Ignore any already published tokens, possibly stale.
.with_subscriptions(topic);

Ok(Self::Jwt {
mqtt_config: Box::new(mqtt_config),
topic_prefix,
})
}
}
}

pub fn new(mqtt_config: mqtt_channel::Config, topic_prefix: TopicPrefix) -> Self {
let topic = TopicFilter::new_unchecked(&format!("{topic_prefix}/s/dat"));
let mqtt_config = mqtt_config
.with_no_session() // Ignore any already published tokens, possibly stale.
.with_subscriptions(topic);

C8yMqttJwtTokenRetriever {
mqtt_config,
topic_prefix,
}
pub async fn get_auth_header_value(&mut self) -> Result<HeaderValue, C8yAuthRetrieverError> {
let header_value = match &self {
Self::Basic { credentials_path } => {
debug!("Using basic authentication.");
let (username, password) = read_c8y_credentials(credentials_path)?;
rina23q marked this conversation as resolved.
Show resolved Hide resolved
format!("Basic {}", base64::encode(format!("{username}:{password}"))).parse()?
}
Self::Jwt {
mqtt_config,
topic_prefix,
} => {
debug!("Using JWT token bearer authentication.");
let jwt_token = Self::get_jwt_token(mqtt_config, topic_prefix).await?;
format!("Bearer {}", jwt_token.token()).parse()?
}
};
Ok(header_value)
}

pub async fn get_jwt_token(&mut self) -> Result<SmartRestJwtResponse, JwtError> {
let mut mqtt_con = Connection::new(&self.mqtt_config).await?;
let pub_topic = format!("{}/s/uat", self.topic_prefix);
async fn get_jwt_token(
mqtt_config: &mqtt_channel::Config,
topic_prefix: &TopicPrefix,
) -> Result<SmartRestJwtResponse, JwtError> {
let mut mqtt_con = Connection::new(mqtt_config).await?;
let pub_topic = format!("{}/s/uat", topic_prefix);

tokio::time::sleep(Duration::from_millis(20)).await;
for _ in 0..3 {
Expand Down Expand Up @@ -220,6 +279,34 @@ impl C8yMqttJwtTokenRetriever {
}
}

pub fn read_c8y_credentials(
credentials_path: &Utf8Path,
) -> Result<(String, String), CredentialsFileError> {
let contents = std::fs::read_to_string(credentials_path).map_err(|e| {
CredentialsFileError::ReadCredentialsFailed {
context: "Failed to read the basic auth credentials file.".to_string(),
source: e,
}
})?;
let credentials: Credentials = toml::from_str(&contents)
.map_err(|e| CredentialsFileError::TomlError(credentials_path.into(), e))?;
let BasicCredentials { username, password } = credentials.c8y;

Ok((username, password))
}

#[derive(thiserror::Error, Debug)]
pub enum CredentialsFileError {
#[error("{context}: {source}")]
ReadCredentialsFailed {
context: String,
source: std::io::Error,
},

#[error("Error while parsing credentials file: '{0}': {1}.")]
TomlError(PathBuf, #[source] toml::de::Error),
}

#[derive(thiserror::Error, Debug)]
pub enum JwtError {
#[error(transparent)]
Expand Down
1 change: 1 addition & 0 deletions crates/core/tedge/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ anyhow = { workspace = true }
base64 = { workspace = true }
c8y-firmware-plugin = { workspace = true }
c8y-remote-access-plugin = { workspace = true }
c8y_api = { workspace = true }
camino = { workspace = true }
cap = { workspace = true }
certificate = { workspace = true, features = ["reqwest-blocking"] }
Expand Down
3 changes: 3 additions & 0 deletions crates/core/tedge/src/bridge/aws.rs
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ impl From<BridgeConfigAwsParams> for BridgeConfig {
connection: "edge_to_aws".into(),
address: mqtt_host,
remote_username: Some(user_name),
remote_password: None,
bridge_root_cert_path,
remote_clientid,
local_clientid: "Aws".into(),
Expand Down Expand Up @@ -110,6 +111,7 @@ fn test_bridge_config_from_aws_params() -> anyhow::Result<()> {
connection: "edge_to_aws".into(),
address: HostPort::<MQTT_TLS_PORT>::try_from("test.test.io")?,
remote_username: Some("alpha".into()),
remote_password: None,
bridge_root_cert_path: Utf8PathBuf::from("./test_root.pem"),
remote_clientid: "alpha".into(),
local_clientid: "Aws".into(),
Expand Down Expand Up @@ -163,6 +165,7 @@ fn test_bridge_config_aws_custom_topic_prefix() -> anyhow::Result<()> {
connection: "edge_to_aws".into(),
address: HostPort::<MQTT_TLS_PORT>::try_from("test.test.io")?,
remote_username: Some("alpha".into()),
remote_password: None,
bridge_root_cert_path: Utf8PathBuf::from("./test_root.pem"),
remote_clientid: "alpha".into(),
local_clientid: "Aws".into(),
Expand Down
Loading