diff --git a/lib/tbot/config/bot_test.go b/lib/tbot/config/bot_test.go index a8438908cac26..1924ccb885e66 100644 --- a/lib/tbot/config/bot_test.go +++ b/lib/tbot/config/bot_test.go @@ -184,13 +184,6 @@ func (p *mockProvider) Config() *BotConfig { // getTestIdent. type identRequest func(id *tlsca.Identity) -// kubernetesRequest requests a Kubernetes cluster. -func kubernetesRequest(k8sCluster string) identRequest { - return func(id *tlsca.Identity) { - id.KubernetesCluster = k8sCluster - } -} - // getTestIdent returns a mostly-valid bot Identity without starting up an // entire Teleport server instance. func getTestIdent(t *testing.T, username string, reqs ...identRequest) *identity.Identity { diff --git a/lib/tbot/config/output_kubernetes.go b/lib/tbot/config/output_kubernetes.go index f9990a50d3a94..886c378d3fbf5 100644 --- a/lib/tbot/config/output_kubernetes.go +++ b/lib/tbot/config/output_kubernetes.go @@ -45,6 +45,13 @@ type KubernetesOutput struct { // This is named a little more verbosely to avoid conflicting with the // name of the Teleport cluster to use. KubernetesCluster string `yaml:"kubernetes_cluster"` + + // DisableExecPlugin disables the default behavior of using `tbot` as a + // `kubectl` credentials exec plugin. This is useful in environments where + // `tbot` may not exist on the system that will consume the outputted + // kubeconfig. It does mean that kubectl will not be able to automatically + // refresh the credentials within an individual invocation. + DisableExecPlugin bool `yaml:"disable_exec_plugin"` } func (o *KubernetesOutput) templates() []template { @@ -54,6 +61,7 @@ func (o *KubernetesOutput) templates() []template { &templateKubernetes{ clusterName: o.KubernetesCluster, executablePathGetter: os.Executable, + disableExecPlugin: o.DisableExecPlugin, }, } } diff --git a/lib/tbot/config/template_kubernetes.go b/lib/tbot/config/template_kubernetes.go index 9fec170c367c6..27d6153146e64 100644 --- a/lib/tbot/config/template_kubernetes.go +++ b/lib/tbot/config/template_kubernetes.go @@ -42,6 +42,7 @@ const defaultKubeconfigPath = "kubeconfig.yaml" type templateKubernetes struct { clusterName string executablePathGetter executablePathGetter + disableExecPlugin bool } func (t *templateKubernetes) name() string { @@ -66,9 +67,43 @@ type kubernetesStatus struct { credentials *client.Key } -// generateKubeConfig creates a Kubernetes config object with the given cluster +func generateKubeConfigWithoutPlugin(ks *kubernetesStatus) (*clientcmdapi.Config, error) { + config := clientcmdapi.NewConfig() + + contextName := kubeconfig.ContextName(ks.teleportClusterName, ks.kubernetesClusterName) + // Configure the cluster. + clusterCAs, err := ks.credentials.RootClusterCAs() + if err != nil { + return nil, trace.Wrap(err) + } + cas := bytes.Join(clusterCAs, []byte("\n")) + if len(cas) == 0 { + return nil, trace.BadParameter("TLS trusted CAs missing in provided credentials") + } + config.Clusters[contextName] = &clientcmdapi.Cluster{ + Server: ks.clusterAddr, + CertificateAuthorityData: cas, + TLSServerName: ks.tlsServerName, + } + + config.AuthInfos[contextName] = &clientcmdapi.AuthInfo{ + ClientCertificateData: ks.credentials.TLSCert, + ClientKeyData: ks.credentials.PrivateKeyPEM(), + } + + // Last, create a context linking the cluster to the auth info. + config.Contexts[contextName] = &clientcmdapi.Context{ + Cluster: contextName, + AuthInfo: contextName, + } + config.CurrentContext = contextName + + return config, nil +} + +// generateKubeConfigWithPlugin creates a Kubernetes config object with the given cluster // config. -func generateKubeConfig(ks *kubernetesStatus, destPath string, executablePath string) (*clientcmdapi.Config, error) { +func generateKubeConfigWithPlugin(ks *kubernetesStatus, destPath string, executablePath string) (*clientcmdapi.Config, error) { config := clientcmdapi.NewConfig() // Implementation note: tsh/kube.go generates a kubeconfig with all @@ -138,13 +173,6 @@ func (t *templateKubernetes) render( ) defer span.End() - // Only Destination dirs are supported right now, but we could be flexible - // on this in the future if needed. - destinationDir, ok := destination.(*DestinationDirectory) - if !ok { - return trace.BadParameter("Destination %s must be a directory", destination) - } - // Ping the proxy to resolve connection addresses. proxyPong, err := bot.ProxyPing(ctx) if err != nil { @@ -173,14 +201,38 @@ func (t *templateKubernetes) render( kubernetesClusterName: t.clusterName, } - executablePath, err := t.executablePathGetter() - if err != nil { - return trace.Wrap(err) - } + var cfg *clientcmdapi.Config + if t.disableExecPlugin { + // If they've disabled the exec plugin, we just write the credentials + // directly into the kubeconfig. + cfg, err = generateKubeConfigWithoutPlugin(status) + if err != nil { + return trace.Wrap(err) + } + } else { + // In exec plugin mode, we write the credentials to disk and write a + // kubeconfig that execs `tbot` to load those credentials. + + // We only support directory mode for this since the exec plugin needs + // to know the path to read the credentials from, and this is + // unpredictable with other types of destination. + destinationDir, ok := destination.(*DestinationDirectory) + if !ok { + return trace.BadParameter( + "Destination %s must be a directory in exec plugin mode", + destination, + ) + } - cfg, err := generateKubeConfig(status, destinationDir.Path, executablePath) - if err != nil { - return trace.Wrap(err) + executablePath, err := t.executablePathGetter() + if err != nil { + return trace.Wrap(err) + } + + cfg, err = generateKubeConfigWithPlugin(status, destinationDir.Path, executablePath) + if err != nil { + return trace.Wrap(err) + } } yamlCfg, err := clientcmd.Write(*cfg) diff --git a/lib/tbot/config/template_kubernetes_test.go b/lib/tbot/config/template_kubernetes_test.go index a57f642e1b435..499c9bc08c921 100644 --- a/lib/tbot/config/template_kubernetes_test.go +++ b/lib/tbot/config/template_kubernetes_test.go @@ -29,9 +29,64 @@ import ( "github.com/gravitational/teleport/api/client/webclient" "github.com/gravitational/teleport/lib/tbot/botfs" + "github.com/gravitational/teleport/lib/tbot/identity" "github.com/gravitational/teleport/lib/utils/golden" ) +// Fairly ugly hardcoded certs to use in the generation so that the tests are +// deterministic. +var ( + tlsCert = []byte(`-----BEGIN CERTIFICATE----- +MIIDfDCCAmSgAwIBAgIQP/jI85sqjDWDTd9qF0V5qjANBgkqhkiG9w0BAQsFADBA +MRUwEwYDVQQKEwxUZWxlcG9ydCBPU1MxJzAlBgNVBAMTHnRlbGVwb3J0LmxvY2Fs +aG9zdC5sb2NhbGRvbWFpbjAeFw0yNDA0MDIxNDA5MjZaFw0yNDA0MDIxNTEwMjZa +MHMxGzAZBgNVBAkTEnRlbGUuYmxhY2ttZXNhLmdvdjENMAsGA1UEERMEbnVsbDER +MA8GA1UEAxMIYm90LXRlc3QxDjAMBgUrzg8BARMDZm9vMQ4wDAYFK84PAQITA2Jh +cjESMBAGBSvODwEDEwdleGFtcGxlMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIB +CgKCAQEAr8WfEDOq1TN0bT0SGEtEuDrRaf+VudmbypHokewy46md9XB3gQWbin9N +/5tyNdbFsWsDgDIyXP3Ube0ubcPYlcsCNtgCvK4qd3RyRvxY5lOfS1pZESPEtvO/ +sxEu6E3O0ofcwq4uKenHuf1EUQuVD6WxABUOaOs2/3aahmYy4SnKNUsM2/l1XrcI +0ekvB0h10nXUC4VJS4sKGzGzThD308ia/bgDSXc0fiUwZPB5TLn7lScuisi+8JSs +qWccknXonGEEtism7FNi+mseV1ahzjEbRM/kfFwZ0H+ekz3CdnsmkND0FmxB9WTf +5PwG8oXM42QJkwuEIu+8Q/VVSSFe4wIDAQABoz8wPTAOBgNVHQ8BAf8EBAMCBaAw +HQYDVR0lBBYwFAYIKwYBBQUHAwEGCCsGAQUFBwMCMAwGA1UdEwEB/wQCMAAwDQYJ +KoZIhvcNAQELBQADggEBAEZYzIS0tx+Yn+cEfS83hpL1jELq9C8V3PTC6y44kjAK +i85Mx+ridYq0ddMdV/91JZ5t1Rqde4LSYUVUP6q/Ukih0mx/I3z2Siwp/YjpOu6x +SZXakjGnt5pCCa/t1NcVNSrqQpLQ8bJ0mruRiNawrKo3/ge57rgidnBFcOv3zV5t +BIkYjQJK2YVYcJjJ68olXzpCEw5hTZ9fw42fs2TORHHrPNUAzPf3Qdrzi03hrvhd +p6nYNA18b6ggygkcRU2MRte5E+/2ABYiwRklwJWNIMQJJHyZAW8Dyws0E2e+B3w+ +7/SYPyeKOlt7foD7z0XM2Kw9ndurJq97AWfkcnrZwbQ= +-----END CERTIFICATE-----`) + + keyPEM = []byte(`-----BEGIN RSA PRIVATE KEY----- +MIIEpQIBAAKCAQEAr8WfEDOq1TN0bT0SGEtEuDrRaf+VudmbypHokewy46md9XB3 +gQWbin9N/5tyNdbFsWsDgDIyXP3Ube0ubcPYlcsCNtgCvK4qd3RyRvxY5lOfS1pZ +ESPEtvO/sxEu6E3O0ofcwq4uKenHuf1EUQuVD6WxABUOaOs2/3aahmYy4SnKNUsM +2/l1XrcI0ekvB0h10nXUC4VJS4sKGzGzThD308ia/bgDSXc0fiUwZPB5TLn7lScu +isi+8JSsqWccknXonGEEtism7FNi+mseV1ahzjEbRM/kfFwZ0H+ekz3CdnsmkND0 +FmxB9WTf5PwG8oXM42QJkwuEIu+8Q/VVSSFe4wIDAQABAoIBACvb8vnW+pyibz3G +zFoVhfs2agS6CsFKJE6io9atinE2ZLzWqGsgXBRt+ad7QT9f7Qp9Om1lmR2NFNGt +KjWndcbC1jWbJuuvxdbyzoUZ+JDYctoZnDnjo/VG0yG6eurqZ14vGo3Vap14wSaO +pNpYOoSiAo2Ts3nIn3uVO6+nlrCKFBWiIMMvJ4L89vSCXyI/5kpwCURxpYeYJrQb +Jzi4TsN0ViYqf6XNfhRCSD+Fk9km2e6zsPIuWyfPrtGx1cA2UeAjnjKK9IS9qkAy +632T63X6M5kWtirIHM/r/IbdSj+lxGCMqnsSgNYILQv5sNkjgjwlkKl182w5l3CZ +TkjyLPkCgYEA0guhs3KBqM4ASDzQI3+h5GTGA87ITb0B/TcitElPQ+u3PLypwz9u +KzS9BHMpaWPWIOJzYRAjb8BDldyCcAhmyr5lv7O/ezRmgGD9NPV66IE5AX9nKVNS +PhJTNiYPSH7g4zO3K4sd5397YunRZzAxgsVWzu7E2gUrJSCsf0nL77UCgYEA1jpg +mJbSGYVrYEyQmt6YjHnwcLiNTJDPbcn27g0LmcRbq9SfIva/IfZaf0ru8b9c8QNM +WMag57WGQghS2B7698GvlwF+nKXXZDjCZ4z6+Efi/T5uHL5VDcpHE4yqdZG42hTW +m+K4wl4Z6B50xGC/mJlxzB6je4qBM9Zsn8wSwzcCgYEAk5O0kv4a9111eUuw+aAN +QQlEzxwUQ/pOUXjRm1X+qTwOTFBJ/nKslxLA00WOjQumQQiaBFJwc23kjoCV7N0a +S8ymdKB4IrpYYk7C2Ni4+G8CfHjlJHX0TMRXTq5DAq6Sl0+YnLFr22EIciDSDewg +fT7llRLRoFUNUVK5n91buhkCgYEAwqUEA2B1wQ6Cg1rNwIkjne9lUWW9rKWecqig +naZoteu9RyDG/qOnAhquGx5ggHJY5fsTMU44AI/kTrb1Xry3Vsk62z9WZMoiLEOO +Dzv/A/t8+I/yyFb/PKpfbhnO/0fJ5wwr+jNDoAaUD10sxwkIzIQO62GjNKqhvhHD +XGW1Xn0CgYEAzULAI9ij/q5S+GMyYj1xLCU4qxBpU/04nE+PfSSmfv8Ma34uh4QM +nRDcZHBqZYNRDt5zNvRTjgwJi4iHGwSB+D4SIYGb0ioTI2MOS2F7zBDyl8FXp7dT +3TSKronwoWYoLSisqnn/s8iN1M9RJA9pyIy7FTVwq59XL3NoetISuKc= +-----END RSA PRIVATE KEY-----`) +) + // TestTemplateKubernetesRender renders a Kubernetes template and compares it // to the saved golden result. func TestTemplateKubernetesRender(t *testing.T) { @@ -40,9 +95,18 @@ func TestTemplateKubernetesRender(t *testing.T) { k8sCluster := "example" mockBot := newMockProvider(cfg) + // We need a fixed cert/key pair here for the golden files testing + // to behave properly. + id := &identity.Identity{ + PrivateKeyBytes: keyPEM, + TLSCertBytes: tlsCert, + ClusterName: mockClusterName, + } + tests := []struct { - name string - useRelativePath bool + name string + useRelativePath bool + disableExecPlugin bool }{ { name: "absolute path", @@ -51,6 +115,10 @@ func TestTemplateKubernetesRender(t *testing.T) { name: "relative path", useRelativePath: true, }, + { + name: "exec plugin disabled", + disableExecPlugin: true, + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { @@ -59,6 +127,7 @@ func TestTemplateKubernetesRender(t *testing.T) { tmpl := templateKubernetes{ clusterName: k8sCluster, executablePathGetter: fakeGetExecutablePath, + disableExecPlugin: tt.disableExecPlugin, } dest := &DestinationDirectory{ Path: dir, @@ -73,9 +142,7 @@ func TestTemplateKubernetesRender(t *testing.T) { dest.Path = relativePath } - ident := getTestIdent(t, "bot-test", kubernetesRequest(k8sCluster)) - - err = tmpl.render(context.Background(), mockBot, ident, dest) + err = tmpl.render(context.Background(), mockBot, id, dest) require.NoError(t, err) kubeconfigBytes, err := os.ReadFile(filepath.Join(dir, defaultKubeconfigPath)) diff --git a/lib/tbot/config/testdata/TestKubernetesOutput_YAML/full.golden b/lib/tbot/config/testdata/TestKubernetesOutput_YAML/full.golden index 2e7770a40a0ea..bacaf8176fc8a 100644 --- a/lib/tbot/config/testdata/TestKubernetesOutput_YAML/full.golden +++ b/lib/tbot/config/testdata/TestKubernetesOutput_YAML/full.golden @@ -4,3 +4,4 @@ destination: roles: - access kubernetes_cluster: k8s.example.com +disable_exec_plugin: false diff --git a/lib/tbot/config/testdata/TestKubernetesOutput_YAML/minimal.golden b/lib/tbot/config/testdata/TestKubernetesOutput_YAML/minimal.golden index 94e41076bac7e..63aaab90491a8 100644 --- a/lib/tbot/config/testdata/TestKubernetesOutput_YAML/minimal.golden +++ b/lib/tbot/config/testdata/TestKubernetesOutput_YAML/minimal.golden @@ -2,3 +2,4 @@ type: kubernetes destination: type: memory kubernetes_cluster: k8s.example.com +disable_exec_plugin: false diff --git a/lib/tbot/config/testdata/TestTemplateKubernetesRender/exec_plugin_disabled/kubeconfig.yaml.golden b/lib/tbot/config/testdata/TestTemplateKubernetesRender/exec_plugin_disabled/kubeconfig.yaml.golden new file mode 100644 index 0000000000000..7a883f681038a --- /dev/null +++ b/lib/tbot/config/testdata/TestTemplateKubernetesRender/exec_plugin_disabled/kubeconfig.yaml.golden @@ -0,0 +1,20 @@ +apiVersion: v1 +clusters: +- cluster: + certificate-authority-data: LS0tLS1CRUdJTiBDRVJUSUZJQ0FURS0tLS0tCk1JSURLakNDQWhLZ0F3SUJBZ0lRSnRKREpaWkJrZy9hZk04ZDJaSkNUakFOQmdrcWhraUc5dzBCQVFzRkFEQkEKTVJVd0V3WURWUVFLRXd4VVpXeGxjRzl5ZENCUFUxTXhKekFsQmdOVkJBTVRIblJsYkdWd2IzSjBMbXh2WTJGcwphRzl6ZEM1c2IyTmhiR1J2YldGcGJqQWVGdzB4TnpBMU1Ea3hPVFF3TXpaYUZ3MHlOekExTURjeE9UUXdNelphCk1FQXhGVEFUQmdOVkJBb1RERlJsYkdWd2IzSjBJRTlUVXpFbk1DVUdBMVVFQXhNZWRHVnNaWEJ2Y25RdWJHOWoKWVd4b2IzTjBMbXh2WTJGc1pHOXRZV2x1TUlJQklqQU5CZ2txaGtpRzl3MEJBUUVGQUFPQ0FROEFNSUlCQ2dLQwpBUUVBdUtGTGFmMmlJSS94RFIrbTJZajZQblVFYStxenF3eHNkTFVqbnVuRlphQVhHK2habTRNbDgwU0NpQmdJCmdUSFFsSnlMSWtUdHVSb0g1YWVNeXoxRVJVQ3RpaTRac1RxRHJqalV5YnhQNHIrNEhWWDZtMzRzNmh3RXI4RmkKZnRzOXBNcDRpUzN0UWd1UmMyOGdQZERvL1Q2VnJKVFZZVWZVVXNORFJ0SXJsQjVPOWlncXFMbnVhWTllcUdpNApQVXgwRzB3UllKcFJ5d29qOEcwSWtwZlFUaVgrQ0FDN2R0NXdzN1pybkdxQ05CTEdpNWJHc2FNbXB0VmJzU0VwCjFUZW5udEY1NFYxaVI0OUlWNUpxRGhtMVMwSG1rbGVvSnpLZGMrNnNQL3hOZXB6OVBKenVGOWQ5TnViVExXZ0IKc0syOFlJdGNtV0hkSFhEL09EeFZhZWhSandJREFRQUJveUF3SGpBT0JnTlZIUThCQWY4RUJBTUNCNEF3REFZRApWUjBUQVFIL0JBSXdBREFOQmdrcWhraUc5dzBCQVFzRkFBT0NBUUVBQVZVNnNOQmRqNzZzYUh3T3hHU2RuRXFRCm8ydE11UjNtc1NNNEY2d0ZLMlVrS2Vwc0Q3Q1lJZi9Qek5TTlVxQTVKSUVVVmVNcUd5aUh1QWJVNEM2NTVuVDEKSXlKWDFELytyNzNzU3A1amJJcFFtMnhvUUdabmo2Zy9LbHR3OE9TT0F3K0RzTUYvUExWcW9XSnAwN3U2ZXcvbQpOeFdzSktjWjVrK3E0ZU14Y2k5bUtSSEhxc3F1V0tYelFsVVJNTkZJK21HYUZ3cktNNGRtemFSMEJFYytpbFN4ClFxVXZRNzRzbXNMSyt6aE5pa21namxHQzVvYjlnOFhraFZBa0pNQWgycmI5b25ETmlSbDY4aUFnY3pQODhtWHUKdk4vbzk4ZHlwenNQeFhtdzZ0a0RxSVJQVUFVYmg0NjVybFk1c0tNbVJnWGkyclVmbC9RVjVuYm96VW8vSFE9PQotLS0tLUVORCBDRVJUSUZJQ0FURS0tLS0t + server: https://tele.blackmesa.gov:443 + tls-server-name: kube-teleport-proxy-alpn.tele.blackmesa.gov + name: tele.blackmesa.gov-example +contexts: +- context: + cluster: tele.blackmesa.gov-example + user: tele.blackmesa.gov-example + name: tele.blackmesa.gov-example +current-context: tele.blackmesa.gov-example +kind: Config +preferences: {} +users: +- name: tele.blackmesa.gov-example + user: + client-certificate-data: LS0tLS1CRUdJTiBDRVJUSUZJQ0FURS0tLS0tCk1JSURmRENDQW1TZ0F3SUJBZ0lRUC9qSTg1c3FqRFdEVGQ5cUYwVjVxakFOQmdrcWhraUc5dzBCQVFzRkFEQkEKTVJVd0V3WURWUVFLRXd4VVpXeGxjRzl5ZENCUFUxTXhKekFsQmdOVkJBTVRIblJsYkdWd2IzSjBMbXh2WTJGcwphRzl6ZEM1c2IyTmhiR1J2YldGcGJqQWVGdzB5TkRBME1ESXhOREE1TWpaYUZ3MHlOREEwTURJeE5URXdNalphCk1ITXhHekFaQmdOVkJBa1RFblJsYkdVdVlteGhZMnR0WlhOaExtZHZkakVOTUFzR0ExVUVFUk1FYm5Wc2JERVIKTUE4R0ExVUVBeE1JWW05MExYUmxjM1F4RGpBTUJnVXJ6ZzhCQVJNRFptOXZNUTR3REFZRks4NFBBUUlUQTJKaApjakVTTUJBR0JTdk9Ed0VERXdkbGVHRnRjR3hsTUlJQklqQU5CZ2txaGtpRzl3MEJBUUVGQUFPQ0FROEFNSUlCCkNnS0NBUUVBcjhXZkVET3ExVE4wYlQwU0dFdEV1RHJSYWYrVnVkbWJ5cEhva2V3eTQ2bWQ5WEIzZ1FXYmluOU4KLzV0eU5kYkZzV3NEZ0RJeVhQM1ViZTB1YmNQWWxjc0NOdGdDdks0cWQzUnlSdnhZNWxPZlMxcFpFU1BFdHZPLwpzeEV1NkUzTzBvZmN3cTR1S2VuSHVmMUVVUXVWRDZXeEFCVU9hT3MyLzNhYWhtWXk0U25LTlVzTTIvbDFYcmNJCjBla3ZCMGgxMG5YVUM0VkpTNHNLR3pHelRoRDMwOGlhL2JnRFNYYzBmaVV3WlBCNVRMbjdsU2N1aXNpKzhKU3MKcVdjY2tuWG9uR0VFdGlzbTdGTmkrbXNlVjFhaHpqRWJSTS9rZkZ3WjBIK2VrejNDZG5zbWtORDBGbXhCOVdUZgo1UHdHOG9YTTQyUUprd3VFSXUrOFEvVlZTU0ZlNHdJREFRQUJvejh3UFRBT0JnTlZIUThCQWY4RUJBTUNCYUF3CkhRWURWUjBsQkJZd0ZBWUlLd1lCQlFVSEF3RUdDQ3NHQVFVRkJ3TUNNQXdHQTFVZEV3RUIvd1FDTUFBd0RRWUoKS29aSWh2Y05BUUVMQlFBRGdnRUJBRVpZeklTMHR4K1luK2NFZlM4M2hwTDFqRUxxOUM4VjNQVEM2eTQ0a2pBSwppODVNeCtyaWRZcTBkZE1kVi85MUpaNXQxUnFkZTRMU1lVVlVQNnEvVWtpaDBteC9JM3oyU2l3cC9ZanBPdTZ4ClNaWGFrakdudDVwQ0NhL3QxTmNWTlNycVFwTFE4YkowbXJ1UmlOYXdyS28zL2dlNTdyZ2lkbkJGY092M3pWNXQKQklrWWpRSksyWVZZY0pqSjY4b2xYenBDRXc1aFRaOWZ3NDJmczJUT1JISHJQTlVBelBmM1FkcnppMDNocnZoZApwNm5ZTkExOGI2Z2d5Z2tjUlUyTVJ0ZTVFKy8yQUJZaXdSa2x3SldOSU1RSkpIeVpBVzhEeXdzMEUyZStCM3crCjcvU1lQeWVLT2x0N2ZvRDd6MFhNMkt3OW5kdXJKcTk3QVdma2Nuclp3YlE9Ci0tLS0tRU5EIENFUlRJRklDQVRFLS0tLS0= + client-key-data: LS0tLS1CRUdJTiBSU0EgUFJJVkFURSBLRVktLS0tLQpNSUlFcFFJQkFBS0NBUUVBcjhXZkVET3ExVE4wYlQwU0dFdEV1RHJSYWYrVnVkbWJ5cEhva2V3eTQ2bWQ5WEIzCmdRV2JpbjlOLzV0eU5kYkZzV3NEZ0RJeVhQM1ViZTB1YmNQWWxjc0NOdGdDdks0cWQzUnlSdnhZNWxPZlMxcFoKRVNQRXR2Ty9zeEV1NkUzTzBvZmN3cTR1S2VuSHVmMUVVUXVWRDZXeEFCVU9hT3MyLzNhYWhtWXk0U25LTlVzTQoyL2wxWHJjSTBla3ZCMGgxMG5YVUM0VkpTNHNLR3pHelRoRDMwOGlhL2JnRFNYYzBmaVV3WlBCNVRMbjdsU2N1CmlzaSs4SlNzcVdjY2tuWG9uR0VFdGlzbTdGTmkrbXNlVjFhaHpqRWJSTS9rZkZ3WjBIK2VrejNDZG5zbWtORDAKRm14QjlXVGY1UHdHOG9YTTQyUUprd3VFSXUrOFEvVlZTU0ZlNHdJREFRQUJBb0lCQUN2Yjh2blcrcHlpYnozRwp6Rm9WaGZzMmFnUzZDc0ZLSkU2aW85YXRpbkUyWkx6V3FHc2dYQlJ0K2FkN1FUOWY3UXA5T20xbG1SMk5GTkd0CktqV25kY2JDMWpXYkp1dXZ4ZGJ5em9VWitKRFljdG9abkRuam8vVkcweUc2ZXVycVoxNHZHbzNWYXAxNHdTYU8KcE5wWU9vU2lBbzJUczNuSW4zdVZPNitubHJDS0ZCV2lJTU12SjRMODl2U0NYeUkvNWtwd0NVUnhwWWVZSnJRYgpKemk0VHNOMFZpWXFmNlhOZmhSQ1NEK0ZrOWttMmU2enNQSXVXeWZQcnRHeDFjQTJVZUFqbmpLSzlJUzlxa0F5CjYzMlQ2M1g2TTVrV3RpcklITS9yL0liZFNqK2x4R0NNcW5zU2dOWUlMUXY1c05ramdqd2xrS2wxODJ3NWwzQ1oKVGtqeUxQa0NnWUVBMGd1aHMzS0JxTTRBU0R6UUkzK2g1R1RHQTg3SVRiMEIvVGNpdEVsUFErdTNQTHlwd3o5dQpLelM5QkhNcGFXUFdJT0p6WVJBamI4QkRsZHlDY0FobXlyNWx2N08vZXpSbWdHRDlOUFY2NklFNUFYOW5LVk5TClBoSlROaVlQU0g3ZzR6TzNLNHNkNTM5N1l1blJaekF4Z3NWV3p1N0UyZ1VySlNDc2Ywbkw3N1VDZ1lFQTFqcGcKbUpiU0dZVnJZRXlRbXQ2WWpIbndjTGlOVEpEUGJjbjI3ZzBMbWNSYnE5U2ZJdmEvSWZaYWYwcnU4YjljOFFOTQpXTWFnNTdXR1FnaFMyQjc2OThHdmx3RituS1hYWkRqQ1o0ejYrRWZpL1Q1dUhMNVZEY3BIRTR5cWRaRzQyaFRXCm0rSzR3bDRaNkI1MHhHQy9tSmx4ekI2amU0cUJNOVpzbjh3U3d6Y0NnWUVBazVPMGt2NGE5MTExZVV1dythQU4KUVFsRXp4d1VRL3BPVVhqUm0xWCtxVHdPVEZCSi9uS3NseExBMDBXT2pRdW1RUWlhQkZKd2MyM2tqb0NWN04wYQpTOHltZEtCNElycFlZazdDMk5pNCtHOENmSGpsSkhYMFRNUlhUcTVEQXE2U2wwK1luTEZyMjJFSWNpRFNEZXdnCmZUN2xsUkxSb0ZVTlVWSzVuOTFidWhrQ2dZRUF3cVVFQTJCMXdRNkNnMXJOd0lram5lOWxVV1c5cktXZWNxaWcKbmFab3RldTlSeURHL3FPbkFocXVHeDVnZ0hKWTVmc1RNVTQ0QUkva1RyYjFYcnkzVnNrNjJ6OVdaTW9pTEVPTwpEenYvQS90OCtJL3l5RmIvUEtwZmJobk8vMGZKNXd3citqTkRvQWFVRDEwc3h3a0l6SVFPNjJHak5LcWh2aEhEClhHVzFYbjBDZ1lFQXpVTEFJOWlqL3E1UytHTXlZajF4TENVNHF4QnBVLzA0bkUrUGZTU21mdjhNYTM0dWg0UU0KblJEY1pIQnFaWU5SRHQ1ek52UlRqZ3dKaTRpSEd3U0IrRDRTSVlHYjBpb1RJMk1PUzJGN3pCRHlsOEZYcDdkVAozVFNLcm9ud29XWW9MU2lzcW5uL3M4aU4xTTlSSkE5cHlJeTdGVFZ3cTU5WEwzTm9ldElTdUtjPQotLS0tLUVORCBSU0EgUFJJVkFURSBLRVktLS0tLQ==