Skip to content
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
1 change: 1 addition & 0 deletions Cargo.lock

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

1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ authors = ["Leandro Rodrigues <leandromqrs@hotmail.com>"]

[workspace.dependencies]
anyhow = "1"
base64 = "0.22"
dirs = "6"
futures = "0.3"
http = "1"
Expand Down
2 changes: 2 additions & 0 deletions crates/aetheris-app/src/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -303,7 +303,9 @@ pub enum AppMsg {
PodPortForwardEvent(u64, PodPortForwardEvent),
PodPortForwardFinished(u64, Result<(), String>),
ShowAddClusterDialog,
ShowCaFile,
ShowTokenForm,
CaFileLoaded(Result<String, String>),
ShowImportFile,
Refresh,
ObjectsLoaded(Result<Vec<ObjectSummary>, String>),
Expand Down
10 changes: 9 additions & 1 deletion crates/aetheris-app/src/app/component.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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(),
Expand Down
7 changes: 7 additions & 0 deletions crates/aetheris-app/src/app/dialogs.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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(),
Expand Down
46 changes: 46 additions & 0 deletions crates/aetheris-app/src/app/handler.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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"))
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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)
}
1 change: 1 addition & 0 deletions crates/aetheris-kube/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ authors.workspace = true

[dependencies]
anyhow.workspace = true
base64.workspace = true
dirs.workspace = true
futures.workspace = true
http.workspace = true
Expand Down
116 changes: 116 additions & 0 deletions crates/aetheris-kube/src/kubeconfig.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -183,10 +184,39 @@ fn normalize_add_cluster_request(mut request: AddClusterRequest) -> Result<AddCl
if !(request.server.starts_with("https://") || request.server.starts_with("http://")) {
bail!("API server URL must start with http:// or https://");
}
if request.insecure_skip_tls_verify && request.certificate_authority_data.is_some() {
bail!("CA data cannot be used when TLS verification is skipped");
}
request.certificate_authority_data = request
.certificate_authority_data
.map(normalize_certificate_authority_data)
.transpose()?;

Ok(request)
}

fn normalize_certificate_authority_data(value: String) -> Result<String> {
if looks_like_pem_certificate(&value) {
return Ok(BASE64_STANDARD.encode(value.as_bytes()));
}

let compact = value.split_whitespace().collect::<String>();
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<PathBuf> {
if let Some(value) = std::env::var_os("KUBECONFIG") {
let paths = std::env::split_paths(&value)
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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",
)
}
}
Loading