Skip to content

Commit

Permalink
Merge pull request #3196 from rina23q/feature/3036/support-basic-auth…
Browse files Browse the repository at this point in the history
…-for-c8y

feat: support SmartREST1.0 for Cumulocity
  • Loading branch information
rina23q authored Oct 30, 2024
2 parents 1a27e54 + c078d4b commit af7e9ce
Show file tree
Hide file tree
Showing 27 changed files with 815 additions and 174 deletions.
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
20 changes: 19 additions & 1 deletion crates/common/tedge_config/src/tedge_config_cli/tedge_config.rs
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))]
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)?;
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

0 comments on commit af7e9ce

Please sign in to comment.