diff --git a/Cargo.lock b/Cargo.lock index 562305b..c3244c0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -28,6 +28,7 @@ name = "aetheris-kube" version = "1.3.0" dependencies = [ "anyhow", + "base64", "dirs", "futures", "http", diff --git a/Cargo.toml b/Cargo.toml index 642a9d5..2b7c28a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -14,6 +14,7 @@ authors = ["Leandro Rodrigues "] [workspace.dependencies] anyhow = "1" +base64 = "0.22" dirs = "6" futures = "0.3" http = "1" diff --git a/crates/aetheris-app/src/app.rs b/crates/aetheris-app/src/app.rs index 35ca0b5..fd3be38 100644 --- a/crates/aetheris-app/src/app.rs +++ b/crates/aetheris-app/src/app.rs @@ -303,7 +303,9 @@ pub enum AppMsg { PodPortForwardEvent(u64, PodPortForwardEvent), PodPortForwardFinished(u64, Result<(), String>), ShowAddClusterDialog, + ShowCaFile, ShowTokenForm, + CaFileLoaded(Result), ShowImportFile, Refresh, ObjectsLoaded(Result, String>), diff --git a/crates/aetheris-app/src/app/component.rs b/crates/aetheris-app/src/app/component.rs index 88df98f..71123a8 100644 --- a/crates/aetheris-app/src/app/component.rs +++ b/crates/aetheris-app/src/app/component.rs @@ -498,9 +498,12 @@ impl Component for App { .hexpand(true) .build(); let setup_ca_entry = adw::EntryRow::builder() - .title(tr("CA Data")) + .title(tr("CA Certificate")) .hexpand(true) .build(); + let setup_ca_file_button = gtk::Button::builder() + .icon_name("document-open-symbolic") + .build(); let setup_insecure_check = adw::SwitchRow::builder() .title(tr("Skip TLS Verification")) .build(); @@ -541,6 +544,7 @@ impl Component for App { server_entry: &setup_server_entry, token_entry: &setup_token_entry, ca_entry: &setup_ca_entry, + ca_file_button: &setup_ca_file_button, insecure_check: &setup_insecure_check, add_button: &setup_button, title_label: &cluster_token_title_label, @@ -861,6 +865,10 @@ impl Component for App { let sender = sender.clone(); move |_| sender.input(AppMsg::AddCluster) }); + setup_ca_file_button.connect_clicked({ + let sender = sender.clone(); + move |_| sender.input(AppMsg::ShowCaFile) + }); let model = App { projects: ProjectStore::default(), diff --git a/crates/aetheris-app/src/app/dialogs.rs b/crates/aetheris-app/src/app/dialogs.rs index aa9f698..62cb7bd 100644 --- a/crates/aetheris-app/src/app/dialogs.rs +++ b/crates/aetheris-app/src/app/dialogs.rs @@ -162,6 +162,7 @@ pub(super) struct ClusterDialogWidgets<'a> { pub(super) server_entry: &'a adw::EntryRow, pub(super) token_entry: &'a adw::PasswordEntryRow, pub(super) ca_entry: &'a adw::EntryRow, + pub(super) ca_file_button: &'a gtk::Button, pub(super) insecure_check: &'a adw::SwitchRow, pub(super) add_button: &'a gtk::Button, pub(super) title_label: &'a gtk::Label, @@ -245,6 +246,12 @@ pub(super) fn token_cluster_page( heading.append(title); container.append(&heading); + widgets.ca_file_button.add_css_class("flat"); + widgets + .ca_file_button + .set_tooltip_text(Some(&tr("Choose CA certificate file"))); + widgets.ca_entry.add_suffix(widgets.ca_file_button); + container.append(&entry_list(&[ widgets.name_entry.upcast_ref(), widgets.server_entry.upcast_ref(), diff --git a/crates/aetheris-app/src/app/handler.rs b/crates/aetheris-app/src/app/handler.rs index f592619..bde1f00 100644 --- a/crates/aetheris-app/src/app/handler.rs +++ b/crates/aetheris-app/src/app/handler.rs @@ -1065,6 +1065,26 @@ impl App { self.set_cluster_dialog_editing(false); self.cluster_dialog_stack.set_visible_child_name("token"); } + AppMsg::ShowCaFile => { + let dialog = gtk::FileDialog::builder() + .title(tr("Choose CA Certificate")) + .accept_label(tr("Choose")) + .modal(true) + .build(); + let sender = sender.clone(); + dialog.open( + Some(root), + gtk::gio::Cancellable::NONE, + move |result| match result { + Ok(file) => sender.input(read_ca_file(file)), + Err(error) => { + if !error.matches(gtk::gio::IOErrorEnum::Cancelled) { + sender.input(AppMsg::Toast(error.to_string())); + } + } + }, + ); + } AppMsg::ShowImportFile => { let dialog = gtk::FileDialog::builder() .title(tr("Import Kubeconfig")) @@ -1093,6 +1113,13 @@ impl App { }, ); } + AppMsg::CaFileLoaded(Ok(data)) => { + self.setup_ca_entry.set_text(data.trim()); + self.setup_insecure_check.set_active(false); + } + AppMsg::CaFileLoaded(Err(error)) => { + self.toaster.add_toast(adw::Toast::new(&error)); + } AppMsg::Refresh => { if self.resources.is_empty() { self.load_cluster(sender); @@ -1252,3 +1279,22 @@ impl App { } } } + +fn read_ca_file(file: gtk::gio::File) -> AppMsg { + let Some(path) = file.path() else { + return AppMsg::Toast(tr( + "Selected file is not available on the local filesystem.", + )); + }; + + let result = fs::read_to_string(&path).map_err(|error| { + tr_format( + "Unable to read {path}: {error}", + &[ + ("{path}", path.display().to_string()), + ("{error}", error.to_string()), + ], + ) + }); + AppMsg::CaFileLoaded(result) +} diff --git a/crates/aetheris-kube/Cargo.toml b/crates/aetheris-kube/Cargo.toml index cfdb6c6..cb7e3ed 100644 --- a/crates/aetheris-kube/Cargo.toml +++ b/crates/aetheris-kube/Cargo.toml @@ -8,6 +8,7 @@ authors.workspace = true [dependencies] anyhow.workspace = true +base64.workspace = true dirs.workspace = true futures.workspace = true http.workspace = true diff --git a/crates/aetheris-kube/src/kubeconfig.rs b/crates/aetheris-kube/src/kubeconfig.rs index 05e74a5..10f22a9 100644 --- a/crates/aetheris-kube/src/kubeconfig.rs +++ b/crates/aetheris-kube/src/kubeconfig.rs @@ -3,6 +3,7 @@ use std::fs; use std::path::PathBuf; use anyhow::{Context as AnyhowContext, Result, bail}; +use base64::prelude::*; use kube::config::{ AuthInfo, Cluster, Context as KubeContext, Kubeconfig, NamedAuthInfo, NamedCluster, NamedContext, @@ -183,10 +184,39 @@ fn normalize_add_cluster_request(mut request: AddClusterRequest) -> Result Result { + if looks_like_pem_certificate(&value) { + return Ok(BASE64_STANDARD.encode(value.as_bytes())); + } + + let compact = value.split_whitespace().collect::(); + let decoded = BASE64_STANDARD + .decode(compact.as_bytes()) + .context("CA data must be a PEM certificate or base64-encoded PEM certificate")?; + let decoded = std::str::from_utf8(&decoded) + .context("CA data must decode to a PEM certificate, not raw DER bytes")?; + if !looks_like_pem_certificate(decoded) { + bail!("CA data must decode to a PEM certificate"); + } + + Ok(compact) +} + +fn looks_like_pem_certificate(value: &str) -> bool { + value.contains("-----BEGIN CERTIFICATE-----") && value.contains("-----END CERTIFICATE-----") +} + fn kubeconfig_write_path() -> Result { if let Some(value) = std::env::var_os("KUBECONFIG") { let paths = std::env::split_paths(&value) @@ -271,6 +301,7 @@ pub(crate) fn server_host(server: &str) -> String { mod tests { use std::sync::Mutex; + use base64::prelude::*; use kube::config::{ AuthInfo, Cluster, Context as KubeContext, Kubeconfig, NamedAuthInfo, NamedCluster, NamedContext, @@ -340,6 +371,75 @@ mod tests { let _ = std::fs::remove_file(&path); } + #[test] + fn add_token_cluster_rejects_ca_when_tls_verification_is_skipped() { + let _guard = KUBECONFIG_ENV_LOCK.lock().unwrap(); + let path = test_kubeconfig_path("aetheris-kube-test-insecure-ca"); + unsafe { + std::env::set_var("KUBECONFIG", &path); + } + + let request = AddClusterRequest { + context_name: String::from("invalid-tls"), + server: String::from("https://api.example.com:6443"), + bearer_token: String::from("sha256~token"), + certificate_authority_data: Some(test_ca_pem()), + insecure_skip_tls_verify: true, + original_context_name: None, + }; + let error = KubeManager::add_token_cluster(request).expect_err("request must be rejected"); + + assert!( + error + .to_string() + .contains("CA data cannot be used when TLS verification is skipped") + ); + + unsafe { + std::env::remove_var("KUBECONFIG"); + } + let _ = std::fs::remove_file(&path); + } + + #[test] + fn add_token_cluster_encodes_pem_ca_data() { + let _guard = KUBECONFIG_ENV_LOCK.lock().unwrap(); + let path = test_kubeconfig_path("aetheris-kube-test-ca-pem"); + unsafe { + std::env::set_var("KUBECONFIG", &path); + } + + let ca = test_ca_pem(); + let request = AddClusterRequest { + context_name: String::from("pem-ca"), + server: String::from("https://api.example.com:6443"), + bearer_token: String::from("sha256~token"), + certificate_authority_data: Some(ca.clone()), + insecure_skip_tls_verify: false, + original_context_name: None, + }; + KubeManager::add_token_cluster(request).expect("PEM CA should be accepted"); + + let kubeconfig = Kubeconfig::read_from(&path).expect("kubeconfig should be readable"); + let stored_ca = kubeconfig + .clusters + .iter() + .find(|cluster| cluster.name == "pem-ca") + .and_then(|cluster| cluster.cluster.as_ref()) + .and_then(|cluster| cluster.certificate_authority_data.as_ref()) + .expect("CA data should be stored"); + let decoded = BASE64_STANDARD + .decode(stored_ca) + .expect("stored CA should be valid base64"); + + assert_eq!(decoded, ca.trim().as_bytes()); + + unsafe { + std::env::remove_var("KUBECONFIG"); + } + let _ = std::fs::remove_file(&path); + } + #[test] fn add_token_cluster_reuses_token_for_non_conventional_user_name() { let _guard = KUBECONFIG_ENV_LOCK.lock().unwrap(); @@ -641,4 +741,20 @@ mod tests { .as_nanos() )) } + + fn test_ca_pem() -> String { + String::from( + "-----BEGIN CERTIFICATE-----\n\ + MIIBszCCAVmgAwIBAgIUeH9mTSZQm2u9uU2xK4w6cmzNmjcwCgYIKoZIzj0EAwIw\n\ + FzEVMBMGA1UEAwwMYWV0aGVyaXMtdGVzdDAeFw0yNjAxMDEwMDAwMDBaFw0yNzAx\n\ + MDEwMDAwMDBaMBcxFTATBgNVBAMMDGFldGhlcmlzLXRlc3QwWTATBgcqhkjOPQIB\n\ + BggqhkjOPQMBBwNCAAQtsnR+S4p0lDNoe0JvLqR0ZGFxiiq7mU0u5KFLMZzU2l+O\n\ + fZgLr9LkqJYTVX9BYRZL2uY5pKiBmqnH6r8jo1MwUTAdBgNVHQ4EFgQUX0T5hZlP\n\ + Q0GYovLUG6CmH0iP2JwwHwYDVR0jBBgwFoAUX0T5hZlPQ0GYovLUG6CmH0iP2Jww\n\ + DwYDVR0TAQH/BAUwAwEB/zAKBggqhkjOPQQDAgNIADBFAiEA7Lc8EX0GkL+5TMWX\n\ + B4JrLuN94ZG6wGB5SYrYd7Lj5P8CIBtMn+5Y0rQkgC0yQZdTx95k7iU01AOhKy4s\n\ + Qd6UpELK\n\ + -----END CERTIFICATE-----\n", + ) + } }