diff --git a/.travis.yml b/.travis.yml index 2b05048a2..474d34c07 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,6 +1,7 @@ language: rust rust: - stable +cache: cargo script: - cargo build - cargo test -v --all-features diff --git a/Cargo.toml b/Cargo.toml index b820ed4db..42d52b24b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -15,6 +15,7 @@ edition = "2018" [dependencies] base64 = "0.9.3" +chrono = "0.4.6" dirs = "1.0.4" failure = "0.1.2" reqwest = "0.9.17" @@ -26,6 +27,7 @@ openssl = "0.10.12" http = "0.1.17" url = "1.7.2" log = "0.4.6" +time = "0.1.42" k8s-openapi = { version = "0.4.0", optional = true } either = "1.5.2" diff --git a/examples/crd_api.rs b/examples/crd_api.rs index c2f8328d9..9fc2799c9 100644 --- a/examples/crd_api.rs +++ b/examples/crd_api.rs @@ -4,7 +4,7 @@ use either::Either::{Left, Right}; use serde_json::json; use kube::{ - api::{RawApi, PostParams, DeleteParams, ListParams, Object, ObjectList, Void, PatchParams}, + api::{RawApi, PostParams, DeleteParams, ListParams, Object, ObjectList, PatchParams, Void}, client::APIClient, config, }; diff --git a/src/config/apis.rs b/src/config/apis.rs index 3a1e8da32..18c08b14d 100644 --- a/src/config/apis.rs +++ b/src/config/apis.rs @@ -1,10 +1,11 @@ +use std::collections::HashMap; use std::fs::File; use std::path::Path; -use failure::Error; -use serde_yaml; - +use failure::ResultExt; +use crate::{Result, ErrorKind}; use crate::config::utils; +use crate::oauth2; /// Config stores information to connect remote kubernetes cluster. #[derive(Clone, Debug, Serialize, Deserialize)] @@ -87,6 +88,28 @@ pub struct AuthInfo { pub impersonate: Option, #[serde(rename = "as-groups")] pub impersonate_groups: Option>, + + #[serde(rename = "auth-provider")] + pub auth_provider: Option, + + pub exec: Option, +} + +/// AuthProviderConfig stores auth for specified cloud provider. +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct AuthProviderConfig { + pub name: String, + pub config: HashMap, +} + +/// ExecConfig stores credential-plugin configuration. +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct ExecConfig { + #[serde(rename = "apiVersion")] + pub api_version: Option, + pub args: Option>, + pub command: String, + pub env: Option>>, } /// NamedContext associates name with context. @@ -106,28 +129,50 @@ pub struct Context { } impl Config { - pub fn load_config>(path: P) -> Result { - let f = File::open(path)?; - let config = serde_yaml::from_reader(f)?; + pub fn load_config>(path: P) -> Result { + let f = File::open(path) + .context(ErrorKind::KubeConfig("Unable to open config file".into()))?; + let config = serde_yaml::from_reader(f) + .context(ErrorKind::KubeConfig("Unable to parse config file as yaml".into()))?; Ok(config) } } impl Cluster { - pub fn load_certificate_authority(&self) -> Result, Error> { - utils::data_or_file_with_base64( + pub fn load_certificate_authority(&self) -> Result> { + let res = utils::data_or_file_with_base64( &self.certificate_authority_data, &self.certificate_authority, - ) + ).context(ErrorKind::KubeConfig("Unable to decode base64 certificates".into()))?; + Ok(res) } } impl AuthInfo { - pub fn load_client_certificate(&self) -> Result, Error> { - utils::data_or_file_with_base64(&self.client_certificate_data, &self.client_certificate) + pub fn load_gcp(&mut self) -> Result { + match &self.auth_provider { + Some(provider) => { + self.token = Some(provider.config["access-token"].clone()); + if utils::is_expired(&provider.config["expiry"]) { + let client = oauth2::CredentialsClient::new()?; + let token = client.request_token(&vec![ + "https://www.googleapis.com/auth/cloud-platform".to_string(), + ])?; + self.token = Some(token.access_token); + } + } + None => {} + }; + Ok(true) + } + + pub fn load_client_certificate(&self) -> Result> { + Ok(utils::data_or_file_with_base64(&self.client_certificate_data, &self.client_certificate) + .context(ErrorKind::KubeConfig("Unable to decode base64 client cert".into()))?) } - pub fn load_client_key(&self) -> Result, Error> { - utils::data_or_file_with_base64(&self.client_key_data, &self.client_key) + pub fn load_client_key(&self) -> Result> { + Ok(utils::data_or_file_with_base64(&self.client_key_data, &self.client_key) + .context(ErrorKind::KubeConfig("Unable to decode base64 client key".into()))?) } } diff --git a/src/config/exec.rs b/src/config/exec.rs new file mode 100644 index 000000000..03db4ff6b --- /dev/null +++ b/src/config/exec.rs @@ -0,0 +1,59 @@ +use std::process::Command; + +use failure::ResultExt; +use crate::{Error, Result, ErrorKind}; +use crate::config::apis; + +/// ExecCredentials is used by exec-based plugins to communicate credentials to +/// HTTP transports. +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct ExecCredential { + pub kind: Option, + #[serde(rename = "apiVersion")] + pub api_version: Option, + pub spec: Option, + pub status: Option, +} + +/// ExecCredenitalSpec holds request and runtime specific information provided +/// by transport. +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct ExecCredentialSpec {} + +/// ExecCredentialStatus holds credentials for the transport to use. +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct ExecCredentialStatus { + #[serde(rename = "expirationTimestamp")] + pub expiration_timestamp: Option>, + pub token: Option, + #[serde(rename = "clientCertificateData")] + pub client_certificate_data: Option, + #[serde(rename = "clientKeyData")] + pub client_key_data: Option, +} + +pub fn auth_exec(auth: &apis::ExecConfig) -> Result { + let mut cmd = Command::new(&auth.command); + if let Some(args) = &auth.args { + cmd.args(args); + } + if let Some(env) = &auth.env { + let envs = env + .iter() + .flat_map(|env| match (env.get("name"), env.get("value")) { + (Some(name), Some(value)) => Some((name, value)), + _ => None, + }); + cmd.envs(envs); + } + let out = cmd.output() + .context(ErrorKind::KubeConfig("Unable to run auth exec".into()))?; + if !out.status.success() { + let err = format!("command `{:?}` failed: {:?}", cmd, out); + return Err(Error::from(ErrorKind::KubeConfig(err))); + } + let creds = serde_json::from_slice(&out.stdout) + .context(ErrorKind::KubeConfig("Unable to parse auth exec result as json".into()))?; + + Ok(creds) +} diff --git a/src/config/kube_config.rs b/src/config/kube_config.rs index bc8574d4c..a326955fa 100644 --- a/src/config/kube_config.rs +++ b/src/config/kube_config.rs @@ -1,10 +1,11 @@ use std::path::Path; - -use failure::Error; -use openssl::pkcs12::Pkcs12; -use openssl::pkey::PKey; -use openssl::x509::X509; - +use openssl::{ + pkcs12::Pkcs12, + pkey::PKey, + x509::X509, +}; +use failure::ResultExt; +use crate::{Result, Error, ErrorKind}; use crate::config::apis::{AuthInfo, Cluster, Config, Context}; /// KubeConfigLoader loads current context, cluster, and authentication information. @@ -16,26 +17,40 @@ pub struct KubeConfigLoader { } impl KubeConfigLoader { - pub fn load>(path: P) -> Result { + pub fn load>( + path: P, + context: Option, + cluster: Option, + user: Option, + ) -> Result { let config = Config::load_config(path)?; + let context_name = context.as_ref().unwrap_or(&config.current_context); let current_context = config .contexts .iter() - .find(|named_context| named_context.name == config.current_context) + .find(|named_context| &named_context.name == context_name) .map(|named_context| &named_context.context) - .ok_or(format_err!("Unable to load current context"))?; + .ok_or_else(|| ErrorKind::KubeConfig("Unable to load current context".into()))?; + let cluster_name = cluster.as_ref().unwrap_or(¤t_context.cluster); let cluster = config .clusters .iter() - .find(|named_cluster| named_cluster.name == current_context.cluster) + .find(|named_cluster| &named_cluster.name == cluster_name) .map(|named_cluster| &named_cluster.cluster) - .ok_or(format_err!("Unable to load cluster of current context"))?; + .ok_or_else(|| ErrorKind::KubeConfig("Unable to load cluster of context".into()))?; + let user_name = user.as_ref().unwrap_or(¤t_context.user); let user = config .auth_infos .iter() - .find(|named_user| named_user.name == current_context.user) - .map(|named_user| &named_user.auth_info) - .ok_or(format_err!("Unable to load user of current context"))?; + .find(|named_user| &named_user.name == user_name) + .map(|named_user| { + let mut user = named_user.auth_info.clone(); + match user.load_gcp() { + Ok(_) => Ok(user), + Err(e) => Err(e), + } + }) + .ok_or_else(|| ErrorKind::KubeConfig("Unable to load user of context".into()))??; Ok(KubeConfigLoader { current_context: current_context.clone(), cluster: cluster.clone(), @@ -43,20 +58,20 @@ impl KubeConfigLoader { }) } - pub fn p12(&self, password: &str) -> Result { + pub fn p12(&self, password: &str) -> Result { let client_cert = &self.user.load_client_certificate()?; let client_key = &self.user.load_client_key()?; - let x509 = X509::from_pem(&client_cert)?; - let pkey = PKey::private_key_from_pem(&client_key)?; + let x509 = X509::from_pem(&client_cert).context(ErrorKind::SslError)?; + let pkey = PKey::private_key_from_pem(&client_key).context(ErrorKind::SslError)?; - Pkcs12::builder() + Ok(Pkcs12::builder() .build(password, "kubeconfig", &pkey, &x509) - .map_err(Error::from) + .context(ErrorKind::SslError)?) } - pub fn ca(&self) -> Result { - let ca = &self.cluster.load_certificate_authority()?; - X509::from_pem(&ca).map_err(Error::from) + pub fn ca(&self) -> Option> { + let ca = self.cluster.load_certificate_authority().ok()?; + Some(X509::from_pem(&ca).map_err(|_| Error::from(ErrorKind::SslError))) } } diff --git a/src/config/mod.rs b/src/config/mod.rs index e2f1a9a87..d6da7c832 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -1,12 +1,14 @@ //! In cluster or out of cluster kubeconfig to be used by an api client mod apis; +mod exec; mod incluster_config; mod kube_config; mod utils; use base64; -use failure::Error; +use failure::ResultExt; +use crate::{Error, ErrorKind, Result}; use reqwest::{header, Certificate, Client, Identity}; use self::kube_config::KubeConfigLoader; @@ -36,24 +38,63 @@ impl Configuration { /// let kubeconfig = config::load_kube_config() /// .expect("failed to load kubeconfig"); /// ``` -pub fn load_kube_config() -> Result { +pub fn load_kube_config() -> Result { + load_kube_config_with(Default::default()) +} + +/// ConfigOptions stores options used when loading kubeconfig file. +#[derive(Default)] +pub struct ConfigOptions { + pub context: Option, + pub cluster: Option, + pub user: Option, +} + +/// Returns a config includes authentication and cluster information from kubeconfig file. +/// +/// # Example +/// ```no_run +/// use kube::config; +/// +/// let kubeconfig = config::load_kube_config() +/// .expect("failed to load kubeconfig"); +/// ``` +pub fn load_kube_config_with(options: ConfigOptions) -> Result { let kubeconfig = utils::kubeconfig_path() .or_else(utils::default_kube_path) - .ok_or(format_err!("Unable to load kubeconfig"))?; + .ok_or_else(|| ErrorKind::KubeConfig("Unable to load file".into()))?; + + let loader = + KubeConfigLoader::load(kubeconfig, options.context, options.cluster, options.user)?; + let token = match &loader.user.token { + Some(token) => Some(token.clone()), + None => { + if let Some(exec) = &loader.user.exec { + let creds = exec::auth_exec(exec)?; + let status = creds + .status + .ok_or_else(|| ErrorKind::KubeConfig("exec-plugin response did not contain a status".into()))?; + status.token + } else { + None + } + } + }; - let loader = KubeConfigLoader::load(kubeconfig)?; let mut client_builder = Client::builder(); - let ca = loader.ca()?; - let req_ca = Certificate::from_der(&ca.to_der()?)?; - client_builder = client_builder.add_root_certificate(req_ca); - + if let Some(ca) = loader.ca() { + let req_ca = Certificate::from_der(&ca?.to_der().context(ErrorKind::SslError)?) + .context(ErrorKind::SslError)?; + client_builder = client_builder.add_root_certificate(req_ca); + } match loader.p12(" ") { Ok(p12) => { - let req_p12 = Identity::from_pkcs12_der(&p12.to_der()?, " ")?; + let req_p12 = Identity::from_pkcs12_der(&p12.to_der().context(ErrorKind::SslError)?, " ") + .context(ErrorKind::SslError)?; client_builder = client_builder.identity(req_p12); } - Err(_e) => { + Err(_) => { // last resort only if configs ask for it, and no client certs if let Some(true) = loader.cluster.insecure_skip_tls_verify { client_builder = client_builder.danger_accept_invalid_certs(true); @@ -64,20 +105,22 @@ pub fn load_kube_config() -> Result { let mut headers = header::HeaderMap::new(); match ( - utils::data_or_file(&loader.user.token, &loader.user.token_file), + utils::data_or_file(&token, &loader.user.token_file), (loader.user.username, loader.user.password), ) { (Ok(token), _) => { headers.insert( header::AUTHORIZATION, - header::HeaderValue::from_str(&format!("Bearer {}", token))?, + header::HeaderValue::from_str(&format!("Bearer {}", token)) + .context(ErrorKind::KubeConfig("Invalid bearer token".to_string()))?, ); } (_, (Some(u), Some(p))) => { let encoded = base64::encode(&format!("{}:{}", u, p)); headers.insert( header::AUTHORIZATION, - header::HeaderValue::from_str(&format!("Basic {}", encoded))?, + header::HeaderValue::from_str(&format!("Basic {}", encoded)) + .context(ErrorKind::KubeConfig("Invalid bearer token".to_string()))?, ); } _ => {} @@ -87,7 +130,8 @@ pub fn load_kube_config() -> Result { Ok(Configuration::new( loader.cluster.server, - client_builder.build()?, + client_builder.build() + .context(ErrorKind::KubeConfig("Unable to build client".to_string()))?, )) } @@ -101,26 +145,35 @@ pub fn load_kube_config() -> Result { /// let kubeconfig = config::incluster_config() /// .expect("failed to load incluster config"); /// ``` -pub fn incluster_config() -> Result { - let server = incluster_config::kube_server().ok_or(format_err!( - "Unable to load incluster config, {} and {} must be defined", - incluster_config::SERVICE_HOSTENV, - incluster_config::SERVICE_PORTENV - ))?; +pub fn incluster_config() -> Result { + let server = incluster_config::kube_server().ok_or_else(|| + Error::from(ErrorKind::KubeConfig(format!( + "Unable to load incluster config, {} and {} must be defined", + incluster_config::SERVICE_HOSTENV, + incluster_config::SERVICE_PORTENV + ))))?; + + let ca = incluster_config::load_cert().context(ErrorKind::SslError)?; + let req_ca = Certificate::from_der(&ca.to_der().context(ErrorKind::SslError)?) + .context(ErrorKind::SslError)?; - let ca = incluster_config::load_cert()?; - let req_ca = Certificate::from_der(&ca.to_der()?)?; + let token = incluster_config::load_token() + .context(ErrorKind::KubeConfig("Unable to load in cluster token".to_string()))?; - let token = incluster_config::load_token()?; let mut headers = header::HeaderMap::new(); headers.insert( header::AUTHORIZATION, - header::HeaderValue::from_str(&format!("Bearer {}", token))?, + header::HeaderValue::from_str(&format!("Bearer {}", token)) + .context(ErrorKind::KubeConfig("Invalid bearer token".to_string()))?, ); let client_builder = Client::builder() .add_root_certificate(req_ca) .default_headers(headers); - Ok(Configuration::new(server, client_builder.build()?)) + Ok(Configuration::new( + server, + client_builder.build() + .context(ErrorKind::KubeConfig("Unable to build client".to_string()))? + )) } diff --git a/src/config/utils.rs b/src/config/utils.rs index 3e7278490..dfb333f22 100644 --- a/src/config/utils.rs +++ b/src/config/utils.rs @@ -4,6 +4,7 @@ use std::io::Read; use std::path::{Path, PathBuf}; use base64; +use chrono::{DateTime, Utc}; use dirs::home_dir; use failure::Error; @@ -51,6 +52,12 @@ pub fn data_or_file>( } } +pub fn is_expired(timestamp: &str) -> bool { + let ts = DateTime::parse_from_rfc3339(timestamp).unwrap(); + let now = DateTime::parse_from_rfc3339(&Utc::now().to_rfc3339()).unwrap(); + ts < now +} + #[test] fn test_kubeconfig_path() { let expect_str = "/fake/.kube/config"; diff --git a/src/lib.rs b/src/lib.rs index 16a8444a4..0d2c7969f 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -41,6 +41,13 @@ pub enum ErrorKind { InvalidMethod(String), #[fail(display = "Request validation failed with {}", _0)] RequestValidation(String), + + /// Configuration error + #[fail(display = "Error loading kube config: {}", _0)] + KubeConfig(String), + + #[fail(display = "Error deserializing response")] + SslError, } use std::fmt::{self, Display}; @@ -103,3 +110,4 @@ pub type Result = std::result::Result; pub mod client; pub mod config; pub mod api; +mod oauth2; diff --git a/src/oauth2/mod.rs b/src/oauth2/mod.rs new file mode 100644 index 000000000..355723055 --- /dev/null +++ b/src/oauth2/mod.rs @@ -0,0 +1,166 @@ +use std::env; +use std::fs::File; +use std::path::PathBuf; + +use chrono::Utc; +use failure::ResultExt; +use crate::{Result, ErrorKind}; +use openssl::pkey::{PKey, Private}; +use openssl::sign::Signer; +use openssl::rsa::Padding; +use openssl::hash::MessageDigest; +use reqwest::Client; +use reqwest::header::CONTENT_TYPE; +use time::Duration; +use url::form_urlencoded::Serializer; + +const GOOGLE_APPLICATION_CREDENTIALS: &str = "GOOGLE_APPLICATION_CREDENTIALS"; +const DEFAULT_GRANT_TYPE: &str = "urn:ietf:params:oauth:grant-type:jwt-bearer"; + + +#[derive(Debug, Serialize)] +struct Header { + alg: String, + typ: String, +} + +// https://github.com/golang/oauth2/blob/c85d3e98c914e3a33234ad863dcbff5dbc425bb8/jws/jws.go#L34-L52 +#[derive(Debug, Serialize)] +struct Claim { + iss: String, + scope: String, + aud: String, + exp: i64, + iat: i64, +} + +impl Claim { + fn new(c: &Credentials, scope: &Vec) -> Claim { + let iat = Utc::now(); + // The access token is available for 1 hour. + // https://github.com/golang/oauth2/blob/c85d3e98c914e3a33234ad863dcbff5dbc425bb8/jws/jws.go#L63 + let exp = iat + Duration::hours(1); + Claim { + iss: c.client_email.clone(), + scope: scope.join(" "), + aud: c.token_uri.clone(), + exp: exp.timestamp(), + iat: iat.timestamp(), + } + } +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct Credentials { + #[serde(rename = "type")] + typ: String, + project_id: String, + private_key_id: String, + private_key: String, + client_email: String, + client_id: String, + auth_uri: String, + token_uri: String, + auth_provider_x509_cert_url: String, + client_x509_cert_url: String, +} + +impl Credentials { + pub fn load() -> Result { + let path = env::var_os(GOOGLE_APPLICATION_CREDENTIALS) + .map(PathBuf::from) + .ok_or_else(|| ErrorKind::KubeConfig("Missing GOOGLE_APPLICATION_CREDENTIALS env".into()))?; + let f = File::open(path) + .context(ErrorKind::KubeConfig("Unable to load credentials file".into()))?; + let config = serde_json::from_reader(f) + .context(ErrorKind::KubeConfig("Unable to parse credentials file".into()))?; + Ok(config) + } +} + +pub struct CredentialsClient { + pub credentials: Credentials, + pub client: Client, +} + +// https://github.com/golang/oauth2/blob/c85d3e98c914e3a33234ad863dcbff5dbc425bb8/internal/token.go#L61-L66 +#[derive(Debug, Serialize, Deserialize)] +struct TokenResponse { + access_token: Option, + token_type: Option, + expires_in: Option, +} + +impl TokenResponse { + pub fn to_token(self) -> Token { + Token { + access_token: self.access_token.unwrap(), + token_type: self.token_type.unwrap(), + refresh_token: String::new(), + expiry: self.expires_in, + } + } +} + +// https://github.com/golang/oauth2/blob/c85d3e98c914e3a33234ad863dcbff5dbc425bb8/token.go#L31-L55 +#[derive(Debug)] +pub struct Token { + pub access_token: String, + pub token_type: String, + pub refresh_token: String, + pub expiry: Option, +} + +impl CredentialsClient { + pub fn new() -> Result { + Ok(CredentialsClient { + credentials: Credentials::load()?, + client: Client::new(), + }) + } + pub fn request_token(&self, scopes: &Vec) -> Result { + let private_key = PKey::private_key_from_pem(&self.credentials.private_key.as_bytes()) + .context(ErrorKind::SslError)?; + let encoded = &self.jws_encode( + &Claim::new(&self.credentials, scopes), + &Header{ + alg: "RS256".to_string(), + typ: "JWT".to_string(), + }, + private_key)?; + + let body = Serializer::new(String::new()) + .extend_pairs(vec![ + ("grant_type".to_string(), DEFAULT_GRANT_TYPE.to_string()), + ("assertion".to_string(), encoded.to_string()), + ]).finish(); + let token_response: TokenResponse = self.client + .post(&self.credentials.token_uri) + .body(body) + .header(CONTENT_TYPE, "application/x-www-form-urlencoded") + .send() + .context(ErrorKind::KubeConfig("Unable to request token".into()))? + .json() + .context(ErrorKind::KubeConfig("Unable to parse request token".into()))?; + Ok(token_response.to_token()) + } + + fn jws_encode(&self, claim: &Claim, header: &Header, key: PKey) -> Result { + let encoded_header = self.base64_encode(serde_json::to_string(&header).unwrap().as_bytes()); + let encoded_claims = self.base64_encode(serde_json::to_string(&claim).unwrap().as_bytes()); + let signature_base = format!("{}.{}", encoded_header, encoded_claims); + let mut signer = Signer::new(MessageDigest::sha256(), &key) + .context(ErrorKind::SslError)?; + signer.set_rsa_padding(Padding::PKCS1) + .context(ErrorKind::SslError)?; + signer.update(signature_base.as_bytes()) + .context(ErrorKind::SslError)?; + let signature = signer.sign_to_vec() + .context(ErrorKind::SslError)?; + Ok(format!("{}.{}", signature_base, self.base64_encode(&signature))) + } + + fn base64_encode(&self, bytes: &[u8]) -> String { + base64::encode_config(bytes, base64::URL_SAFE) + } +}