diff --git a/Cargo.lock b/Cargo.lock index 4667651..077be8d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -450,6 +450,7 @@ dependencies = [ "rsa", "serde", "serde_json", + "serde_yaml 0.9.13", "sha1", "x509-parser", ] @@ -957,7 +958,7 @@ dependencies = [ "secrecy", "serde", "serde_json", - "serde_yaml", + "serde_yaml 0.8.26", "thiserror", "tokio", "tokio-util", @@ -1863,6 +1864,19 @@ dependencies = [ "yaml-rust", ] +[[package]] +name = "serde_yaml" +version = "0.9.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8613d593412a0deb7bbd8de9d908efff5a0cb9ccd8f62c641e7b2ed2f57291d1" +dependencies = [ + "indexmap", + "itoa", + "ryu", + "serde", + "unsafe-libyaml", +] + [[package]] name = "sha1" version = "0.10.2" @@ -2361,6 +2375,12 @@ dependencies = [ "subtle", ] +[[package]] +name = "unsafe-libyaml" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1e5fa573d8ac5f1a856f8d7be41d390ee973daf97c806b2c1a465e4e1406e68" + [[package]] name = "url" version = "2.2.2" diff --git a/Cargo.toml b/Cargo.toml index 56d3a42..f599078 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -15,6 +15,7 @@ rocket = { version = "0.5.0-rc.2", features = ["json"] } rsa = "0.6.1" serde = { version = "1.0", features = ["derive"] } serde_json = "1.0.85" +serde_yaml = "0.9.13" sha1 = "0.10.2" x509-parser = "0.14.0" diff --git a/src/k8s.rs b/src/k8s.rs index 7150b66..b9fd111 100644 --- a/src/k8s.rs +++ b/src/k8s.rs @@ -3,13 +3,15 @@ use std::collections::btree_map::BTreeMap; use chrono::offset::Utc; use chrono::{DateTime, Duration}; -use k8s_openapi::api::core::v1::Secret; +use k8s_openapi::api::core::v1::{ConfigMap, Secret}; use k8s_openapi::apimachinery::pkg::apis::meta::v1::ObjectMeta; use kube::core::params::{ListParams, Patch, PatchParams, PostParams}; use kube::{Api, Client}; -use log::{debug, error, info}; +use log::{debug, error, info, warn}; use rand::seq::SliceRandom; +use crate::model::k8s::*; + /// The set of characters allowed to appear in bootstrap tokens const TOKEN_CHARS: [char; 36] = [ 'a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j', 'k', 'l', 'm', 'n', 'o', @@ -312,9 +314,138 @@ pub async fn create_bootstrap_token>( Ok(()) } +/// Get the `kubeadm join` configuration for the specified EC2 instance +/// +/// This function creates a kubeconfig file that can be passed to `kubeadm +/// join` to add the specified EC2 instance to the Kubernetes cluster as a +/// worker node. The cluster configuration is read from the `cluster-info` +/// ConfigMap in the *kube-public* namespace. The bootstrap token assigned to +/// the instance is included for client authentication. +pub async fn get_kubeconfig>( + instance_id: I, +) -> Result, kube::Error> { + let instance_id = instance_id.as_ref(); + let token = match get_bootstrap_token(&instance_id).await { + Ok(Some(t)) => t, + Ok(None) => { + warn!("No bootstrap token assigned to instance {}", &instance_id); + return Ok(None); + } + Err(e) => { + error!( + "Could not get bootstrap token for instance {}: {}", + &instance_id, e + ); + return Ok(None); + } + }; + match get_cluster_info().await? { + Some(config) => { + let cluster = Cluster { + name: "kubernetes".into(), + cluster: config.clusters[0].cluster.clone(), + }; + let context = Context { + name: "kubeadm".into(), + context: ContextInfo { + cluster: "kubernetes".into(), + user: "kubeadm".into(), + }, + }; + let user = User { + name: "kubeadm".into(), + user: UserInfo { token: token }, + }; + let mut kubeconfig = KubeConfig::default(); + kubeconfig.clusters = vec![cluster]; + kubeconfig.contexts = Some(vec![context]); + kubeconfig.current_context = "kubeadm".into(); + kubeconfig.users = Some(vec![user]); + Ok(Some(kubeconfig)) + } + None => { + warn!("No kubeconfig loaded from cluster-info"); + Ok(None) + } + } +} + +/// Retrieve the bootstrap token assigned to an EC2 instance +async fn get_bootstrap_token>( + instance_id: I, +) -> Result, kube::Error> { + let instance_id = instance_id.as_ref(); + let client = Client::try_default().await?; + let secrets: Api = Api::namespaced(client, "kube-system"); + let lp = ListParams::default() + .fields("type=bootstrap.kubernetes.io/token") + .labels(&format!( + "dynk8s.du5t1n.me/ec2-instance-id={}", + &instance_id + )); + for s in secrets.list(&lp).await? { + match token_string(&s) { + Ok(t) => return Ok(Some(t)), + Err(e) => { + error!("Invalid bootstrap token: {}", e); + } + } + } + Ok(None) +} + +/// Get cluster information from the ConfigMap +async fn get_cluster_info() -> Result, kube::Error> { + let client = Client::try_default().await?; + let configmaps: Api = Api::namespaced(client, "kube-public"); + let cluster_info = configmaps.get("cluster-info").await?; + if let Some(data) = cluster_info.data { + if let Some(config) = data.get("kubeconfig") { + match serde_yaml::from_str::(config) { + Ok(c) => return Ok(Some(c)), + Err(e) => { + error!( + "Could not load kubeconfig from cluster-info: {}", + e + ); + } + }; + } else { + error!("No kubeconfig property found in cluster-info ConfigMap"); + } + } else { + error!("No data property found in cluster-info ConfigMap"); + } + Ok(None) +} + +/// Get the string representation of a bootstrap token from a Secret +fn token_string(secret: &Secret) -> Result { + let data = match &secret.data { + Some(d) => d, + None => return Err("Missing data property".into()), + }; + let token_id = match data.get("token-id") { + Some(s) => match String::from_utf8(s.0.clone()) { + Ok(s) => s, + Err(e) => return Err(e.to_string()), + }, + None => return Err("Missing token-id".into()), + }; + let secret = match data.get("token-secret") { + Some(s) => match String::from_utf8(s.0.clone()) { + Ok(s) => s, + Err(e) => return Err(e.to_string()), + }, + None => return Err("Missing token-secret".into()), + }; + Ok(format!("{}.{}", token_id, secret)) +} + #[cfg(test)] mod test { use super::*; + use k8s_openapi::ByteString; use regex::Regex; #[test] @@ -377,4 +508,17 @@ mod test { "i-0a1b2c3d4e5f6f7f8" ); } + + #[test] + fn test_token_string() { + let token = BootstrapToken::new(); + let mut data = BTreeMap::new(); + data.insert("token-id".into(), ByteString(token.token_id().into())); + data.insert("token-secret".into(), ByteString(token.secret().into())); + let secret = Secret { + data: Some(data), + ..Default::default() + }; + assert_eq!(token.token(), token_string(&secret).unwrap()); + } } diff --git a/src/main.rs b/src/main.rs index 374ab41..de3af1f 100644 --- a/src/main.rs +++ b/src/main.rs @@ -12,6 +12,10 @@ fn rocket() -> _ { "/", rocket::routes![ routes::health::get_health, + routes::kubeadm::get_node_kubeconfig, + routes::kubeadm::post_node_kubeconfig, + routes::kubeadm::patch_node_kubeconfig, + routes::kubeadm::put_node_kubeconfig, routes::wireguard::get_node_wireguard, routes::sns::post_sns_notify, routes::sns::get_sns_notify, diff --git a/src/model/k8s.rs b/src/model/k8s.rs new file mode 100644 index 0000000..ed46ec7 --- /dev/null +++ b/src/model/k8s.rs @@ -0,0 +1,91 @@ +//! kubeadm configuration data types +//! +//! The Kubernetes API reference does not include a specification for the +//! `Config` resource, and as such there is no model for it in [`k8s_openapi`]. +//! Since *dynk8s* needs to read and write objects of this type, to provide +//! configuration for `kubeadm` on dynamic nodes, a subset of the required +//! model is defined here. +use serde::{Deserialize, Serialize}; + +/// Cluster information +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(rename_all = "kebab-case")] +pub struct ClusterInfo { + /// X.509 certificate of the Kubernetes certificate authority + pub certificate_authority_data: String, + /// URL of the Kubernetes API server + pub server: String, +} + +/// Cluster definition +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct Cluster { + /// Cluster information + pub cluster: ClusterInfo, + /// Cluster name + pub name: String, +} + +/// kubeconfig context information +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct ContextInfo { + /// The cluster to use + pub cluster: String, + /// The user to use + pub user: String, +} + +/// kubeconfig context definition +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct Context { + /// Context information + pub context: ContextInfo, + /// Context name + pub name: String, +} + +/// User information +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct UserInfo { + /// Bootstrap token for authentication + pub token: String, +} + +/// User definition +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct User { + /// User name + pub name: String, + /// User information + pub user: UserInfo, +} + +/// kubeconfig +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct KubeConfig { + #[serde(rename = "apiVersion")] + pub api_version: String, + pub kind: String, + /// List of defined clusters + pub clusters: Vec, + /// List of defined contexts (user–cluster associations) + pub contexts: Option>, + #[serde(rename = "current-context")] + /// Current context + pub current_context: String, + /// List of defined users + pub users: Option>, +} + +impl Default for KubeConfig { + fn default() -> Self { + Self { + api_version: "v1".into(), + kind: "Config".into(), + clusters: vec![], + contexts: None, + current_context: "".into(), + users: None, + } + } +} diff --git a/src/model/mod.rs b/src/model/mod.rs index 470c8dc..957f68b 100644 --- a/src/model/mod.rs +++ b/src/model/mod.rs @@ -1,3 +1,4 @@ //! The dynk8s provisioner data model pub mod events; +pub mod k8s; pub mod sns; diff --git a/src/routes/kubeadm.rs b/src/routes/kubeadm.rs new file mode 100644 index 0000000..79692ed --- /dev/null +++ b/src/routes/kubeadm.rs @@ -0,0 +1,72 @@ +use rocket::http::Status; + +use crate::k8s::get_kubeconfig; + +#[rocket::get("/kubeadm/kubeconfig/")] +pub async fn get_node_kubeconfig(instance_id: String) -> Option { + if let Ok(Some(kubeconfig)) = get_kubeconfig(&instance_id).await { + Some(serde_yaml::to_string(&kubeconfig).unwrap()) + } else { + None + } +} + +#[rocket::post("/kubeadm/kubeconfig/<_instance_id>")] +pub async fn post_node_kubeconfig(_instance_id: String) -> Status { + Status::MethodNotAllowed +} + +#[rocket::patch("/kubeadm/kubeconfig/<_instance_id>")] +pub async fn patch_node_kubeconfig(_instance_id: String) -> Status { + Status::MethodNotAllowed +} + +#[rocket::put("/kubeadm/kubeconfig/<_instance_id>")] +pub async fn put_node_kubeconfig(_instance_id: String) -> Status { + Status::MethodNotAllowed +} + +#[cfg(test)] +mod test { + use super::*; + use crate::rocket; + use rocket::local::blocking::Client; + use rocket::uri; + + #[test] + fn test_get_node_token_404() { + let client = Client::tracked(rocket()).unwrap(); + let res = client + .get(uri!(get_node_kubeconfig( + instance_id = "i-0a1b2c3d4e5f6f7f8" + ))) + .dispatch(); + assert_eq!(res.status(), Status::NotFound); + } + + #[test] + fn test_kubeconfig_msg_wrong_method() { + let client = Client::tracked(rocket()).unwrap(); + + let res = client + .post(uri!(get_node_kubeconfig( + instance_id = "i-0a1b2c3d4e5f6f7f8" + ))) + .dispatch(); + assert_eq!(res.status(), Status::MethodNotAllowed); + + let res = client + .patch(uri!(get_node_kubeconfig( + instance_id = "i-0a1b2c3d4e5f6f7f8" + ))) + .dispatch(); + assert_eq!(res.status(), Status::MethodNotAllowed); + + let res = client + .put(uri!(get_node_kubeconfig( + instance_id = "i-0a1b2c3d4e5f6f7f8" + ))) + .dispatch(); + assert_eq!(res.status(), Status::MethodNotAllowed); + } +} diff --git a/src/routes/mod.rs b/src/routes/mod.rs index 1d72d0c..b0a5a61 100644 --- a/src/routes/mod.rs +++ b/src/routes/mod.rs @@ -1,4 +1,5 @@ //! Rocket route handlers pub mod health; +pub mod kubeadm; pub mod sns; pub mod wireguard;