Skip to content

Commit c38dbd8

Browse files
committed
feat(clusters): load CA certificate data
Accept PEM or base64-encoded PEM CA data for token-based cluster entries, encode PEM data into kubeconfig certificate-authority-data, and reject CA data when TLS verification is skipped. Add a CA certificate file picker to the token cluster dialog and cover the new validation and encoding behavior with kubeconfig tests.
1 parent 20f7fbc commit c38dbd8

8 files changed

Lines changed: 183 additions & 1 deletion

File tree

Cargo.lock

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ authors = ["Leandro Rodrigues <leandromqrs@hotmail.com>"]
1414

1515
[workspace.dependencies]
1616
anyhow = "1"
17+
base64 = "0.22"
1718
dirs = "6"
1819
futures = "0.3"
1920
http = "1"

crates/aetheris-app/src/app.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -303,7 +303,9 @@ pub enum AppMsg {
303303
PodPortForwardEvent(u64, PodPortForwardEvent),
304304
PodPortForwardFinished(u64, Result<(), String>),
305305
ShowAddClusterDialog,
306+
ShowCaFile,
306307
ShowTokenForm,
308+
CaFileLoaded(Result<String, String>),
307309
ShowImportFile,
308310
Refresh,
309311
ObjectsLoaded(Result<Vec<ObjectSummary>, String>),

crates/aetheris-app/src/app/component.rs

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -498,9 +498,12 @@ impl Component for App {
498498
.hexpand(true)
499499
.build();
500500
let setup_ca_entry = adw::EntryRow::builder()
501-
.title(tr("CA Data"))
501+
.title(tr("CA Certificate"))
502502
.hexpand(true)
503503
.build();
504+
let setup_ca_file_button = gtk::Button::builder()
505+
.icon_name("document-open-symbolic")
506+
.build();
504507
let setup_insecure_check = adw::SwitchRow::builder()
505508
.title(tr("Skip TLS Verification"))
506509
.build();
@@ -541,6 +544,7 @@ impl Component for App {
541544
server_entry: &setup_server_entry,
542545
token_entry: &setup_token_entry,
543546
ca_entry: &setup_ca_entry,
547+
ca_file_button: &setup_ca_file_button,
544548
insecure_check: &setup_insecure_check,
545549
add_button: &setup_button,
546550
title_label: &cluster_token_title_label,
@@ -861,6 +865,10 @@ impl Component for App {
861865
let sender = sender.clone();
862866
move |_| sender.input(AppMsg::AddCluster)
863867
});
868+
setup_ca_file_button.connect_clicked({
869+
let sender = sender.clone();
870+
move |_| sender.input(AppMsg::ShowCaFile)
871+
});
864872

865873
let model = App {
866874
projects: ProjectStore::default(),

crates/aetheris-app/src/app/dialogs.rs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -162,6 +162,7 @@ pub(super) struct ClusterDialogWidgets<'a> {
162162
pub(super) server_entry: &'a adw::EntryRow,
163163
pub(super) token_entry: &'a adw::PasswordEntryRow,
164164
pub(super) ca_entry: &'a adw::EntryRow,
165+
pub(super) ca_file_button: &'a gtk::Button,
165166
pub(super) insecure_check: &'a adw::SwitchRow,
166167
pub(super) add_button: &'a gtk::Button,
167168
pub(super) title_label: &'a gtk::Label,
@@ -245,6 +246,12 @@ pub(super) fn token_cluster_page(
245246
heading.append(title);
246247
container.append(&heading);
247248

249+
widgets.ca_file_button.add_css_class("flat");
250+
widgets
251+
.ca_file_button
252+
.set_tooltip_text(Some(&tr("Choose CA certificate file")));
253+
widgets.ca_entry.add_suffix(widgets.ca_file_button);
254+
248255
container.append(&entry_list(&[
249256
widgets.name_entry.upcast_ref(),
250257
widgets.server_entry.upcast_ref(),

crates/aetheris-app/src/app/handler.rs

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1065,6 +1065,26 @@ impl App {
10651065
self.set_cluster_dialog_editing(false);
10661066
self.cluster_dialog_stack.set_visible_child_name("token");
10671067
}
1068+
AppMsg::ShowCaFile => {
1069+
let dialog = gtk::FileDialog::builder()
1070+
.title(tr("Choose CA Certificate"))
1071+
.accept_label(tr("Choose"))
1072+
.modal(true)
1073+
.build();
1074+
let sender = sender.clone();
1075+
dialog.open(
1076+
Some(root),
1077+
gtk::gio::Cancellable::NONE,
1078+
move |result| match result {
1079+
Ok(file) => sender.input(read_ca_file(file)),
1080+
Err(error) => {
1081+
if !error.matches(gtk::gio::IOErrorEnum::Cancelled) {
1082+
sender.input(AppMsg::Toast(error.to_string()));
1083+
}
1084+
}
1085+
},
1086+
);
1087+
}
10681088
AppMsg::ShowImportFile => {
10691089
let dialog = gtk::FileDialog::builder()
10701090
.title(tr("Import Kubeconfig"))
@@ -1093,6 +1113,13 @@ impl App {
10931113
},
10941114
);
10951115
}
1116+
AppMsg::CaFileLoaded(Ok(data)) => {
1117+
self.setup_ca_entry.set_text(data.trim());
1118+
self.setup_insecure_check.set_active(false);
1119+
}
1120+
AppMsg::CaFileLoaded(Err(error)) => {
1121+
self.toaster.add_toast(adw::Toast::new(&error));
1122+
}
10961123
AppMsg::Refresh => {
10971124
if self.resources.is_empty() {
10981125
self.load_cluster(sender);
@@ -1252,3 +1279,22 @@ impl App {
12521279
}
12531280
}
12541281
}
1282+
1283+
fn read_ca_file(file: gtk::gio::File) -> AppMsg {
1284+
let Some(path) = file.path() else {
1285+
return AppMsg::Toast(tr(
1286+
"Selected file is not available on the local filesystem.",
1287+
));
1288+
};
1289+
1290+
let result = fs::read_to_string(&path).map_err(|error| {
1291+
tr_format(
1292+
"Unable to read {path}: {error}",
1293+
&[
1294+
("{path}", path.display().to_string()),
1295+
("{error}", error.to_string()),
1296+
],
1297+
)
1298+
});
1299+
AppMsg::CaFileLoaded(result)
1300+
}

crates/aetheris-kube/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ authors.workspace = true
88

99
[dependencies]
1010
anyhow.workspace = true
11+
base64.workspace = true
1112
dirs.workspace = true
1213
futures.workspace = true
1314
http.workspace = true

crates/aetheris-kube/src/kubeconfig.rs

Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ use std::fs;
33
use std::path::PathBuf;
44

55
use anyhow::{Context as AnyhowContext, Result, bail};
6+
use base64::prelude::*;
67
use kube::config::{
78
AuthInfo, Cluster, Context as KubeContext, Kubeconfig, NamedAuthInfo, NamedCluster,
89
NamedContext,
@@ -183,10 +184,39 @@ fn normalize_add_cluster_request(mut request: AddClusterRequest) -> Result<AddCl
183184
if !(request.server.starts_with("https://") || request.server.starts_with("http://")) {
184185
bail!("API server URL must start with http:// or https://");
185186
}
187+
if request.insecure_skip_tls_verify && request.certificate_authority_data.is_some() {
188+
bail!("CA data cannot be used when TLS verification is skipped");
189+
}
190+
request.certificate_authority_data = request
191+
.certificate_authority_data
192+
.map(normalize_certificate_authority_data)
193+
.transpose()?;
186194

187195
Ok(request)
188196
}
189197

198+
fn normalize_certificate_authority_data(value: String) -> Result<String> {
199+
if looks_like_pem_certificate(&value) {
200+
return Ok(BASE64_STANDARD.encode(value.as_bytes()));
201+
}
202+
203+
let compact = value.split_whitespace().collect::<String>();
204+
let decoded = BASE64_STANDARD
205+
.decode(compact.as_bytes())
206+
.context("CA data must be a PEM certificate or base64-encoded PEM certificate")?;
207+
let decoded = std::str::from_utf8(&decoded)
208+
.context("CA data must decode to a PEM certificate, not raw DER bytes")?;
209+
if !looks_like_pem_certificate(decoded) {
210+
bail!("CA data must decode to a PEM certificate");
211+
}
212+
213+
Ok(compact)
214+
}
215+
216+
fn looks_like_pem_certificate(value: &str) -> bool {
217+
value.contains("-----BEGIN CERTIFICATE-----") && value.contains("-----END CERTIFICATE-----")
218+
}
219+
190220
fn kubeconfig_write_path() -> Result<PathBuf> {
191221
if let Some(value) = std::env::var_os("KUBECONFIG") {
192222
let paths = std::env::split_paths(&value)
@@ -271,6 +301,7 @@ pub(crate) fn server_host(server: &str) -> String {
271301
mod tests {
272302
use std::sync::Mutex;
273303

304+
use base64::prelude::*;
274305
use kube::config::{
275306
AuthInfo, Cluster, Context as KubeContext, Kubeconfig, NamedAuthInfo, NamedCluster,
276307
NamedContext,
@@ -340,6 +371,75 @@ mod tests {
340371
let _ = std::fs::remove_file(&path);
341372
}
342373

374+
#[test]
375+
fn add_token_cluster_rejects_ca_when_tls_verification_is_skipped() {
376+
let _guard = KUBECONFIG_ENV_LOCK.lock().unwrap();
377+
let path = test_kubeconfig_path("aetheris-kube-test-insecure-ca");
378+
unsafe {
379+
std::env::set_var("KUBECONFIG", &path);
380+
}
381+
382+
let request = AddClusterRequest {
383+
context_name: String::from("invalid-tls"),
384+
server: String::from("https://api.example.com:6443"),
385+
bearer_token: String::from("sha256~token"),
386+
certificate_authority_data: Some(test_ca_pem()),
387+
insecure_skip_tls_verify: true,
388+
original_context_name: None,
389+
};
390+
let error = KubeManager::add_token_cluster(request).expect_err("request must be rejected");
391+
392+
assert!(
393+
error
394+
.to_string()
395+
.contains("CA data cannot be used when TLS verification is skipped")
396+
);
397+
398+
unsafe {
399+
std::env::remove_var("KUBECONFIG");
400+
}
401+
let _ = std::fs::remove_file(&path);
402+
}
403+
404+
#[test]
405+
fn add_token_cluster_encodes_pem_ca_data() {
406+
let _guard = KUBECONFIG_ENV_LOCK.lock().unwrap();
407+
let path = test_kubeconfig_path("aetheris-kube-test-ca-pem");
408+
unsafe {
409+
std::env::set_var("KUBECONFIG", &path);
410+
}
411+
412+
let ca = test_ca_pem();
413+
let request = AddClusterRequest {
414+
context_name: String::from("pem-ca"),
415+
server: String::from("https://api.example.com:6443"),
416+
bearer_token: String::from("sha256~token"),
417+
certificate_authority_data: Some(ca.clone()),
418+
insecure_skip_tls_verify: false,
419+
original_context_name: None,
420+
};
421+
KubeManager::add_token_cluster(request).expect("PEM CA should be accepted");
422+
423+
let kubeconfig = Kubeconfig::read_from(&path).expect("kubeconfig should be readable");
424+
let stored_ca = kubeconfig
425+
.clusters
426+
.iter()
427+
.find(|cluster| cluster.name == "pem-ca")
428+
.and_then(|cluster| cluster.cluster.as_ref())
429+
.and_then(|cluster| cluster.certificate_authority_data.as_ref())
430+
.expect("CA data should be stored");
431+
let decoded = BASE64_STANDARD
432+
.decode(stored_ca)
433+
.expect("stored CA should be valid base64");
434+
435+
assert_eq!(decoded, ca.trim().as_bytes());
436+
437+
unsafe {
438+
std::env::remove_var("KUBECONFIG");
439+
}
440+
let _ = std::fs::remove_file(&path);
441+
}
442+
343443
#[test]
344444
fn add_token_cluster_reuses_token_for_non_conventional_user_name() {
345445
let _guard = KUBECONFIG_ENV_LOCK.lock().unwrap();
@@ -641,4 +741,20 @@ mod tests {
641741
.as_nanos()
642742
))
643743
}
744+
745+
fn test_ca_pem() -> String {
746+
String::from(
747+
"-----BEGIN CERTIFICATE-----\n\
748+
MIIBszCCAVmgAwIBAgIUeH9mTSZQm2u9uU2xK4w6cmzNmjcwCgYIKoZIzj0EAwIw\n\
749+
FzEVMBMGA1UEAwwMYWV0aGVyaXMtdGVzdDAeFw0yNjAxMDEwMDAwMDBaFw0yNzAx\n\
750+
MDEwMDAwMDBaMBcxFTATBgNVBAMMDGFldGhlcmlzLXRlc3QwWTATBgcqhkjOPQIB\n\
751+
BggqhkjOPQMBBwNCAAQtsnR+S4p0lDNoe0JvLqR0ZGFxiiq7mU0u5KFLMZzU2l+O\n\
752+
fZgLr9LkqJYTVX9BYRZL2uY5pKiBmqnH6r8jo1MwUTAdBgNVHQ4EFgQUX0T5hZlP\n\
753+
Q0GYovLUG6CmH0iP2JwwHwYDVR0jBBgwFoAUX0T5hZlPQ0GYovLUG6CmH0iP2Jww\n\
754+
DwYDVR0TAQH/BAUwAwEB/zAKBggqhkjOPQQDAgNIADBFAiEA7Lc8EX0GkL+5TMWX\n\
755+
B4JrLuN94ZG6wGB5SYrYd7Lj5P8CIBtMn+5Y0rQkgC0yQZdTx95k7iU01AOhKy4s\n\
756+
Qd6UpELK\n\
757+
-----END CERTIFICATE-----\n",
758+
)
759+
}
644760
}

0 commit comments

Comments
 (0)