diff --git a/Cargo.lock b/Cargo.lock index 7794e8217c4..fad2044cdfa 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3834,6 +3834,7 @@ dependencies = [ "pad", "pem 1.1.1", "predicates 2.1.5", + "rcgen", "reqwest", "rpassword", "rumqttc", diff --git a/crates/core/tedge/Cargo.toml b/crates/core/tedge/Cargo.toml index e21edde3a33..947a8086289 100644 --- a/crates/core/tedge/Cargo.toml +++ b/crates/core/tedge/Cargo.toml @@ -62,6 +62,7 @@ mockito = { workspace = true } mqtt_tests = { workspace = true } pem = { workspace = true } predicates = { workspace = true } +rcgen = { workspace = true } tedge_test_utils = { workspace = true } tempfile = { workspace = true } test-case = { workspace = true } diff --git a/crates/core/tedge/src/cli/common.rs b/crates/core/tedge/src/cli/common.rs index 45442c24339..842a826bc64 100644 --- a/crates/core/tedge/src/cli/common.rs +++ b/crates/core/tedge/src/cli/common.rs @@ -9,18 +9,21 @@ use tedge_config::ProfileName; pub enum CloudArg { C8y { /// The cloud profile you wish to use + /// /// [env: TEDGE_CLOUD_PROFILE] #[clap(long)] profile: Option, }, Az { /// The cloud profile you wish to use + /// /// [env: TEDGE_CLOUD_PROFILE] #[clap(long)] profile: Option, }, Aws { /// The cloud profile you wish to use + /// /// [env: TEDGE_CLOUD_PROFILE] #[clap(long)] profile: Option, @@ -148,4 +151,10 @@ impl MaybeBorrowedCloud<'_> { Self::Azure(Some(profile)) => format!("az@{profile}-bridge.conf").into(), } } + + pub fn profile_name(&self) -> Option<&ProfileName> { + match self { + Self::Aws(profile) | Self::Azure(profile) | Self::C8y(profile) => profile.as_deref(), + } + } } diff --git a/crates/core/tedge/src/cli/config/cli.rs b/crates/core/tedge/src/cli/config/cli.rs index 50024a7a0f5..cfe63e51562 100644 --- a/crates/core/tedge/src/cli/config/cli.rs +++ b/crates/core/tedge/src/cli/config/cli.rs @@ -12,7 +12,11 @@ pub enum ConfigCmd { /// Configuration key. Run `tedge config list --doc` for available keys key: ReadableKey, - /// Cloud profile + /// The cloud profile you wish to use, if accessing a cloud configuration + /// (i.e. `c8y.*`, `az.*` or `aws.*`). If you don't wish to use cloud profiles, + /// or want to access the default profile, don't supply this. + /// + /// [env: TEDGE_CLOUD_PROFILE] #[clap(long)] profile: Option, }, @@ -25,7 +29,11 @@ pub enum ConfigCmd { /// Configuration value. value: String, - /// Cloud profile + /// The cloud profile you wish to use, if accessing a cloud configuration + /// (i.e. `c8y.*`, `az.*` or `aws.*`). If you don't wish to use cloud profiles, + /// or want to access the default profile, don't supply this. + /// + /// [env: TEDGE_CLOUD_PROFILE] #[clap(long)] profile: Option, }, @@ -35,7 +43,11 @@ pub enum ConfigCmd { /// Configuration key. Run `tedge config list --doc` for available keys key: WritableKey, - /// Cloud profile + /// The cloud profile you wish to use, if accessing a cloud configuration + /// (i.e. `c8y.*`, `az.*` or `aws.*`). If you don't wish to use cloud profiles, + /// or want to access the default profile, don't supply this. + /// + /// [env: TEDGE_CLOUD_PROFILE] #[clap(long)] profile: Option, }, @@ -48,7 +60,11 @@ pub enum ConfigCmd { /// Configuration value. value: String, - /// Cloud profile + /// The cloud profile you wish to use, if accessing a cloud configuration + /// (i.e. `c8y.*`, `az.*` or `aws.*`). If you don't wish to use cloud profiles, + /// or want to access the default profile, don't supply this. + /// + /// [env: TEDGE_CLOUD_PROFILE] #[clap(long)] profile: Option, }, @@ -61,8 +77,12 @@ pub enum ConfigCmd { /// Configuration value. value: String, + /// The cloud profile you wish to use, if accessing a cloud configuration + /// (i.e. `c8y.*`, `az.*` or `aws.*`). If you don't wish to use cloud profiles, + /// or want to access the default profile, don't supply this. + /// + /// [env: TEDGE_CLOUD_PROFILE] #[clap(long)] - /// Cloud profile profile: Option, }, diff --git a/crates/core/tedge/src/cli/connect/command.rs b/crates/core/tedge/src/cli/connect/command.rs index 36661e23ffc..47fb3feb262 100644 --- a/crates/core/tedge/src/cli/connect/command.rs +++ b/crates/core/tedge/src/cli/connect/command.rs @@ -322,6 +322,49 @@ fn validate_config(config: &TEdgeConfig, cloud: &MaybeBorrowedCloud<'_>) -> anyh Ok(()) } +fn disallow_matching_url_device_id( + config: &TEdgeConfig, + url: fn(Option) -> ReadableKey, + device_id: fn(Option) -> ReadableKey, + profiles: &[Option], +) -> anyhow::Result<()> { + let url_entries = profiles.into_iter().filter_map(|profile| { + let key = url(profile.clone()); + let value = config.read_string(&key).ok(); + Some(((profile, key), value)) + }); + + for url_matches in find_all_matching(url_entries) { + let device_id_entries = profiles.into_iter().filter_map(|profile| { + url_matches.iter().find(|(p, _)| *p == profile)?; + let key = device_id(profile.clone()); + let value = config.read_string(&key).ok(); + Some(((profile, key), value)) + }); + if let Some(matches) = find_matching(device_id_entries) { + let url_keys: String = matches + .iter() + .map(|&(k, _)| format!("{}", url(k.clone()).yellow().bold())) + .collect::>() + .join(", "); + let device_id_keys: String = matches + .iter() + .map(|(_, key)| format!("{}", key.yellow().bold())) + .collect::>() + .join(", "); + bail!( + "You have matching URLs and device IDs for different profiles. + +{url_keys} are set to the same value, but so are {device_id_keys}. + +Each cloud profile requires either a unique URL or unique device ID, \ +so it corresponds to a unique device in the associated cloud." + ); + } + } + Ok(()) +} + fn disallow_matching_configurations( config: &TEdgeConfig, configuration: fn(Option) -> ReadableKey, @@ -337,7 +380,7 @@ fn disallow_matching_configurations( Some((key, value)) }); if let Some(matches) = find_matching(entries) { - let keys = matches + let keys: String = matches .iter() .map(|k| format!("{}", k.yellow().bold())) .collect::>() @@ -357,6 +400,15 @@ fn find_matching(entries: impl Iterator) -> Opti match_map.into_values().find(|t| t.len() > 1) } +fn find_all_matching(entries: impl Iterator) -> Vec> { + let match_map = entries.fold(HashMap::>::new(), |mut acc, (key, value)| { + acc.entry(value).or_default().push(key); + acc + }); + + match_map.into_values().filter(|t| t.len() > 1).collect() +} + pub fn bridge_config( config: &TEdgeConfig, cloud: &MaybeBorrowedCloud<'_>, @@ -1208,6 +1260,160 @@ mod tests { validate_config(&config, &cloud).unwrap(); } + #[test] + fn disallows_matching_device_id_same_urls() { + yansi::disable(); + let cloud = Cloud::c8y(Some("new".parse().unwrap())); + let ttd = TempTedgeDir::new(); + let loc = TEdgeConfigLocation::from_custom_root(ttd.path()); + loc.update_toml(&|dto, _| { + dto.try_update_str(&"c8y.url".parse().unwrap(), "example.com") + .unwrap(); + dto.try_update_str(&"c8y.profiles.new.url".parse().unwrap(), "example.com") + .unwrap(); + Ok(()) + }) + .unwrap(); + let config = loc.load().unwrap(); + + let err = validate_config(&config, &cloud).unwrap_err(); + assert_eq!(err.to_string(), "You have matching URLs and device IDs for different profiles. + +c8y.url, c8y.profiles.new.url are set to the same value, but so are c8y.device.id, c8y.profiles.new.device.id. + +Each cloud profile requires either a unique URL or unique device ID, so it corresponds to a unique device in the associated cloud.") + } + + #[test] + fn allows_different_urls() { + let cloud = Cloud::c8y(Some("new".parse().unwrap())); + let ttd = TempTedgeDir::new(); + let loc = TEdgeConfigLocation::from_custom_root(ttd.path()); + loc.update_toml(&|dto, _| { + dto.try_update_str(&"c8y.url".parse().unwrap(), "example.com") + .unwrap(); + dto.try_update_str( + &"c8y.profiles.new.url".parse().unwrap(), + "different.example.com", + ) + .unwrap(); + dto.try_update_str( + &"c8y.profiles.new.bridge.topic_prefix".parse().unwrap(), + "c8y-new", + ) + .unwrap(); + dto.try_update_str(&"c8y.profiles.new.proxy.bind.port".parse().unwrap(), "8002") + .unwrap(); + Ok(()) + }) + .unwrap(); + let config = loc.load().unwrap(); + + validate_config(&config, &cloud).unwrap(); + } + + #[test] + fn allows_different_device_ids() { + let cloud = Cloud::c8y(Some("new".parse().unwrap())); + let ttd = TempTedgeDir::new(); + let cert = rcgen::generate_simple_self_signed(["test-device".into()]).unwrap(); + let mut cert_path = ttd.path().to_owned(); + cert_path.push("test.crt"); + let mut key_path = ttd.path().to_owned(); + key_path.push("test.key"); + std::fs::write(&cert_path, cert.serialize_pem().unwrap()).unwrap(); + std::fs::write(&key_path, cert.serialize_private_key_pem()).unwrap(); + let loc = TEdgeConfigLocation::from_custom_root(ttd.path()); + loc.update_toml(&|dto, _| { + dto.try_update_str(&"c8y.url".parse().unwrap(), "example.com") + .unwrap(); + dto.try_update_str(&"c8y.profiles.new.url".parse().unwrap(), "example.com") + .unwrap(); + dto.try_update_str( + &"c8y.profiles.new.device.cert_path".parse().unwrap(), + &cert_path.display().to_string(), + ) + .unwrap(); + dto.try_update_str( + &"c8y.profiles.new.device.key_path".parse().unwrap(), + &key_path.display().to_string(), + ) + .unwrap(); + dto.try_update_str( + &"c8y.profiles.new.bridge.topic_prefix".parse().unwrap(), + "c8y-new", + ) + .unwrap(); + dto.try_update_str(&"c8y.profiles.new.proxy.bind.port".parse().unwrap(), "8002") + .unwrap(); + Ok(()) + }) + .unwrap(); + let config = loc.load().unwrap(); + + validate_config(&config, &cloud).unwrap(); + } + + #[test] + fn allows_combination_of_urls_and_device_ids() { + let cloud = Cloud::c8y(Some("new".parse().unwrap())); + let ttd = TempTedgeDir::new(); + let cert = rcgen::generate_simple_self_signed(["test-device".into()]).unwrap(); + let mut cert_path = ttd.path().to_owned(); + cert_path.push("test.crt"); + let mut key_path = ttd.path().to_owned(); + key_path.push("test.key"); + std::fs::write(&cert_path, cert.serialize_pem().unwrap()).unwrap(); + std::fs::write(&key_path, cert.serialize_private_key_pem()).unwrap(); + let loc = TEdgeConfigLocation::from_custom_root(ttd.path()); + loc.update_toml(&|dto, _| { + dto.try_update_str(&"c8y.url".parse().unwrap(), "example.com") + .unwrap(); + dto.try_update_str(&"c8y.profiles.diff_id.url".parse().unwrap(), "example.com") + .unwrap(); + dto.try_update_str( + &"c8y.profiles.diff_id.device.cert_path".parse().unwrap(), + &cert_path.display().to_string(), + ) + .unwrap(); + dto.try_update_str( + &"c8y.profiles.diff_id.device.key_path".parse().unwrap(), + &key_path.display().to_string(), + ) + .unwrap(); + dto.try_update_str( + &"c8y.profiles.diff_id.bridge.topic_prefix".parse().unwrap(), + "c8y-diff-id", + ) + .unwrap(); + dto.try_update_str( + &"c8y.profiles.diff_id.proxy.bind.port".parse().unwrap(), + "8002", + ) + .unwrap(); + dto.try_update_str( + &"c8y.profiles.diff_url.url".parse().unwrap(), + "different.example.com", + ) + .unwrap(); + dto.try_update_str( + &"c8y.profiles.diff_url.bridge.topic_prefix".parse().unwrap(), + "c8y-diff-url", + ) + .unwrap(); + dto.try_update_str( + &"c8y.profiles.diff_url.proxy.bind.port".parse().unwrap(), + "8003", + ) + .unwrap(); + Ok(()) + }) + .unwrap(); + let config = loc.load().unwrap(); + + validate_config(&config, &cloud).unwrap(); + } + #[test] fn allows_single_named_az_profile_without_default_profile() { let cloud = Cloud::az(Some("new".parse().unwrap()));