diff --git a/src/KubernetesClient/KubeConfigModels/ExecCredentialResponse.cs b/src/KubernetesClient/KubeConfigModels/ExecCredentialResponse.cs new file mode 100644 index 000000000..0c353fb7d --- /dev/null +++ b/src/KubernetesClient/KubeConfigModels/ExecCredentialResponse.cs @@ -0,0 +1,15 @@ +using System.Collections.Generic; +using Newtonsoft.Json; + +namespace k8s.KubeConfigModels +{ + public class ExecCredentialResponse + { + [JsonProperty("apiVersion")] + public string ApiVersion { get; set; } + [JsonProperty("kind")] + public string Kind { get; set; } + [JsonProperty("status")] + public IDictionary Status { get; set; } + } +} diff --git a/src/KubernetesClient/KubeConfigModels/ExternalExecution.cs b/src/KubernetesClient/KubeConfigModels/ExternalExecution.cs new file mode 100644 index 000000000..899f519ee --- /dev/null +++ b/src/KubernetesClient/KubeConfigModels/ExternalExecution.cs @@ -0,0 +1,26 @@ +using System.Collections.Generic; +using YamlDotNet.Serialization; + +namespace k8s.KubeConfigModels +{ + public class ExternalExecution + { + [YamlMember(Alias = "apiVersion")] + public string ApiVersion { get; set; } + /// + /// The command to execute. Required. + /// + [YamlMember(Alias = "command")] + public string Command { get; set; } + /// + /// Environment variables to set when executing the plugin. Optional. + /// + [YamlMember(Alias = "env")] + public IDictionary EnvironmentVariables { get; set; } + /// + /// Arguments to pass when executing the plugin. Optional. + /// + [YamlMember(Alias = "args")] + public IList Arguments { get; set; } + } +} diff --git a/src/KubernetesClient/KubeConfigModels/UserCredentials.cs b/src/KubernetesClient/KubeConfigModels/UserCredentials.cs index c34f37467..7ab580e58 100644 --- a/src/KubernetesClient/KubeConfigModels/UserCredentials.cs +++ b/src/KubernetesClient/KubeConfigModels/UserCredentials.cs @@ -80,5 +80,11 @@ public class UserCredentials /// [YamlMember(Alias = "extensions")] public IDictionary Extensions { get; set; } + + /// + /// Gets or sets external command and its arguments to receive user credentials + /// + [YamlMember(Alias = "exec")] + public ExternalExecution ExternalExecution { get; set; } } } diff --git a/src/KubernetesClient/KubernetesClientConfiguration.ConfigFile.cs b/src/KubernetesClient/KubernetesClientConfiguration.ConfigFile.cs index a1e9b2d31..74a150931 100644 --- a/src/KubernetesClient/KubernetesClientConfiguration.ConfigFile.cs +++ b/src/KubernetesClient/KubernetesClientConfiguration.ConfigFile.cs @@ -1,5 +1,9 @@ using System; +#if NETSTANDARD2_0 +using Newtonsoft.Json; using System.Collections.Generic; +using System.Diagnostics; +#endif using System.IO; using System.Linq; using System.Runtime.InteropServices; @@ -8,6 +12,7 @@ using k8s.Exceptions; using k8s.KubeConfigModels; + namespace k8s { public partial class KubernetesClientConfiguration @@ -28,15 +33,19 @@ public partial class KubernetesClientConfiguration /// /// Initializes a new instance of the from config file /// - public static KubernetesClientConfiguration BuildDefaultConfig() { + public static KubernetesClientConfiguration BuildDefaultConfig() + { var kubeconfig = Environment.GetEnvironmentVariable("KUBECONFIG"); - if (kubeconfig != null) { + if (kubeconfig != null) + { return BuildConfigFromConfigFile(kubeconfigPath: kubeconfig); } - if (File.Exists(KubeConfigDefaultLocation)) { + if (File.Exists(KubeConfigDefaultLocation)) + { return BuildConfigFromConfigFile(kubeconfigPath: KubeConfigDefaultLocation); } - if (IsInCluster()) { + if (IsInCluster()) + { return InClusterConfig(); } var config = new KubernetesClientConfiguration(); @@ -150,7 +159,7 @@ private static KubernetesClientConfiguration GetKubernetesClientConfiguration(st var k8SConfiguration = new KubernetesClientConfiguration(); currentContext = currentContext ?? k8SConfig.CurrentContext; - // only init context if context if set + // only init context if context is set if (currentContext != null) { k8SConfiguration.InitializeContext(k8SConfig, currentContext); @@ -214,7 +223,7 @@ private void SetClusterDetails(K8SConfiguration k8SConfig, Context activeContext Host = clusterDetails.ClusterEndpoint.Server; SkipTlsVerify = clusterDetails.ClusterEndpoint.SkipTlsVerify; - if(!Uri.TryCreate(Host, UriKind.Absolute, out Uri uri)) + if (!Uri.TryCreate(Host, UriKind.Absolute, out Uri uri)) { throw new KubeConfigException($"Bad server host URL `{Host}` (cannot be parsed)"); } @@ -294,65 +303,81 @@ private void SetUserDetails(K8SConfiguration k8SConfig, Context activeContext) switch (userDetails.UserCredentials.AuthProvider.Name) { case "azure": - { - var config = userDetails.UserCredentials.AuthProvider.Config; - if (config.ContainsKey("expires-on")) { - var expiresOn = Int32.Parse(config["expires-on"]); - DateTimeOffset expires; - #if NET452 + var config = userDetails.UserCredentials.AuthProvider.Config; + if (config.ContainsKey("expires-on")) + { + var expiresOn = Int32.Parse(config["expires-on"]); + DateTimeOffset expires; +#if NET452 var epoch = new DateTimeOffset(1970, 1, 1, 0, 0, 0, TimeSpan.Zero); expires = epoch.AddSeconds(expiresOn); - #else - expires = DateTimeOffset.FromUnixTimeSeconds(expiresOn); - #endif +#else + expires = DateTimeOffset.FromUnixTimeSeconds(expiresOn); +#endif - if (DateTimeOffset.Compare(expires - , DateTimeOffset.Now) - <= 0) - { - var tenantId = config["tenant-id"]; - var clientId = config["client-id"]; - var apiServerId = config["apiserver-id"]; - var refresh = config["refresh-token"]; - var newToken = RenewAzureToken(tenantId - , clientId - , apiServerId - , refresh); - config["access-token"] = newToken; + if (DateTimeOffset.Compare(expires + , DateTimeOffset.Now) + <= 0) + { + var tenantId = config["tenant-id"]; + var clientId = config["client-id"]; + var apiServerId = config["apiserver-id"]; + var refresh = config["refresh-token"]; + var newToken = RenewAzureToken(tenantId + , clientId + , apiServerId + , refresh); + config["access-token"] = newToken; + } } - } - AccessToken = config["access-token"]; - userCredentialsFound = true; - break; - } + AccessToken = config["access-token"]; + userCredentialsFound = true; + break; + } case "gcp": - { - var config = userDetails.UserCredentials.AuthProvider.Config; - const string keyExpire = "expiry"; - if (config.ContainsKey(keyExpire)) { - if (DateTimeOffset.TryParse(config[keyExpire] - , out DateTimeOffset expires)) + var config = userDetails.UserCredentials.AuthProvider.Config; + const string keyExpire = "expiry"; + if (config.ContainsKey(keyExpire)) { - if (DateTimeOffset.Compare(expires - , DateTimeOffset.Now) - <= 0) + if (DateTimeOffset.TryParse(config[keyExpire] + , out DateTimeOffset expires)) { - throw new KubeConfigException("Refresh not supported."); + if (DateTimeOffset.Compare(expires + , DateTimeOffset.Now) + <= 0) + { + throw new KubeConfigException("Refresh not supported."); + } } } - } - AccessToken = config["access-token"]; - userCredentialsFound = true; - break; - } + AccessToken = config["access-token"]; + userCredentialsFound = true; + break; + } } } } +#if NETSTANDARD2_0 + if (userDetails.UserCredentials.ExternalExecution != null) + { + if (string.IsNullOrWhiteSpace(userDetails.UserCredentials.ExternalExecution.Command)) + throw new KubeConfigException( + "External command execution to receive user credentials must include a command to execute"); + if (string.IsNullOrWhiteSpace(userDetails.UserCredentials.ExternalExecution.ApiVersion)) + throw new KubeConfigException("External command execution missing ApiVersion key"); + + var token = ExecuteExternalCommand(userDetails.UserCredentials.ExternalExecution); + AccessToken = token; + + userCredentialsFound = true; + } +#endif + if (!userCredentialsFound) { throw new KubeConfigException( @@ -365,6 +390,84 @@ public static string RenewAzureToken(string tenantId, string clientId, string ap throw new KubeConfigException("Refresh not supported."); } +#if NETSTANDARD2_0 + /// + /// Implementation of the proposal for out-of-tree client + /// authentication providers as described here -- + /// https://github.com/kubernetes/community/blob/master/contributors/design-proposals/auth/kubectl-exec-plugins.md + /// Took inspiration from python exec_provider.py -- + /// https://github.com/kubernetes-client/python-base/blob/master/config/exec_provider.py + /// + /// The external command execution configuration + /// The token received from the external commmand execution + public static string ExecuteExternalCommand(ExternalExecution config) + { + var execInfo = new Dictionary + { + {"apiVersion", config.ApiVersion}, + {"kind", "ExecCredentials"}, + {"spec", new Dictionary + { + {"interactive", Environment.UserInteractive} + }} + }; + + var process = new Process(); + + process.StartInfo.Environment.Add("KUBERNETES_EXEC_INFO", + JsonConvert.SerializeObject(execInfo)); + + if (config.EnvironmentVariables != null) + foreach (var configEnvironmentVariableKey in config.EnvironmentVariables.Keys) + process.StartInfo.Environment.Add(key: configEnvironmentVariableKey, + value: config.EnvironmentVariables[configEnvironmentVariableKey]); + + process.StartInfo.FileName = config.Command; + if (config.Arguments != null) + process.StartInfo.Arguments = string.Join(" ", config.Arguments); + process.StartInfo.RedirectStandardOutput = true; + process.StartInfo.RedirectStandardError = true; + process.StartInfo.UseShellExecute = false; + + try + { + process.Start(); + } + catch (Exception ex) + { + throw new KubeConfigException($"external exec failed due to: {ex.Message}"); + } + + var stdout = process.StandardOutput.ReadToEnd(); + var stderr = process.StandardOutput.ReadToEnd(); + if (string.IsNullOrWhiteSpace(stderr) == false) + throw new KubeConfigException($"external exec failed due to: {stderr}"); + + // Wait for a maximum of 5 seconds, if a response takes longer probably something went wrong... + process.WaitForExit(5); + + try + { + var responseObject = JsonConvert.DeserializeObject(stdout); + if (responseObject == null || responseObject.ApiVersion != config.ApiVersion) + throw new KubeConfigException( + $"external exec failed because api version {responseObject.ApiVersion} does not match {config.ApiVersion}"); + return responseObject.Status["token"]; + } + catch (JsonSerializationException ex) + { + throw new KubeConfigException($"external exec failed due to failed deserialization process: {ex}"); + } + catch (Exception ex) + { + throw new KubeConfigException($"external exec failed due to uncaught exception: {ex}"); + } + + + + } +#endif + /// /// Loads entire Kube Config from default or explicit file path /// diff --git a/tests/KubernetesClient.Tests/AuthTests.cs b/tests/KubernetesClient.Tests/AuthTests.cs index 22e3f03ab..d71a42a3a 100644 --- a/tests/KubernetesClient.Tests/AuthTests.cs +++ b/tests/KubernetesClient.Tests/AuthTests.cs @@ -9,6 +9,7 @@ using System.Security.Cryptography.X509Certificates; using System.Text; using System.Threading.Tasks; +using k8s.KubeConfigModels; using k8s.Models; using k8s.Tests.Mock; using Microsoft.AspNetCore.Hosting; @@ -168,8 +169,7 @@ public void BasicAuth() } } -#if NETCOREAPP2_1 // The functionality under test, here, is dependent on managed HTTP / WebSocket functionality in .NET Core 2.1 or newer. - +#if NETCOREAPP2_1 // The functionality under test, here, is dependent on managed HTTP / WebSocket in .NET Core 2.1 or newer. [Fact] public void Cert() { @@ -280,6 +280,47 @@ public void Cert() #endif // NETCOREAPP2_1 +#if NETSTANDARD2_0 + [Fact] + public void ExternalToken() + { + const string token = "testingtoken"; + const string name = "testing_irrelevant"; + + using (var server = new MockKubeApiServer(testOutput, cxt => + { + var header = cxt.Request.Headers["Authorization"].FirstOrDefault(); + + var expect = new AuthenticationHeaderValue("Bearer", token).ToString(); + + if (header != expect) + { + cxt.Response.StatusCode = (int) HttpStatusCode.Unauthorized; + return Task.FromResult(false); + } + + return Task.FromResult(true); + })) + { + { + var kubernetesConfig = GetK8SConfiguration(server.Uri.ToString(), token, name); + var clientConfig = KubernetesClientConfiguration.BuildConfigFromConfigObject(kubernetesConfig, name); + var client = new Kubernetes(clientConfig); + var listTask = ExecuteListPods(client); + Assert.True(listTask.Response.IsSuccessStatusCode); + Assert.Equal(1, listTask.Body.Items.Count); + } + { + var kubernetesConfig = GetK8SConfiguration(server.Uri.ToString(), "wrong token", name); + var clientConfig = KubernetesClientConfiguration.BuildConfigFromConfigObject(kubernetesConfig, name); + var client = new Kubernetes(clientConfig); + var listTask = ExecuteListPods(client); + Assert.Equal(HttpStatusCode.Unauthorized, listTask.Response.StatusCode); + } + } + } +#endif // NETSTANDARD2_0 + [Fact] public void Token() { @@ -371,5 +412,58 @@ private X509Certificate2 OpenCertificateStore(Stream stream) return certificate; } + + private K8SConfiguration GetK8SConfiguration(string serverUri, string token, string name) + { + const string username = "testinguser"; + + var contexts = new List + { + new Context {Name = name, ContextDetails = new ContextDetails {Cluster = name, User = username}} + }; + + var responseJson = $"{{\"apiVersion\": \"testingversion\", \"status\": {{\"token\": \"{token}\"}}}}"; + + { + var clusters = new List + { + new Cluster + { + Name = name, + ClusterEndpoint = new ClusterEndpoint {SkipTlsVerify = true, Server = serverUri} + } + }; + + var command = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? "cmd.exe" : "echo"; + if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX)) + command = "printf"; + + var arguments = new string[] { }; + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + arguments = ($"/c echo {responseJson}").Split(" "); + if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux) || RuntimeInformation.IsOSPlatform(OSPlatform.OSX)) + arguments = new[] {responseJson}; + + + var users = new List + { + new User + { + Name = username, + UserCredentials = new UserCredentials + { + ExternalExecution = new ExternalExecution + { + ApiVersion = "testingversion", + Command = command, + Arguments = arguments.ToList() + } + } + } + }; + var kubernetesConfig = new K8SConfiguration {Clusters = clusters, Users = users, Contexts = contexts}; + return kubernetesConfig; + } + } } }