diff --git a/cmd/kube-apiserver/app/server.go b/cmd/kube-apiserver/app/server.go index 16329486b1..4c03ad35e9 100644 --- a/cmd/kube-apiserver/app/server.go +++ b/cmd/kube-apiserver/app/server.go @@ -466,26 +466,6 @@ func BuildGenericConfig(s *options.ServerRunOptions, proxyTransport *http.Transp if proxyTransport != nil && proxyTransport.Dial != nil { webhookClientConfig.Dial = proxyTransport.Dial } - // TODO: this is the wrong cert/key pair. - // Given the generic case of webhook admission from a generic apiserver, - // this key pair should be signed by the the API server's client CA. - // Read client cert/key for plugins that need to make calls out - certBytes, keyBytes := []byte{}, []byte{} - if len(s.ProxyClientCertFile) > 0 && len(s.ProxyClientKeyFile) > 0 { - var err error - certBytes, err = ioutil.ReadFile(s.ProxyClientCertFile) - if err != nil { - return nil, nil, nil, nil, nil, fmt.Errorf("failed to read proxy client cert file from: %s, err: %v", s.ProxyClientCertFile, err) - } - keyBytes, err = ioutil.ReadFile(s.ProxyClientKeyFile) - if err != nil { - return nil, nil, nil, nil, nil, fmt.Errorf("failed to read proxy client key file from: %s, err: %v", s.ProxyClientKeyFile, err) - } - webhookClientConfig.TLSClientConfig.CertData = certBytes - webhookClientConfig.TLSClientConfig.KeyData = keyBytes - } - webhookClientConfig.UserAgent = "kube-apiserver-admission" - webhookClientConfig.Timeout = 30 * time.Second err = s.Admission.ApplyTo( genericConfig, diff --git a/plugin/pkg/admission/webhook/BUILD b/plugin/pkg/admission/webhook/BUILD index ef77878e89..1e42c6017d 100644 --- a/plugin/pkg/admission/webhook/BUILD +++ b/plugin/pkg/admission/webhook/BUILD @@ -5,6 +5,8 @@ go_library( srcs = [ "admission.go", "admissionreview.go", + "authentication.go", + "config.go", "doc.go", "rules.go", "serviceresolver.go", @@ -21,15 +23,17 @@ go_library( "//vendor/k8s.io/apimachinery/pkg/api/errors:go_default_library", "//vendor/k8s.io/apimachinery/pkg/apis/meta/v1:go_default_library", "//vendor/k8s.io/apimachinery/pkg/runtime:go_default_library", - "//vendor/k8s.io/apimachinery/pkg/runtime/schema:go_default_library", "//vendor/k8s.io/apimachinery/pkg/runtime/serializer:go_default_library", "//vendor/k8s.io/apimachinery/pkg/util/runtime:go_default_library", "//vendor/k8s.io/apimachinery/pkg/util/wait:go_default_library", + "//vendor/k8s.io/apimachinery/pkg/util/yaml:go_default_library", "//vendor/k8s.io/apiserver/pkg/admission:go_default_library", "//vendor/k8s.io/apiserver/pkg/admission/configuration:go_default_library", "//vendor/k8s.io/apiserver/pkg/admission/initializer:go_default_library", "//vendor/k8s.io/client-go/kubernetes:go_default_library", "//vendor/k8s.io/client-go/rest:go_default_library", + "//vendor/k8s.io/client-go/tools/clientcmd:go_default_library", + "//vendor/k8s.io/client-go/tools/clientcmd/api:go_default_library", ], ) @@ -37,6 +41,7 @@ go_test( name = "go_default_test", srcs = [ "admission_test.go", + "authentication_test.go", "certs_test.go", "rules_test.go", "serviceresolver_test.go", @@ -49,11 +54,14 @@ go_test( "//pkg/apis/admission/install:go_default_library", "//vendor/k8s.io/api/admission/v1alpha1:go_default_library", "//vendor/k8s.io/api/admissionregistration/v1alpha1:go_default_library", + "//vendor/k8s.io/apimachinery/pkg/api/equality:go_default_library", "//vendor/k8s.io/apimachinery/pkg/apis/meta/v1:go_default_library", "//vendor/k8s.io/apimachinery/pkg/runtime/schema:go_default_library", + "//vendor/k8s.io/apimachinery/pkg/util/diff:go_default_library", "//vendor/k8s.io/apiserver/pkg/admission:go_default_library", "//vendor/k8s.io/apiserver/pkg/authentication/user:go_default_library", "//vendor/k8s.io/client-go/rest:go_default_library", + "//vendor/k8s.io/client-go/tools/clientcmd/api:go_default_library", ], ) diff --git a/plugin/pkg/admission/webhook/admission.go b/plugin/pkg/admission/webhook/admission.go index 3452cdb0d1..260d7aec0a 100644 --- a/plugin/pkg/admission/webhook/admission.go +++ b/plugin/pkg/admission/webhook/admission.go @@ -21,6 +21,7 @@ import ( "context" "fmt" "io" + "path" "sync" "github.com/golang/glog" @@ -30,10 +31,10 @@ import ( apierrors "k8s.io/apimachinery/pkg/api/errors" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/runtime" - "k8s.io/apimachinery/pkg/runtime/schema" "k8s.io/apimachinery/pkg/runtime/serializer" utilruntime "k8s.io/apimachinery/pkg/util/runtime" "k8s.io/apimachinery/pkg/util/wait" + "k8s.io/apimachinery/pkg/util/yaml" "k8s.io/apiserver/pkg/admission" "k8s.io/apiserver/pkg/admission/configuration" genericadmissioninit "k8s.io/apiserver/pkg/admission/initializer" @@ -42,17 +43,9 @@ import ( admissioninit "k8s.io/kubernetes/pkg/kubeapiserver/admission" // install the clientgo admission API for use with api registry - "path" - _ "k8s.io/kubernetes/pkg/apis/admission/install" ) -var ( - groupVersions = []schema.GroupVersion{ - admissionv1alpha1.SchemeGroupVersion, - } -) - type ErrCallingWebhook struct { WebhookName string Reason error @@ -68,7 +61,7 @@ func (e *ErrCallingWebhook) Error() string { // Register registers a plugin func Register(plugins *admission.Plugins) { plugins.Register("GenericAdmissionWebhook", func(configFile io.Reader) (admission.Interface, error) { - plugin, err := NewGenericAdmissionWebhook() + plugin, err := NewGenericAdmissionWebhook(configFile) if err != nil { return nil, err } @@ -84,7 +77,23 @@ type WebhookSource interface { } // NewGenericAdmissionWebhook returns a generic admission webhook plugin. -func NewGenericAdmissionWebhook() (*GenericAdmissionWebhook, error) { +func NewGenericAdmissionWebhook(configFile io.Reader) (*GenericAdmissionWebhook, error) { + kubeconfigFile := "" + if configFile != nil { + // TODO: move this to a versioned configuration file format + var config AdmissionConfig + d := yaml.NewYAMLOrJSONDecoder(configFile, 4096) + err := d.Decode(&config) + if err != nil { + return nil, err + } + kubeconfigFile = config.KubeConfigFile + } + authInfoResolver, err := newDefaultAuthenticationInfoResolver(kubeconfigFile) + if err != nil { + return nil, err + } + return &GenericAdmissionWebhook{ Handler: admission.NewHandler( admission.Connect, @@ -92,7 +101,8 @@ func NewGenericAdmissionWebhook() (*GenericAdmissionWebhook, error) { admission.Delete, admission.Update, ), - serviceResolver: defaultServiceResolver{}, + authInfoResolver: authInfoResolver, + serviceResolver: defaultServiceResolver{}, }, nil } @@ -103,7 +113,7 @@ type GenericAdmissionWebhook struct { serviceResolver admissioninit.ServiceResolver negotiatedSerializer runtime.NegotiatedSerializer - restClientConfig *rest.Config + authInfoResolver AuthenticationInfoResolver } var ( @@ -112,8 +122,14 @@ var ( _ = genericadmissioninit.WantsExternalKubeClientSet(&GenericAdmissionWebhook{}) ) +// TODO find a better way wire this, but keep this pull small for now. func (a *GenericAdmissionWebhook) SetWebhookRESTClientConfig(in *rest.Config) { - a.restClientConfig = in + if in != nil { + a.authInfoResolver = &dialOverridingAuthenticationInfoResolver{ + dialFn: in.Dial, + delegate: a.authInfoResolver, + } + } } // SetServiceResolver sets a service resolver for the webhook admission plugin. @@ -138,9 +154,6 @@ func (a *GenericAdmissionWebhook) SetExternalKubeClientSet(client clientset.Inte } func (a *GenericAdmissionWebhook) Validate() error { - if a.restClientConfig == nil { - return fmt.Errorf("the GenericAdmissionWebhook admission plugin requires a restClientConfig to be provided") - } if a.hookSource == nil { return fmt.Errorf("the GenericAdmissionWebhook admission plugin requires a Kubernetes client to be provided") } @@ -266,16 +279,21 @@ func (a *GenericAdmissionWebhook) callHook(ctx context.Context, h *v1alpha1.Exte } func (a *GenericAdmissionWebhook) hookClient(h *v1alpha1.ExternalAdmissionHook) (*rest.RESTClient, error) { + serverName := h.ClientConfig.Service.Name + "." + h.ClientConfig.Service.Namespace + ".svc" u, err := a.serviceResolver.ResolveEndpoint(h.ClientConfig.Service.Namespace, h.ClientConfig.Service.Name) if err != nil { return nil, err } // TODO: cache these instead of constructing one each time - cfg := rest.CopyConfig(a.restClientConfig) + restConfig, err := a.authInfoResolver.ClientConfigFor(serverName) + if err != nil { + return nil, err + } + cfg := rest.CopyConfig(restConfig) cfg.Host = u.Host cfg.APIPath = path.Join(u.Path, h.ClientConfig.URLPath) - cfg.TLSClientConfig.ServerName = h.ClientConfig.Service.Name + "." + h.ClientConfig.Service.Namespace + ".svc" + cfg.TLSClientConfig.ServerName = serverName cfg.TLSClientConfig.CAData = h.ClientConfig.CABundle cfg.ContentConfig.NegotiatedSerializer = a.negotiatedSerializer return rest.UnversionedRESTClientFor(cfg) diff --git a/plugin/pkg/admission/webhook/admission_test.go b/plugin/pkg/admission/webhook/admission_test.go index 82a1d28ff1..2685af9a99 100644 --- a/plugin/pkg/admission/webhook/admission_test.go +++ b/plugin/pkg/admission/webhook/admission_test.go @@ -32,10 +32,10 @@ import ( metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apiserver/pkg/admission" "k8s.io/apiserver/pkg/authentication/user" - "k8s.io/client-go/rest" "k8s.io/kubernetes/pkg/api" "k8s.io/kubernetes/pkg/api/legacyscheme" + "k8s.io/client-go/rest" _ "k8s.io/kubernetes/pkg/apis/admission/install" ) @@ -86,14 +86,19 @@ func TestAdmit(t *testing.T) { if err != nil { t.Fatalf("this should never happen? %v", err) } - wh, err := NewGenericAdmissionWebhook() + wh, err := NewGenericAdmissionWebhook(nil) if err != nil { t.Fatal(err) } - wh.restClientConfig = &rest.Config{} - wh.restClientConfig.TLSClientConfig.CAData = caCert - wh.restClientConfig.TLSClientConfig.CertData = clientCert - wh.restClientConfig.TLSClientConfig.KeyData = clientKey + wh.authInfoResolver = &fakeAuthenticationInfoResolver{ + restConfig: &rest.Config{ + TLSClientConfig: rest.TLSClientConfig{ + CAData: caCert, + CertData: clientCert, + KeyData: clientKey, + }, + }, + } // Set up a test object for the call kind := api.Kind("Pod").WithVersion("v1") @@ -318,3 +323,11 @@ func webhookHandler(w http.ResponseWriter, r *http.Request) { http.NotFound(w, r) } } + +type fakeAuthenticationInfoResolver struct { + restConfig *rest.Config +} + +func (c *fakeAuthenticationInfoResolver) ClientConfigFor(server string) (*rest.Config, error) { + return c.restConfig, nil +} diff --git a/plugin/pkg/admission/webhook/authentication.go b/plugin/pkg/admission/webhook/authentication.go new file mode 100644 index 0000000000..a4c9226364 --- /dev/null +++ b/plugin/pkg/admission/webhook/authentication.go @@ -0,0 +1,157 @@ +/* +Copyright 2017 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package webhook + +import ( + "io/ioutil" + "net" + "strings" + "time" + + "fmt" + + "k8s.io/client-go/rest" + "k8s.io/client-go/tools/clientcmd" + clientcmdapi "k8s.io/client-go/tools/clientcmd/api" +) + +type AuthenticationInfoResolver interface { + ClientConfigFor(server string) (*rest.Config, error) +} + +type defaultAuthenticationInfoResolver struct { + kubeconfig clientcmdapi.Config +} + +func newDefaultAuthenticationInfoResolver(kubeconfigFile string) (AuthenticationInfoResolver, error) { + if len(kubeconfigFile) == 0 { + return &defaultAuthenticationInfoResolver{}, nil + } + + loadingRules := clientcmd.NewDefaultClientConfigLoadingRules() + loadingRules.ExplicitPath = kubeconfigFile + loader := clientcmd.NewNonInteractiveDeferredLoadingClientConfig(loadingRules, &clientcmd.ConfigOverrides{}) + clientConfig, err := loader.RawConfig() + if err != nil { + return nil, err + } + + return &defaultAuthenticationInfoResolver{kubeconfig: clientConfig}, nil +} + +func (c *defaultAuthenticationInfoResolver) ClientConfigFor(server string) (*rest.Config, error) { + // exact match + if authConfig, ok := c.kubeconfig.AuthInfos[server]; ok { + return restConfigFromKubeconfig(authConfig) + } + + // star prefixed match + serverSteps := strings.Split(server, ".") + for i := 1; i < len(serverSteps); i++ { + nickName := "*." + strings.Join(serverSteps[i:], ".") + if authConfig, ok := c.kubeconfig.AuthInfos[nickName]; ok { + return restConfigFromKubeconfig(authConfig) + } + } + + // if we're trying to hit the kube-apiserver and there wasn't an explicit config, use the in-cluster config + if server == "kubernetes.default.svc" { + // if we can find an in-cluster-config use that. If we can't, fall through. + inClusterConfig, err := rest.InClusterConfig() + if err == nil { + return setGlobalDefaults(inClusterConfig), nil + } + } + + // star (default) match + if authConfig, ok := c.kubeconfig.AuthInfos["*"]; ok { + return restConfigFromKubeconfig(authConfig) + } + + // use the current context from the kubeconfig if possible + if len(c.kubeconfig.CurrentContext) > 0 { + if currContext, ok := c.kubeconfig.Contexts[c.kubeconfig.CurrentContext]; ok { + if len(currContext.AuthInfo) > 0 { + if currAuth, ok := c.kubeconfig.AuthInfos[currContext.AuthInfo]; ok { + return restConfigFromKubeconfig(currAuth) + } + } + } + } + + // anonymous + return setGlobalDefaults(&rest.Config{}), nil +} + +func restConfigFromKubeconfig(configAuthInfo *clientcmdapi.AuthInfo) (*rest.Config, error) { + config := &rest.Config{} + + // blindly overwrite existing values based on precedence + if len(configAuthInfo.Token) > 0 { + config.BearerToken = configAuthInfo.Token + } else if len(configAuthInfo.TokenFile) > 0 { + tokenBytes, err := ioutil.ReadFile(configAuthInfo.TokenFile) + if err != nil { + return nil, err + } + config.BearerToken = string(tokenBytes) + } + if len(configAuthInfo.Impersonate) > 0 { + config.Impersonate = rest.ImpersonationConfig{ + UserName: configAuthInfo.Impersonate, + Groups: configAuthInfo.ImpersonateGroups, + Extra: configAuthInfo.ImpersonateUserExtra, + } + } + if len(configAuthInfo.ClientCertificate) > 0 || len(configAuthInfo.ClientCertificateData) > 0 { + config.CertFile = configAuthInfo.ClientCertificate + config.CertData = configAuthInfo.ClientCertificateData + config.KeyFile = configAuthInfo.ClientKey + config.KeyData = configAuthInfo.ClientKeyData + } + if len(configAuthInfo.Username) > 0 || len(configAuthInfo.Password) > 0 { + config.Username = configAuthInfo.Username + config.Password = configAuthInfo.Password + } + if configAuthInfo.AuthProvider != nil { + return nil, fmt.Errorf("auth provider not supported") + } + + return setGlobalDefaults(config), nil +} + +func setGlobalDefaults(config *rest.Config) *rest.Config { + config.UserAgent = "kube-apiserver-admission" + config.Timeout = 30 * time.Second + + return config +} + +type dialOverridingAuthenticationInfoResolver struct { + dialFn func(network, addr string) (net.Conn, error) + delegate AuthenticationInfoResolver +} + +func (c *dialOverridingAuthenticationInfoResolver) ClientConfigFor(server string) (*rest.Config, error) { + cfg, err := c.delegate.ClientConfigFor(server) + if err != nil { + return nil, err + } + + cfg.Dial = c.dialFn + return cfg, nil +} diff --git a/plugin/pkg/admission/webhook/authentication_test.go b/plugin/pkg/admission/webhook/authentication_test.go new file mode 100644 index 0000000000..d91a428c06 --- /dev/null +++ b/plugin/pkg/admission/webhook/authentication_test.go @@ -0,0 +1,130 @@ +/* +Copyright 2017 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package webhook + +import ( + "testing" + + "k8s.io/apimachinery/pkg/api/equality" + "k8s.io/apimachinery/pkg/util/diff" + "k8s.io/client-go/rest" + clientcmdapi "k8s.io/client-go/tools/clientcmd/api" +) + +func TestAuthenticationDetection(t *testing.T) { + tests := []struct { + name string + kubeconfig clientcmdapi.Config + serverName string + expected rest.Config + }{ + { + name: "empty", + serverName: "foo.com", + }, + { + name: "fallback to current context", + serverName: "foo.com", + kubeconfig: clientcmdapi.Config{ + AuthInfos: map[string]*clientcmdapi.AuthInfo{ + "bar.com": {Token: "bar"}, + }, + Contexts: map[string]*clientcmdapi.Context{ + "ctx": { + AuthInfo: "bar.com", + }, + }, + CurrentContext: "ctx", + }, + expected: rest.Config{BearerToken: "bar"}, + }, + { + name: "exact match", + serverName: "foo.com", + kubeconfig: clientcmdapi.Config{ + AuthInfos: map[string]*clientcmdapi.AuthInfo{ + "foo.com": {Token: "foo"}, + "*.com": {Token: "foo-star"}, + "bar.com": {Token: "bar"}, + }, + }, + expected: rest.Config{BearerToken: "foo"}, + }, + { + name: "partial star match", + serverName: "foo.com", + kubeconfig: clientcmdapi.Config{ + AuthInfos: map[string]*clientcmdapi.AuthInfo{ + "*.com": {Token: "foo-star"}, + "bar.com": {Token: "bar"}, + }, + }, + expected: rest.Config{BearerToken: "foo-star"}, + }, + { + name: "full star match", + serverName: "foo.com", + kubeconfig: clientcmdapi.Config{ + AuthInfos: map[string]*clientcmdapi.AuthInfo{ + "*": {Token: "star"}, + "bar.com": {Token: "bar"}, + }, + }, + expected: rest.Config{BearerToken: "star"}, + }, + { + name: "skip bad in cluster config", + serverName: "kubernetes.default.svc", + kubeconfig: clientcmdapi.Config{ + AuthInfos: map[string]*clientcmdapi.AuthInfo{ + "*": {Token: "star"}, + "bar.com": {Token: "bar"}, + }, + }, + expected: rest.Config{BearerToken: "star"}, + }, + { + name: "most selective", + serverName: "one.two.three.com", + kubeconfig: clientcmdapi.Config{ + AuthInfos: map[string]*clientcmdapi.AuthInfo{ + "*.two.three.com": {Token: "first"}, + "*.three.com": {Token: "second"}, + "*.com": {Token: "third"}, + }, + }, + expected: rest.Config{BearerToken: "first"}, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + resolver := defaultAuthenticationInfoResolver{kubeconfig: tc.kubeconfig} + actual, err := resolver.ClientConfigFor(tc.serverName) + if err != nil { + t.Fatal(err) + } + actual.UserAgent = "" + actual.Timeout = 0 + + if !equality.Semantic.DeepEqual(*actual, tc.expected) { + t.Errorf("%v", diff.ObjectReflectDiff(tc.expected, *actual)) + } + }) + } + +} diff --git a/plugin/pkg/admission/webhook/config.go b/plugin/pkg/admission/webhook/config.go new file mode 100644 index 0000000000..7285d4e9f0 --- /dev/null +++ b/plugin/pkg/admission/webhook/config.go @@ -0,0 +1,22 @@ +/* +Copyright 2017 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package webhook + +// AdmissionConfig holds config data that is unique to each API server. +type AdmissionConfig struct { + KubeConfigFile string `json:"kubeConfigFile"` +} diff --git a/staging/src/k8s.io/apiserver/pkg/admission/initializer/initializer.go b/staging/src/k8s.io/apiserver/pkg/admission/initializer/initializer.go index d41b305d96..a7151b407a 100644 --- a/staging/src/k8s.io/apiserver/pkg/admission/initializer/initializer.go +++ b/staging/src/k8s.io/apiserver/pkg/admission/initializer/initializer.go @@ -30,6 +30,7 @@ type pluginInitializer struct { externalInformers informers.SharedInformerFactory authorizer authorizer.Authorizer // webhookRESTClientConfig provies a client used to contact webhooks + // TODO clean out cruft webhookRESTClientConfig *rest.Config scheme *runtime.Scheme }