diff --git a/cmd/kube-apiserver/app/plugins.go b/cmd/kube-apiserver/app/plugins.go index f287a9a47d0..6818b28ddfa 100644 --- a/cmd/kube-apiserver/app/plugins.go +++ b/cmd/kube-apiserver/app/plugins.go @@ -47,6 +47,7 @@ import ( "k8s.io/kubernetes/plugin/pkg/admission/securitycontext/scdeny" "k8s.io/kubernetes/plugin/pkg/admission/serviceaccount" storagedefault "k8s.io/kubernetes/plugin/pkg/admission/storageclass/default" + "k8s.io/kubernetes/plugin/pkg/admission/webhook" ) // registerAllAdmissionPlugins registers all admission plugins @@ -73,4 +74,5 @@ func registerAllAdmissionPlugins(plugins *admission.Plugins) { scdeny.Register(plugins) serviceaccount.Register(plugins) storagedefault.Register(plugins) + webhook.Register(plugins) } diff --git a/plugin/pkg/admission/webhook/admission.go b/plugin/pkg/admission/webhook/admission.go new file mode 100644 index 00000000000..11d165509e2 --- /dev/null +++ b/plugin/pkg/admission/webhook/admission.go @@ -0,0 +1,164 @@ +/* +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 checks a webhook for configured operation admission +package webhook + +import ( + "errors" + "fmt" + "io" + + "github.com/golang/glog" + + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/apimachinery/pkg/util/yaml" + "k8s.io/apiserver/pkg/admission" + "k8s.io/apiserver/pkg/util/webhook" + + "k8s.io/kubernetes/pkg/api" + admissionv1alpha1 "k8s.io/kubernetes/pkg/apis/admission/v1alpha1" + + // install the clientgo admissiony API for use with api registry + _ "k8s.io/kubernetes/pkg/apis/admission/install" +) + +var ( + groupVersions = []schema.GroupVersion{ + admissionv1alpha1.SchemeGroupVersion, + } +) + +// Register registers a plugin +func Register(plugins *admission.Plugins) { + plugins.Register("GenericAdmissionWebhook", func(configFile io.Reader) (admission.Interface, error) { + var gwhConfig struct { + WebhookConfig GenericAdmissionWebhookConfig `json:"webhook"` + } + + d := yaml.NewYAMLOrJSONDecoder(configFile, 4096) + err := d.Decode(&gwhConfig) + + if err != nil { + return nil, err + } + + plugin, err := NewGenericAdmissionWebhook(&gwhConfig.WebhookConfig) + + if err != nil { + return nil, err + } + + return plugin, nil + }) +} + +// NewGenericAdmissionWebhook returns a generic admission webhook plugin. +func NewGenericAdmissionWebhook(config *GenericAdmissionWebhookConfig) (admission.Interface, error) { + err := normalizeConfig(config) + + if err != nil { + return nil, err + } + + gw, err := webhook.NewGenericWebhook(api.Registry, api.Codecs, config.KubeConfigFile, groupVersions, config.RetryBackoff) + + if err != nil { + return nil, err + } + + return &GenericAdmissionWebhook{ + Handler: admission.NewHandler(admission.Connect, admission.Create, admission.Delete, admission.Update), + webhook: gw, + rules: config.Rules, + }, nil +} + +// GenericAdmissionWebhook is an implementation of admission.Interface. +type GenericAdmissionWebhook struct { + *admission.Handler + webhook *webhook.GenericWebhook + rules []Rule +} + +// Admit makes an admission decision based on the request attributes. +func (a *GenericAdmissionWebhook) Admit(attr admission.Attributes) (err error) { + var matched *Rule + + // Process all declared rules to attempt to find a match + for i, rule := range a.rules { + if Matches(rule, attr) { + glog.V(2).Infof("rule at index %d matched request", i) + matched = &a.rules[i] + break + } + } + + if matched == nil { + glog.V(2).Infof("rule explicitly allowed the request: no rule matched the admission request") + return nil + } + + // The matched rule skips processing this request + if matched.Type == Skip { + glog.V(2).Infof("rule explicitly allowed the request") + return nil + } + + // Make the webhook request + request := admissionv1alpha1.NewAdmissionReview(attr) + response := a.webhook.RestClient.Post().Body(&request).Do() + + // Handle webhook response + if err := response.Error(); err != nil { + return a.handleError(attr, matched.FailAction, err) + } + + var statusCode int + if response.StatusCode(&statusCode); statusCode < 200 || statusCode >= 300 { + return a.handleError(attr, matched.FailAction, fmt.Errorf("error contacting webhook: %d", statusCode)) + } + + if err := response.Into(&request); err != nil { + return a.handleError(attr, matched.FailAction, err) + } + + if !request.Status.Allowed { + if request.Status.Result != nil && len(request.Status.Result.Reason) > 0 { + return a.handleError(attr, Deny, fmt.Errorf("webhook backend denied the request: %s", request.Status.Result.Reason)) + } + return a.handleError(attr, Deny, errors.New("webhook backend denied the request")) + } + + // The webhook admission controller DOES NOT allow mutation of the admission request so nothing else is required + + return nil +} + +func (a *GenericAdmissionWebhook) handleError(attr admission.Attributes, allowIfErr FailAction, err error) error { + if err != nil { + glog.V(2).Infof("error contacting webhook backend: %s", err) + if allowIfErr != Allow { + glog.V(2).Infof("resource not allowed due to webhook backend failure: %s", err) + return admission.NewForbidden(attr, err) + } + glog.V(2).Infof("resource allowed in spite of webhook backend failure") + } + + return nil +} + +// TODO: Allow configuring the serialization strategy diff --git a/plugin/pkg/admission/webhook/admission_test.go b/plugin/pkg/admission/webhook/admission_test.go new file mode 100644 index 00000000000..1ff95ba18e0 --- /dev/null +++ b/plugin/pkg/admission/webhook/admission_test.go @@ -0,0 +1,369 @@ +/* +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 ( + "bytes" + "crypto/tls" + "crypto/x509" + "encoding/json" + "fmt" + "io/ioutil" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "regexp" + "testing" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apiserver/pkg/admission" + "k8s.io/apiserver/pkg/authentication/user" + "k8s.io/client-go/pkg/api" + "k8s.io/client-go/tools/clientcmd/api/v1" + "k8s.io/kubernetes/pkg/apis/admission/v1alpha1" + + _ "k8s.io/kubernetes/pkg/apis/admission/install" +) + +const ( + errAdmitPrefix = `pods "my-pod" is forbidden: ` +) + +var ( + requestCount int + testRoot string + testServer *httptest.Server +) + +type genericAdmissionWebhookTest struct { + test string + config *GenericAdmissionWebhookConfig + value interface{} +} + +func TestMain(m *testing.M) { + tmpRoot, err := ioutil.TempDir("", "") + + if err != nil { + killTests(err) + } + + testRoot = tmpRoot + + // Cleanup + defer os.RemoveAll(testRoot) + + // Create the test webhook server + cert, err := tls.X509KeyPair(clientCert, clientKey) + + if err != nil { + killTests(err) + } + + rootCAs := x509.NewCertPool() + + rootCAs.AppendCertsFromPEM(caCert) + + testServer = httptest.NewUnstartedServer(http.HandlerFunc(webhookHandler)) + + testServer.TLS = &tls.Config{ + Certificates: []tls.Certificate{cert}, + ClientCAs: rootCAs, + ClientAuth: tls.RequireAndVerifyClientCert, + } + + // Create the test webhook server + testServer.StartTLS() + + // Cleanup + defer testServer.Close() + + // Create an invalid and valid Kubernetes configuration file + var kubeConfig bytes.Buffer + + err = json.NewEncoder(&kubeConfig).Encode(v1.Config{ + Clusters: []v1.NamedCluster{ + { + Cluster: v1.Cluster{ + Server: testServer.URL, + CertificateAuthorityData: caCert, + }, + }, + }, + AuthInfos: []v1.NamedAuthInfo{ + { + AuthInfo: v1.AuthInfo{ + ClientCertificateData: clientCert, + ClientKeyData: clientKey, + }, + }, + }, + }) + + if err != nil { + killTests(err) + } + + // The files needed on disk for the webhook tests + files := map[string][]byte{ + "ca.pem": caCert, + "client.pem": clientCert, + "client-key.pem": clientKey, + "kube-config": kubeConfig.Bytes(), + } + + // Write the certificate files to disk or fail + for fileName, fileData := range files { + if err := ioutil.WriteFile(filepath.Join(testRoot, fileName), fileData, 0400); err != nil { + killTests(err) + } + } + + // Run the tests + m.Run() +} + +// TestNewGenericAdmissionWebhook tests that NewGenericAdmissionWebhook works as expected +func TestNewGenericAdmissionWebhook(t *testing.T) { + tests := []genericAdmissionWebhookTest{ + genericAdmissionWebhookTest{ + test: "Empty webhook config", + config: &GenericAdmissionWebhookConfig{}, + value: fmt.Errorf("kubeConfigFile is required"), + }, + genericAdmissionWebhookTest{ + test: "Broken webhook config", + config: &GenericAdmissionWebhookConfig{ + KubeConfigFile: filepath.Join(testRoot, "kube-config.missing"), + Rules: []Rule{ + Rule{ + Type: Skip, + }, + }, + }, + value: fmt.Errorf("stat .*kube-config.missing.*"), + }, + genericAdmissionWebhookTest{ + test: "Valid webhook config", + config: &GenericAdmissionWebhookConfig{ + KubeConfigFile: filepath.Join(testRoot, "kube-config"), + Rules: []Rule{ + Rule{ + Type: Skip, + }, + }, + }, + value: nil, + }, + } + + for _, tt := range tests { + _, err := NewGenericAdmissionWebhook(tt.config) + + checkForError(t, tt.test, tt.value, err) + } +} + +// TestAdmit tests that GenericAdmissionWebhook#Admit works as expected +func TestAdmit(t *testing.T) { + configWithFailAction := makeGoodConfig() + kind := api.Kind("Pod").WithVersion("v1") + name := "my-pod" + namespace := "webhook-test" + object := api.Pod{ + ObjectMeta: metav1.ObjectMeta{ + Labels: map[string]string{ + "pod.name": name, + }, + Name: name, + Namespace: namespace, + }, + TypeMeta: metav1.TypeMeta{ + APIVersion: "v1", + Kind: "Pod", + }, + } + oldObject := api.Pod{ + ObjectMeta: metav1.ObjectMeta{Name: name, Namespace: namespace}, + } + operation := admission.Update + resource := api.Resource("pods").WithVersion("v1") + subResource := "" + userInfo := user.DefaultInfo{ + Name: "webhook-test", + UID: "webhook-test", + } + + configWithFailAction.Rules[0].FailAction = Allow + + tests := []genericAdmissionWebhookTest{ + genericAdmissionWebhookTest{ + test: "No matching rule", + config: &GenericAdmissionWebhookConfig{ + KubeConfigFile: filepath.Join(testRoot, "kube-config"), + Rules: []Rule{ + Rule{ + Operations: []admission.Operation{ + admission.Create, + }, + Type: Send, + }, + }, + }, + value: nil, + }, + genericAdmissionWebhookTest{ + test: "Matching rule skips", + config: &GenericAdmissionWebhookConfig{ + KubeConfigFile: filepath.Join(testRoot, "kube-config"), + Rules: []Rule{ + Rule{ + Operations: []admission.Operation{ + admission.Update, + }, + Type: Skip, + }, + }, + }, + value: nil, + }, + genericAdmissionWebhookTest{ + test: "Matching rule sends (webhook internal server error)", + config: makeGoodConfig(), + value: fmt.Errorf(`%san error on the server ("webhook internal server error") has prevented the request from succeeding`, errAdmitPrefix), + }, + genericAdmissionWebhookTest{ + test: "Matching rule sends (webhook unsuccessful response)", + config: makeGoodConfig(), + value: fmt.Errorf("%serror contacting webhook: 101", errAdmitPrefix), + }, + genericAdmissionWebhookTest{ + test: "Matching rule sends (webhook unmarshallable response)", + config: makeGoodConfig(), + value: fmt.Errorf("%scouldn't get version/kind; json parse error: json: cannot unmarshal string into Go value of type struct.*", errAdmitPrefix), + }, + genericAdmissionWebhookTest{ + test: "Matching rule sends (webhook error but allowed via fail action)", + config: configWithFailAction, + value: nil, + }, + genericAdmissionWebhookTest{ + test: "Matching rule sends (webhook request denied without reason)", + config: makeGoodConfig(), + value: fmt.Errorf("%swebhook backend denied the request", errAdmitPrefix), + }, + genericAdmissionWebhookTest{ + test: "Matching rule sends (webhook request denied with reason)", + config: makeGoodConfig(), + value: fmt.Errorf("%swebhook backend denied the request: you shall not pass", errAdmitPrefix), + }, + genericAdmissionWebhookTest{ + test: "Matching rule sends (webhook request allowed)", + config: makeGoodConfig(), + value: nil, + }, + } + + for _, tt := range tests { + wh, err := NewGenericAdmissionWebhook(tt.config) + + if err != nil { + t.Errorf("%s: unexpected error: %v", tt.test, err) + } else { + err = wh.Admit(admission.NewAttributesRecord(&object, &oldObject, kind, namespace, name, resource, subResource, operation, &userInfo)) + + checkForError(t, tt.test, tt.value, err) + } + } +} + +func checkForError(t *testing.T, test string, expected, actual interface{}) { + aErr, _ := actual.(error) + eErr, _ := expected.(error) + + if eErr != nil { + if aErr == nil { + t.Errorf("%s: expected an error", test) + } else if eErr.Error() != aErr.Error() && !regexp.MustCompile(eErr.Error()).MatchString(aErr.Error()) { + t.Errorf("%s: unexpected error message to match:\n Expected: %s\n Actual: %s", test, eErr.Error(), aErr.Error()) + } + } else { + if aErr != nil { + t.Errorf("%s: unexpected error: %v", test, aErr) + } + } +} + +func killTests(err error) { + panic(fmt.Sprintf("Unable to bootstrap tests: %v", err)) +} + +func makeGoodConfig() *GenericAdmissionWebhookConfig { + return &GenericAdmissionWebhookConfig{ + KubeConfigFile: filepath.Join(testRoot, "kube-config"), + Rules: []Rule{ + Rule{ + Operations: []admission.Operation{ + admission.Update, + }, + Type: Send, + }, + }, + } +} + +func webhookHandler(w http.ResponseWriter, r *http.Request) { + requestCount++ + + switch requestCount { + case 1, 4: + http.Error(w, "webhook internal server error", http.StatusInternalServerError) + return + case 2: + w.WriteHeader(http.StatusSwitchingProtocols) + w.Write([]byte("webhook invalid request")) + return + case 3: + w.Header().Set("Content-Type", "application/json") + w.Write([]byte("webhook invalid response")) + case 5: + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(&v1alpha1.AdmissionReview{ + Status: v1alpha1.AdmissionReviewStatus{ + Allowed: false, + }, + }) + case 6: + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(&v1alpha1.AdmissionReview{ + Status: v1alpha1.AdmissionReviewStatus{ + Allowed: false, + Result: &metav1.Status{ + Reason: "you shall not pass", + }, + }, + }) + case 7: + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(&v1alpha1.AdmissionReview{ + Status: v1alpha1.AdmissionReviewStatus{ + Allowed: true, + }, + }) + } +} diff --git a/plugin/pkg/admission/webhook/certs_test.go b/plugin/pkg/admission/webhook/certs_test.go new file mode 100644 index 00000000000..db1e07be026 --- /dev/null +++ b/plugin/pkg/admission/webhook/certs_test.go @@ -0,0 +1,218 @@ +/* +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. +*/ + +// This file was generated using openssl by the gencerts.sh script +// and holds raw certificates for the webhook tests. + +package webhook + +var caKey = []byte(`-----BEGIN RSA PRIVATE KEY----- +MIIEowIBAAKCAQEA5qdHpJR3sAg1afEWLlnxdbDV1LtAvCd9WWE/4g71+BBwvwLL +C/piPRi+m/7AYHJ9zoVwvwAUYBJM2ppQV2Gwsq4XA5dLvr8fXt4+YA/sLTAo0PAm +/QQmZziHU9v7OJ04ypjTTuA86D/EkkpEP7lRkN/NFpV3PhU0F5TTrkrzSpePBIR1 +aPSzh/6Um6TtFk9oiqat05leWcMzonizrgeQU9EW6bSYY5w+gq1X2eZb1s9eR98H +0xcZfoh3qHmJ1Iq9Cc2Nr+MUohm2m45ozT+1L+g46ZMJxyPB6xLr7uOhkoJsK30q +67AZKPo58tDTmEKSXfBotIvFI5N9P3sAWxcTiQIDAQABAoIBAAaJHOWT82RAh0ru +MuOzVr0v+o8hky8Bq3KZ59Z++AdEZ/1xldFMEfaLOfNvn4HcHKZ6b3xqAynJuvXC +w54GPZyChFJsug+4mKn2gCv2p4mMQMvS0jf/IxtvpZ4BsLek9NQAypQElJU8IVTH +1/E6Tg5d2RDXwV43+Zbld64Ln6MwZGwv8UFPEHylDMjwkex5u3tzVBD5NaegI0MD +AHAf3fiCsANmAeGWjTvUXQsOes6wjaHw6kbih5QrXM6iThHfU/YHXYmgfdfSSIFC +4puLaehp3/U8HcI97xN238B0khnkOVHzUmRJpmf17SWFSkOAZyUMiFfTSFSOedvu +lFO7v0UCgYEA/FczxADDZXq4SSTvrU61XzolgI//OKblsqe4RW8JGtk8pwjZdYOQ +v4UAYEcv5rUWA3wWohcjgNWzI9EzdhOCYpC9YFqbHJalmwhGgOjRCvDy581pcXz7 +xsfkm2loWm3g+PcrCIset2tGQw/5gqSkBW42E/U0ba25wCS6RlYWCFsCgYEA6f+Q +vENXPmBUBW8TsyjWj7MZKVzKuV2yyKT37Mf7RmrpSNpft0PLJDqGqRMfhe1J7Adm +Np1fv/18RngjGjV3fSnjbvQ51748gabwzGZKWKiWJPsRDqjEriiQcFInhaqVJs7F +D0TaWalBHWyjPHCQQx7rWqA8tr3Wpga0AhNUuOsCgYEAnbrQU7L6cEM+UBIzcswh +GO4apPrdWIcSSxMFXvlh4pNpkys36nmbj+tN6eB1c6s7oF//McBu48gwWrIYjbTy +KjQ4+7KHBF6yE28fytI8YK9t1jESuOqb4ovuPKqtnODT4Ct3jbaQM6xtVdv1ZZEO +KYrTaLQ72lbeJdmPSgnjacMCgYASip6kXE2ocqeVuqR7+MtvnYhr359sqsEE5xWC +HKKLhOMxU6Rr+CI7n6uV8B76VMAbxMZTo4q3wtU7HD/jzsLGFzCfVRjUQI241EqW +V7Cib9Fd4ssKN1NGXY58Z/YbwFWLOq0gtZr7qc6wDzCsFFtKBkQt7S6CaG5+v186 +HuACuwKBgEd0JaREFj6AG6boytQAx+Npj+wGG7K22O8v9YslJjaS5t2i8XrvIr0F +5ltR8Ijegp9a2pCgjshaEzUqMHhxX3EGvUxVM4R430EaQ933WRPmiLlVLyhthYt0 +9oPxMoN783J7UP/IkBc6AGi3a4uTn/h6Vi884wOLop4bmsK37uIt +-----END RSA PRIVATE KEY-----`) + +var caCert = []byte(`-----BEGIN CERTIFICATE----- +MIIDhDCCAmygAwIBAgIJAIf67NAEFfGmMA0GCSqGSIb3DQEBBQUAMDQxMjAwBgNV +BAMUKWdlbmVyaWNfd2ViaG9va19hZG1pc3Npb25fcGx1Z2luX3Rlc3RzX2NhMCAX +DTE3MDUwNDIyMzMyOFoYDzIyOTEwMjE3MjIzMzI4WjA0MTIwMAYDVQQDFClnZW5l +cmljX3dlYmhvb2tfYWRtaXNzaW9uX3BsdWdpbl90ZXN0c19jYTCCASIwDQYJKoZI +hvcNAQEBBQADggEPADCCAQoCggEBAOanR6SUd7AINWnxFi5Z8XWw1dS7QLwnfVlh +P+IO9fgQcL8Cywv6Yj0Yvpv+wGByfc6FcL8AFGASTNqaUFdhsLKuFwOXS76/H17e +PmAP7C0wKNDwJv0EJmc4h1Pb+zidOMqY007gPOg/xJJKRD+5UZDfzRaVdz4VNBeU +065K80qXjwSEdWj0s4f+lJuk7RZPaIqmrdOZXlnDM6J4s64HkFPRFum0mGOcPoKt +V9nmW9bPXkffB9MXGX6Id6h5idSKvQnNja/jFKIZtpuOaM0/tS/oOOmTCccjwesS +6+7joZKCbCt9KuuwGSj6OfLQ05hCkl3waLSLxSOTfT97AFsXE4kCAwEAAaOBljCB +kzAdBgNVHQ4EFgQU55hOE1Dsydy16+6wgOxwsPKZ8JEwZAYDVR0jBF0wW4AU55hO +E1Dsydy16+6wgOxwsPKZ8JGhOKQ2MDQxMjAwBgNVBAMUKWdlbmVyaWNfd2ViaG9v +a19hZG1pc3Npb25fcGx1Z2luX3Rlc3RzX2NhggkAh/rs0AQV8aYwDAYDVR0TBAUw +AwEB/zANBgkqhkiG9w0BAQUFAAOCAQEAtEa7AMnFdc/GmVrtY0aOmA3h1WJ/rhhd +SnVOp7LmA+1jnA6FMMvVCOFflYLfBwLhIPjPj4arQadWHyd5Quok6GIqgzL4RkK7 +67hPMc8inNH8w+9K0kgsCls06Jy08NIt7QTtIckh8skvxQsfJ6An/ROiCNI5QiYj +oQOK3Tp8jGf/2wcsJLeO9y09ZPcOUbLkDe2YlnT+OqNMx9VXrPSvRwq2qtYphgYW +YWHPMEBnB/9XrVkEtnKeWPjtjarVd+rNnfVpCW0ImnZRFtKQQeI4rtf4Gr83Gdju +Z0gPepfIFptDMl5wKWyw4o2XVSZ69Ur8tQQynoNyX/FTIx6tQ//hzg== +-----END CERTIFICATE-----`) + +var badCAKey = []byte(`-----BEGIN RSA PRIVATE KEY----- +MIIEpAIBAAKCAQEA2Mbqfl5nW+rYCSw/+G00ymtBElvTwzEI88sg3m5PXU0XMv5y +Tyjt+S3mhbzLY460ty6fXlEiveNNP0bzbo0k+tvUj7HbOG4q4Cs0JWiLvibchRXQ +LimqtndsVsT2xj56m7oTETWMYhjd8KFz1s6w7yK9dChe2pBCeeGjIbQs0def8Bf0 +5h7BbsmuXQb44nnpUdWgFI/Frqcz6jO6jySYV5U3ajqCjcWOBQmXRtMi1we48Hdj +2t4jW/evbRIo/tX88kluXFSVBmTitKA3nn23AzA+/qa6kTDwzQ8OntjC09JT7ykS +764WLRiBOo+wC3a6M3eZNMkW615+DNP7wAjYEwIDAQABAoIBAC9lZnXUvDKLqUpw +I1h0wBsV0jdqXmWJ/hQXsIsRgUa8CTt8CJAoOcfGcmWBPtL4q6h1iCC+CqOL5CLW +p3jfYVt73wC/+VdgNv2mVJNtRUiBBKwQdeDx+UJF4CkkjXQQywvrZinYFGaKW1Q2 +aLZpoKPYa6XPAdY1vmMZo2pGE5qZbGbIXKcl3kl5N2X0qyJKvTR6RA1Q8JiTmATh +H1U3S9K/MIGU+9OwP8zCDVKFdDI+xgJvDo05W8bt6XLfmE4bLRYHRfe0iE0uBNSC +zx5OGBNqkiHq/vMk5mLCYM0w/uzUkhB5uXqe5gSqa7DioLrHztqAe+sbtBvfL+Yd +hP3asWECgYEA9NQXa2B6AVXVf2GII67H8diPFrILKLEw4CUZjFmVbvrKIRB2ypqA +IsohpYl97s0nstetZt/75uEvkv3RPu5ad5LJaVAPHE3ESXKdAz6u4fSmi33qqPi6 +PvhHLYDDXvMgC7j2yCYyANVKNg2T+EJvpWMISlXM4h7CVm21CSDVZjECgYEA4qsl +zDA3sHoClfC4nAOVrRghYlHU2bT6HPLxjLtBkcUTfj6nO6nOLGC7EFVkqYw5mUWq +uSNntk1D1+MYVnZBeKqw6y21FFsclmzzXTtAJg0vuAg1jxnMHqiDbNqpUUO8ZWLG +iz2tdAWiBAKwZv0Psv44Dy++4v/BMd8FBb/iHYMCgYEAttKmRmW91b9t9Xg0fEjp +QBzyBQWhNZrTn520rUy8PSqDxBsSSgsDgncklwPMCYYjjfZmo3rBFdC0gPSOy4qb +/cycIMtK7VzZJeuzehfV6h+SOnolwFY0Zg9qv3z257FwDbDqf92d22dqymBrTaj2 +zC7eovvdSkGj53x3AsEE+hECgYAd7JJU3pi7h6AHw3vbvO1pqKHfpQYAp8/NOpWB +CsehQu9L32GcktJRMYQAqAVeDNEd1wCu6Gmsu46VVbnE0F/cWkx4/9PEGDMx+Lg4 +OrZBT8RY+1x2w+UatwyCtmtb+yFIET4866uWgZfeB6zaK9aCvuUPvDHrLfCHcPXs +yGRFmQKBgQDYvcjkUqKvnZwwpV6p6BHi6PuHWyjL+GLTk/IzsxOJykHzX4SV46Sd +9IxyE+OWZhkISBMSMe8wQC/S+QYs8hDbEE3WH1DXp33joyNgt2aWI0Splu7S1f6P +Pe08fYlLgz1xrbJuJAOgvkbskAUcmfmRwFeGaZSkkCtkzVxgN7LS3Q== +-----END RSA PRIVATE KEY-----`) + +var badCACert = []byte(`-----BEGIN CERTIFICATE----- +MIIDhDCCAmygAwIBAgIJAIabsJi6ILN7MA0GCSqGSIb3DQEBBQUAMDQxMjAwBgNV +BAMUKWdlbmVyaWNfd2ViaG9va19hZG1pc3Npb25fcGx1Z2luX3Rlc3RzX2NhMCAX +DTE3MDUwNDIyMzMyOFoYDzIyOTEwMjE3MjIzMzI4WjA0MTIwMAYDVQQDFClnZW5l +cmljX3dlYmhvb2tfYWRtaXNzaW9uX3BsdWdpbl90ZXN0c19jYTCCASIwDQYJKoZI +hvcNAQEBBQADggEPADCCAQoCggEBANjG6n5eZ1vq2AksP/htNMprQRJb08MxCPPL +IN5uT11NFzL+ck8o7fkt5oW8y2OOtLcun15RIr3jTT9G826NJPrb1I+x2zhuKuAr +NCVoi74m3IUV0C4pqrZ3bFbE9sY+epu6ExE1jGIY3fChc9bOsO8ivXQoXtqQQnnh +oyG0LNHXn/AX9OYewW7Jrl0G+OJ56VHVoBSPxa6nM+ozuo8kmFeVN2o6go3FjgUJ +l0bTItcHuPB3Y9reI1v3r20SKP7V/PJJblxUlQZk4rSgN559twMwPv6mupEw8M0P +Dp7YwtPSU+8pEu+uFi0YgTqPsAt2ujN3mTTJFutefgzT+8AI2BMCAwEAAaOBljCB +kzAdBgNVHQ4EFgQU9SUu1vDxGeyGEgy7pzZjLm+WSH0wZAYDVR0jBF0wW4AU9SUu +1vDxGeyGEgy7pzZjLm+WSH2hOKQ2MDQxMjAwBgNVBAMUKWdlbmVyaWNfd2ViaG9v +a19hZG1pc3Npb25fcGx1Z2luX3Rlc3RzX2NhggkAhpuwmLogs3swDAYDVR0TBAUw +AwEB/zANBgkqhkiG9w0BAQUFAAOCAQEAViib1ZcwLUi5YE279NRypsGLa8pfT/A8 +10u41L4xw+32mD2HojMmAlAF4jnC62exaXFsAYEsze4TF+0zqwDkHyGqViw/hKAv +SrGgPUX3C7wLyiMa3pjZfcQQy+80SKiJLeClxxjkdhO0mGNo1LdJThYU5IADHVtF +u2oOKLTjWBVzkMRkTXp5RReeEoUPvFgJhPKIVLggdXdJT8oQjgIVlx6IuzjU0AeM +tJ5AIWYrsqv9FlpfUXWjdiy8uF3iLWTOpd6pICnjzfj02wQouTEkxQ2iFinl7Das +iK+7d34q6Ww1/1nu4EBBDYB1VlWdhDLJVT4F+mF8wZFBu863Ba+U5Q== +-----END CERTIFICATE-----`) + +var serverKey = []byte(`-----BEGIN RSA PRIVATE KEY----- +MIIEowIBAAKCAQEAquioChbTG+iyTg3+SwGww/7yN84jtj55v8Xld8TgkyavSZm7 +Q6WURevvXtJHMug/NAz6qoCFy/zOiozUrMB36GFOA8MtwaOddCMdHMruX7q2+CiF +amhFg58uPfVr9qit20JiyBhaPH97WmQzwsfRQt4E1mCbEHZYK6r1RlF3i00nDCxc +741wuYp4Uc5oYM0dYoVCv11DYdf62v1grLFO02a5qjAULS74KKNaJ4YDOmqnTtWt +I75ZkEJSC2TT90o8eFIRuImY17venqIbp30A+XtvMhITg55648Zdci+CGHDcqJA1 +C+y0rxXZkcuFFFrK0tiN7K/cPK1LDtS08gp61QIDAQABAoIBAFhGVxTu+Rc/N2lt +fNzNALobIoyEYpms50GQO5eDDuOyZXNEfh7QlScQV9DIF5JJtutxkL8kJvdXmm6h +ku+vcb+LErqKw0Vy9s6XnF/UyQ6U6BCBDXgKZ202eLHz41HBihrnzRHA0krRJato +efuvLXy2JBV+TFlSZvQXFxy801wVIxlI7Jh94YR7CT3HYmD5qjAtiTVkSS+wmx++ +cfh0rrMulkXDEUlRPm/fXqt0do+neH9eNYee4h0mbZ84f3ecz/ql3HfkfoK9ZBq+ +M85VWnEvRetRF0AQlYK3IUPQH2XHIEZkUab8s+EZoIsgVBY9xvl6VTEQWFtbzRWC +7ozg2PkCgYEA3X8Pv6a5wzWBwX17RkTXcPqS44uzJ/K0adv1QSrVvXrZJQ0FEGqr +gR74aQXiaVl06X/N50y/soQIHWpvqGjoEbkA+jS5y4GkGxAviFFAuRnW9DyRePxH +nQiYFzgxBj55iDsdvdqJ2e0vM1EWNcaVmghNI25D+cBc4Qh1Zz4qFSsCgYEAxYg9 +vYGepcTMJ0/dk4SVMaITi/8nAnZHnptILpSjgPNp8udsovQPkymwo+EDnjQqVPO4 +OIIICEopk6Zup0Rq2iW3zRGbRJtp+uJ7mfFS+nT6sAe0tPWbGejwrudDDcx0fkx4 +C+1//rJ95H0c9L4nd51azCJD0k2yKtIFyj91r/8CgYBwMkWa8exU+oyQo2xHSuXK +n9K6GnCUwrcqjDWuXfFI+qp1vyOajj3zuOlh4Y4viRXUlV2KVXEhDwpBREHtD77G +A22AUCbw8+lZoBhDt8zONk2RCAE0RK5N2CWaVWdX31uWa0OEgOelESUAnIlgkggD +r0LLuLYME6m4f51gv7d3YwKBgCFp8He0C3AjIB2uRt8DWHFy5zeRS7oA5BCSV915 +S0cu5ccvGpNeEZxlOvodwAzs6hRAvfLhHBa65NmTF7i3vBN2uea4iblLSNwln57k +0ZKIYzePtiO+QCRb4QrVF+SnpzUOHmh2HmapLt6Nw24rFGYJeih5y1sxxWe060HR +BkllAoGBAMkT1a3BhhEwoyyKiwC05+gzlKfAWz7t6J//6/yx+82lXDk4/J455qcw +ny2y6P6r964EUoqMrAU0bdTs3sKDtOLdNMIt5RfoDBsdQDt2ktbv0pvii3E8SQFi +JuJWSenrfFaI+AgwE9jDo1Hy6dhF6/hnV3+QoznwEPRAO6wmPyVA +-----END RSA PRIVATE KEY-----`) + +var serverCert = []byte(`-----BEGIN CERTIFICATE----- +MIIDOzCCAiOgAwIBAgIJAN0PSMLOjTVAMA0GCSqGSIb3DQEBBQUAMDQxMjAwBgNV +BAMUKWdlbmVyaWNfd2ViaG9va19hZG1pc3Npb25fcGx1Z2luX3Rlc3RzX2NhMCAX +DTE3MDUwNDIyMzMyOFoYDzIyOTEwMjE3MjIzMzI4WjA4MTYwNAYDVQQDDC1nZW5l +cmljX3dlYmhvb2tfYWRtaXNzaW9uX3BsdWdpbl90ZXN0c19zZXJ2ZXIwggEiMA0G +CSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQCq6KgKFtMb6LJODf5LAbDD/vI3ziO2 +Pnm/xeV3xOCTJq9JmbtDpZRF6+9e0kcy6D80DPqqgIXL/M6KjNSswHfoYU4Dwy3B +o510Ix0cyu5furb4KIVqaEWDny499Wv2qK3bQmLIGFo8f3taZDPCx9FC3gTWYJsQ +dlgrqvVGUXeLTScMLFzvjXC5inhRzmhgzR1ihUK/XUNh1/ra/WCssU7TZrmqMBQt +Lvgoo1onhgM6aqdO1a0jvlmQQlILZNP3Sjx4UhG4iZjXu96eohunfQD5e28yEhOD +nnrjxl1yL4IYcNyokDUL7LSvFdmRy4UUWsrS2I3sr9w8rUsO1LTyCnrVAgMBAAGj +SjBIMAkGA1UdEwQCMAAwCwYDVR0PBAQDAgXgMB0GA1UdJQQWMBQGCCsGAQUFBwMC +BggrBgEFBQcDATAPBgNVHREECDAGhwR/AAABMA0GCSqGSIb3DQEBBQUAA4IBAQCA +/IFclqY/dsadGY0e9U3xq0XliIjZIHaI6OLXmO8xBdLDVf2+d6Bt3dl9gQMtGQI3 +zj9vqJrM+znXR30yAERFefZItk8hTzAotk15HYExVJkIn5JQBaXRbeO2DUZFgAnu +6OU6KnuVC6i+7xDlbMl8wtRPmeZ6FS1wW4wnxLWZtKYAuLVDs0ISy6qbznGhCkWc +b0uPbxnMmZHQLVL+yF1LYWpX9+Xa9QnWXOSY7KHtcuYXZB/XV4Pt6aDncg76bFdl +MG3bocbJ9MsoS/LdlAiYzLNmKlFa243QPOo/zN170NhEZaF1lM80YBaLIE4rRtga +nrkNOnPHx1evvuleH0Yv +-----END CERTIFICATE-----`) + +var clientKey = []byte(`-----BEGIN RSA PRIVATE KEY----- +MIIEogIBAAKCAQEA1Cf8Tch5IieN9zAI0b5FFJoMzPsN3RAYD5GAk3Xo9XNbKnvJ +38o2TKdxFu1/Q8hqG3668vWuL6TYDYVcYWtC6rwNpU/moAdJZyyEtmZIam1Va1VW +HDCpylsk4b8jbUxxm6OzF1XxZpBUJg9o/1OvL1XA0rmcDiddXN5VdL2adJZsS54t +gI57hcH0jkQocHTEv5gIl2tcTDStd4GeWxBmCotYdCQWcNk+96QMRxIai88aPPYw +QxpOQeaABrYrjPO1fJA0jimM9bhkjGXK6cnrRQrg3jkeF3vuPw9BFbE4VeENLY+y +v2OWBPYOD8ov0Tn2inge4QjE2DZyK95l46wQbwIDAQABAoIBAGZiAY1cALEt24H9 +yVPG+bluelz1jwQuvx3MPvtqvIivKcC/ynVYNYoaiCXjaTZB4orwRrH3RB8z8xvb +TvCofbugEwnDHG3/9jl3L3iCtdG+f6lznkGublH8WDklL6iQaocMoeHSFNRFNIbF +iwskzHcQcCSBdEEUWCb4GM9krMQz7pBR9BloyV40ZAGilMXI9F9FZ0YBWWee09gi +jid6sEzYQveZ5RQgEEDrE/i+jzXkU8sBKsSm1GuKH62+YrtelBqP83DwVIKthMOJ +79tw6i98v5JHV+1ikqC1Na/c7OxBBF3xrgwCN47ok0cHaIXh7SX+EIN6jcwKTmRH +VZQBz2ECgYEA6tTpJEKNAzEY3KOO578zY2hWhPUNqzUTm8yLxZedhDt2rwyWjd4J +HhS7CiNqFpMT/kxFyOupA8SFJPZDhJBXttnyzO9Sb61fLWgSP739K9OQfK1FGTA6 +khjc3vHtBeGrWm9+1jSxQtrwly7Rs+EmdvseDCN7yie/mgrBLS7p1l8CgYEA50fN +6BnbeAgCTK8GDBWSJPaYUlo/lebUDCn5QIp1LK93vPQWjJR8xRBwL1TbiVKGd054 +dRZVuJYMJx+2mbrt5ca9UArisZp5OZgR4xz29n9u69P5XiuG8Fq/JBJXp1GXONVx +JNOsUHOW/b3w2tNUWZcMQAH601BHOtO+EtaEX/ECgYBHygz4A8xeFG1YTjwKxt3r +3uLMRKoIE/LJp093eXEzEoam3v9LoXxCEO5ZHBh7jD0JecG/uaNyvmpBsXNUnFfk +U16xndwiveqh0/X4PJmgA05hfwrnt2HAdg9XrLfcG3Ap9nnc/EDQgmQYo7yB9Cux +JfW6mkJmu54Mdos1x+i+mwKBgHmewcGe71E0bPkkRLrQERUM89bCjJNoWfO3ktIE +vU9tSjr75GuyndYHKedJ6VRSKFHO2vs/bn5tsSBVxfEbYoSlOOJBhyo8AClwNV/H +2HqRUqQCySxjGUeFgOQYHS3ocuw5GZFzGjcIQctXObPo0391NcTnBZ5fpcVimZ5Q +XjYRAoGAN2O3HQjPyThoOUOn5MJEIx0L5bMvGNDzJO+oFngAntX/zv0zu/zsD9tc +kk4EbMLluiw2/XJvYjCStieaYxbSWioKwlThy39C+iUut+IbpP2eI46SOkhvPczt +4u1/sslqjs4ZSntR4Z9UOk3vY3oxRKbiXX2/vl9cqzB/cGYu01c= +-----END RSA PRIVATE KEY-----`) + +var clientCert = []byte(`-----BEGIN CERTIFICATE----- +MIIDOzCCAiOgAwIBAgIJAN0PSMLOjTVBMA0GCSqGSIb3DQEBBQUAMDQxMjAwBgNV +BAMUKWdlbmVyaWNfd2ViaG9va19hZG1pc3Npb25fcGx1Z2luX3Rlc3RzX2NhMCAX +DTE3MDUwNDIyMzMyOFoYDzIyOTEwMjE3MjIzMzI4WjA4MTYwNAYDVQQDDC1nZW5l +cmljX3dlYmhvb2tfYWRtaXNzaW9uX3BsdWdpbl90ZXN0c19jbGllbnQwggEiMA0G +CSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQDUJ/xNyHkiJ433MAjRvkUUmgzM+w3d +EBgPkYCTdej1c1sqe8nfyjZMp3EW7X9DyGobfrry9a4vpNgNhVxha0LqvA2lT+ag +B0lnLIS2ZkhqbVVrVVYcMKnKWyThvyNtTHGbo7MXVfFmkFQmD2j/U68vVcDSuZwO +J11c3lV0vZp0lmxLni2AjnuFwfSORChwdMS/mAiXa1xMNK13gZ5bEGYKi1h0JBZw +2T73pAxHEhqLzxo89jBDGk5B5oAGtiuM87V8kDSOKYz1uGSMZcrpyetFCuDeOR4X +e+4/D0EVsThV4Q0tj7K/Y5YE9g4Pyi/ROfaKeB7hCMTYNnIr3mXjrBBvAgMBAAGj +SjBIMAkGA1UdEwQCMAAwCwYDVR0PBAQDAgXgMB0GA1UdJQQWMBQGCCsGAQUFBwMC +BggrBgEFBQcDATAPBgNVHREECDAGhwR/AAABMA0GCSqGSIb3DQEBBQUAA4IBAQC/ +TBXk511JBKLosKVqrjluo8bzbgnREUrPcKclatiAOiIFKbMBy4nE4BlGZZW34t1u +sStB1dDHBHIuEkZxs93xwjqXN03yNNfve+FkRcb+guaZJEIBRlNocNxhd+lVDo8J +axRTdoOxyEOHGCjg+gyb0i9f/rqEqLnDwnYLZbH9Qbh/yv6OgISUTYOCzH35H0/6 +unY5JaBhRvmJHI0Z3KtmvMShbUyzoYD+oNLaS31fvoYIekcHsnOjZGBukaIx1bE1 +4SFjCUSPGDdzJdaYxQb0UXNI7oXKr6e6YeOrglIrVbboa0X3jtqGF1U7rop8ts3v +24SeXsvxqJht40itVvGK +-----END CERTIFICATE-----`) diff --git a/plugin/pkg/admission/webhook/config.go b/plugin/pkg/admission/webhook/config.go new file mode 100644 index 00000000000..8a70ac5de31 --- /dev/null +++ b/plugin/pkg/admission/webhook/config.go @@ -0,0 +1,120 @@ +/* +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 checks a webhook for configured operation admission +package webhook + +import ( + "fmt" + "strings" + "time" + + "k8s.io/apiserver/pkg/admission" +) + +const ( + defaultFailAction = Deny + defaultRetryBackoff = time.Duration(500) * time.Millisecond + errInvalidFailAction = "webhook rule (%d) has an invalid fail action: %s should be either %v or %v" + errInvalidRuleOperation = "webhook rule (%d) has an invalid operation (%d): %s" + errInvalidRuleType = "webhook rule (%d) has an invalid type: %s should be either %v or %v" + errMissingKubeConfigFile = "kubeConfigFile is required" + errOneRuleRequired = "webhook requires at least one rule defined" + errRetryBackoffOutOfRange = "webhook retry backoff is invalid: %v should be between %v and %v" + minRetryBackoff = time.Duration(1) * time.Millisecond + maxRetryBackoff = time.Duration(5) * time.Minute +) + +func normalizeConfig(config *GenericAdmissionWebhookConfig) error { + allowFailAction := string(Allow) + denyFailAction := string(Deny) + connectOperation := string(admission.Connect) + createOperation := string(admission.Create) + deleteOperation := string(admission.Delete) + updateOperation := string(admission.Update) + sendRuleType := string(Send) + skipRuleType := string(Skip) + + // Validate the kubeConfigFile property is present + if config.KubeConfigFile == "" { + return fmt.Errorf(errMissingKubeConfigFile) + } + + // Normalize and validate the retry backoff + if config.RetryBackoff == 0 { + config.RetryBackoff = defaultRetryBackoff + } else { + // Unmarshalling gives nanoseconds so convert to milliseconds + config.RetryBackoff *= time.Millisecond + } + + if config.RetryBackoff < minRetryBackoff || config.RetryBackoff > maxRetryBackoff { + return fmt.Errorf(errRetryBackoffOutOfRange, config.RetryBackoff, minRetryBackoff, maxRetryBackoff) + } + + // Validate that there is at least one rule + if len(config.Rules) == 0 { + return fmt.Errorf(errOneRuleRequired) + } + + for i, rule := range config.Rules { + // Normalize and validate the fail action + failAction := strings.ToUpper(string(rule.FailAction)) + + switch failAction { + case "": + config.Rules[i].FailAction = defaultFailAction + case allowFailAction: + config.Rules[i].FailAction = Allow + case denyFailAction: + config.Rules[i].FailAction = Deny + default: + return fmt.Errorf(errInvalidFailAction, i, rule.FailAction, Allow, Deny) + } + + // Normalize and validate the rule operation(s) + for j, operation := range rule.Operations { + operation := strings.ToUpper(string(operation)) + + switch operation { + case connectOperation: + config.Rules[i].Operations[j] = admission.Connect + case createOperation: + config.Rules[i].Operations[j] = admission.Create + case deleteOperation: + config.Rules[i].Operations[j] = admission.Delete + case updateOperation: + config.Rules[i].Operations[j] = admission.Update + default: + return fmt.Errorf(errInvalidRuleOperation, i, j, rule.Operations[j]) + } + } + + // Normalize and validate the rule type + ruleType := strings.ToUpper(string(rule.Type)) + + switch ruleType { + case sendRuleType: + config.Rules[i].Type = Send + case skipRuleType: + config.Rules[i].Type = Skip + default: + return fmt.Errorf(errInvalidRuleType, i, rule.Type, Send, Skip) + } + } + + return nil +} diff --git a/plugin/pkg/admission/webhook/config_test.go b/plugin/pkg/admission/webhook/config_test.go new file mode 100644 index 00000000000..16e342c5913 --- /dev/null +++ b/plugin/pkg/admission/webhook/config_test.go @@ -0,0 +1,220 @@ +/* +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 ( + "fmt" + "reflect" + "testing" + "time" + + "k8s.io/apiserver/pkg/admission" +) + +func TestConfigNormalization(t *testing.T) { + defaultRules := []Rule{ + Rule{ + Type: Skip, + }, + } + highRetryBackoff := (maxRetryBackoff / time.Millisecond) + (time.Duration(1) * time.Millisecond) + kubeConfigFile := "/tmp/kube/config" + lowRetryBackoff := time.Duration(-1) + normalizedValidRules := []Rule{ + Rule{ + APIGroups: []string{""}, + FailAction: Allow, + Namespaces: []string{"my-ns"}, + Operations: []admission.Operation{ + admission.Connect, + admission.Create, + admission.Delete, + admission.Update, + }, + Resources: []string{"pods"}, + ResourceNames: []string{"my-name"}, + Type: Send, + }, + Rule{ + FailAction: Deny, + Type: Skip, + }, + } + rawValidRules := []Rule{ + Rule{ + APIGroups: []string{""}, + FailAction: FailAction("AlLoW"), + Namespaces: []string{"my-ns"}, + Operations: []admission.Operation{ + admission.Operation("connect"), + admission.Operation("CREATE"), + admission.Operation("DeLeTe"), + admission.Operation("UPdaTE"), + }, + Resources: []string{"pods"}, + ResourceNames: []string{"my-name"}, + Type: RuleType("SenD"), + }, + Rule{ + FailAction: Deny, + Type: Skip, + }, + } + unknownFailAction := FailAction("Unknown") + unknownOperation := admission.Operation("Unknown") + unknownRuleType := RuleType("Allow") + tests := []struct { + test string + rawConfig GenericAdmissionWebhookConfig + normalizedConfig GenericAdmissionWebhookConfig + err error + }{ + { + test: "kubeConfigFile was not provided (error)", + rawConfig: GenericAdmissionWebhookConfig{}, + err: fmt.Errorf(errMissingKubeConfigFile), + }, + { + test: "retryBackoff was not provided (use default)", + rawConfig: GenericAdmissionWebhookConfig{ + KubeConfigFile: kubeConfigFile, + Rules: defaultRules, + }, + normalizedConfig: GenericAdmissionWebhookConfig{ + KubeConfigFile: kubeConfigFile, + RetryBackoff: defaultRetryBackoff, + Rules: defaultRules, + }, + }, + { + test: "retryBackoff was below minimum value (error)", + rawConfig: GenericAdmissionWebhookConfig{ + KubeConfigFile: kubeConfigFile, + RetryBackoff: lowRetryBackoff, + Rules: defaultRules, + }, + err: fmt.Errorf(errRetryBackoffOutOfRange, lowRetryBackoff*time.Millisecond, minRetryBackoff, maxRetryBackoff), + }, + { + test: "retryBackoff was above maximum value (error)", + rawConfig: GenericAdmissionWebhookConfig{ + KubeConfigFile: kubeConfigFile, + RetryBackoff: highRetryBackoff, + Rules: defaultRules, + }, + err: fmt.Errorf(errRetryBackoffOutOfRange, highRetryBackoff*time.Millisecond, minRetryBackoff, maxRetryBackoff), + }, + { + test: "rules should have at least one rule (error)", + rawConfig: GenericAdmissionWebhookConfig{ + KubeConfigFile: kubeConfigFile, + }, + err: fmt.Errorf(errOneRuleRequired), + }, + { + test: "fail action was not provided (use default)", + rawConfig: GenericAdmissionWebhookConfig{ + KubeConfigFile: kubeConfigFile, + Rules: []Rule{ + Rule{ + Type: Skip, + }, + }, + }, + normalizedConfig: GenericAdmissionWebhookConfig{ + KubeConfigFile: kubeConfigFile, + RetryBackoff: defaultRetryBackoff, + Rules: []Rule{ + Rule{ + FailAction: defaultFailAction, + Type: Skip, + }, + }, + }, + }, + { + test: "rule has invalid fail action (error)", + rawConfig: GenericAdmissionWebhookConfig{ + KubeConfigFile: kubeConfigFile, + Rules: []Rule{ + Rule{ + FailAction: unknownFailAction, + }, + }, + }, + err: fmt.Errorf(errInvalidFailAction, 0, unknownFailAction, Allow, Deny), + }, + { + test: "rule has invalid operation (error)", + rawConfig: GenericAdmissionWebhookConfig{ + KubeConfigFile: kubeConfigFile, + Rules: []Rule{ + Rule{ + Operations: []admission.Operation{unknownOperation}, + }, + }, + }, + err: fmt.Errorf(errInvalidRuleOperation, 0, 0, unknownOperation), + }, + { + test: "rule has invalid type (error)", + rawConfig: GenericAdmissionWebhookConfig{ + KubeConfigFile: kubeConfigFile, + Rules: []Rule{ + Rule{ + Type: unknownRuleType, + }, + }, + }, + err: fmt.Errorf(errInvalidRuleType, 0, unknownRuleType, Send, Skip), + }, + { + test: "valid configuration", + rawConfig: GenericAdmissionWebhookConfig{ + KubeConfigFile: kubeConfigFile, + Rules: rawValidRules, + }, + normalizedConfig: GenericAdmissionWebhookConfig{ + KubeConfigFile: kubeConfigFile, + RetryBackoff: defaultRetryBackoff, + Rules: normalizedValidRules, + }, + err: nil, + }, + } + + for _, tt := range tests { + err := normalizeConfig(&tt.rawConfig) + if err == nil { + if tt.err != nil { + // Ensure that expected errors are produced + t.Errorf("%s: expected error but did not produce one", tt.test) + } else if !reflect.DeepEqual(tt.rawConfig, tt.normalizedConfig) { + // Ensure that valid configurations are structured properly + t.Errorf("%s: normalized config mismtach. got: %v expected: %v", tt.test, tt.rawConfig, tt.normalizedConfig) + } + } else { + if tt.err == nil { + // Ensure that unexpected errors are not produced + t.Errorf("%s: unexpected error: %v", tt.test, err) + } else if err != nil && tt.err != nil && err.Error() != tt.err.Error() { + // Ensure that expected errors are formated properly + t.Errorf("%s: error message mismatch. got: '%v' expected: '%v'", tt.test, err, tt.err) + } + } + } +} diff --git a/plugin/pkg/admission/webhook/doc.go b/plugin/pkg/admission/webhook/doc.go new file mode 100644 index 00000000000..e4efbbeb210 --- /dev/null +++ b/plugin/pkg/admission/webhook/doc.go @@ -0,0 +1,18 @@ +/* +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 checks a webhook for configured operation admission +package webhook // import "k8s.io/kubernetes/plugin/pkg/admission/webhook" diff --git a/plugin/pkg/admission/webhook/gencerts.sh b/plugin/pkg/admission/webhook/gencerts.sh new file mode 100755 index 00000000000..7de850a590a --- /dev/null +++ b/plugin/pkg/admission/webhook/gencerts.sh @@ -0,0 +1,107 @@ +#!/bin/bash + +# 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. + +set -e + +# gencerts.sh generates the certificates for the generic webhook admission plugin tests. +# +# It is not expected to be run often (there is no go generate rule), and mainly +# exists for documentation purposes. + +CN_BASE="generic_webhook_admission_plugin_tests" + +cat > server.conf << EOF +[req] +req_extensions = v3_req +distinguished_name = req_distinguished_name +[req_distinguished_name] +[ v3_req ] +basicConstraints = CA:FALSE +keyUsage = nonRepudiation, digitalSignature, keyEncipherment +extendedKeyUsage = clientAuth, serverAuth +subjectAltName = @alt_names +[alt_names] +IP.1 = 127.0.0.1 +EOF + +cat > client.conf << EOF +[req] +req_extensions = v3_req +distinguished_name = req_distinguished_name +[req_distinguished_name] +[ v3_req ] +basicConstraints = CA:FALSE +keyUsage = nonRepudiation, digitalSignature, keyEncipherment +extendedKeyUsage = clientAuth, serverAuth +subjectAltName = @alt_names +[alt_names] +IP.1 = 127.0.0.1 +EOF + +# Create a certificate authority +openssl genrsa -out caKey.pem 2048 +openssl req -x509 -new -nodes -key caKey.pem -days 100000 -out caCert.pem -subj "/CN=${CN_BASE}_ca" + +# Create a second certificate authority +openssl genrsa -out badCAKey.pem 2048 +openssl req -x509 -new -nodes -key badCAKey.pem -days 100000 -out badCACert.pem -subj "/CN=${CN_BASE}_ca" + +# Create a server certiticate +openssl genrsa -out serverKey.pem 2048 +openssl req -new -key serverKey.pem -out server.csr -subj "/CN=${CN_BASE}_server" -config server.conf +openssl x509 -req -in server.csr -CA caCert.pem -CAkey caKey.pem -CAcreateserial -out serverCert.pem -days 100000 -extensions v3_req -extfile server.conf + +# Create a client certiticate +openssl genrsa -out clientKey.pem 2048 +openssl req -new -key clientKey.pem -out client.csr -subj "/CN=${CN_BASE}_client" -config client.conf +openssl x509 -req -in client.csr -CA caCert.pem -CAkey caKey.pem -CAcreateserial -out clientCert.pem -days 100000 -extensions v3_req -extfile client.conf + +outfile=certs_test.go + +cat > $outfile << EOF +/* +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. +*/ + +EOF + +echo "// This file was generated using openssl by the gencerts.sh script" >> $outfile +echo "// and holds raw certificates for the webhook tests." >> $outfile +echo "" >> $outfile +echo "package webhook" >> $outfile +for file in caKey caCert badCAKey badCACert serverKey serverCert clientKey clientCert; do + data=$(cat ${file}.pem) + echo "" >> $outfile + echo "var $file = []byte(\`$data\`)" >> $outfile +done + +# Clean up after we're done. +rm *.pem +rm *.csr +rm *.srl +rm *.conf \ No newline at end of file diff --git a/plugin/pkg/admission/webhook/rules.go b/plugin/pkg/admission/webhook/rules.go new file mode 100644 index 00000000000..99521e87c65 --- /dev/null +++ b/plugin/pkg/admission/webhook/rules.go @@ -0,0 +1,98 @@ +/* +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 checks a webhook for configured operation admission +package webhook + +import "k8s.io/apiserver/pkg/admission" + +func indexOf(items []string, requested string) int { + for i, item := range items { + if item == requested { + return i + } + } + + return -1 +} + +// APIGroupMatches returns if the admission.Attributes matches the rule's API groups +func APIGroupMatches(rule Rule, attr admission.Attributes) bool { + if len(rule.APIGroups) == 0 { + return true + } + + return indexOf(rule.APIGroups, attr.GetResource().Group) > -1 +} + +// Matches returns if the admission.Attributes matches the rule +func Matches(rule Rule, attr admission.Attributes) bool { + return APIGroupMatches(rule, attr) && + NamespaceMatches(rule, attr) && + OperationMatches(rule, attr) && + ResourceMatches(rule, attr) && + ResourceNamesMatches(rule, attr) +} + +// OperationMatches returns if the admission.Attributes matches the rule's operation +func OperationMatches(rule Rule, attr admission.Attributes) bool { + if len(rule.Operations) == 0 { + return true + } + + aOp := attr.GetOperation() + + for _, rOp := range rule.Operations { + if aOp == rOp { + return true + } + } + + return false +} + +// NamespaceMatches returns if the admission.Attributes matches the rule's namespaces +func NamespaceMatches(rule Rule, attr admission.Attributes) bool { + if len(rule.Namespaces) == 0 { + return true + } + + return indexOf(rule.Namespaces, attr.GetNamespace()) > -1 +} + +// ResourceMatches returns if the admission.Attributes matches the rule's resource (and optional subresource) +func ResourceMatches(rule Rule, attr admission.Attributes) bool { + if len(rule.Resources) == 0 { + return true + } + + resource := attr.GetResource().Resource + + if len(attr.GetSubresource()) > 0 { + resource = attr.GetResource().Resource + "/" + attr.GetSubresource() + } + + return indexOf(rule.Resources, resource) > -1 +} + +// ResourceNamesMatches returns if the admission.Attributes matches the rule's resource names +func ResourceNamesMatches(rule Rule, attr admission.Attributes) bool { + if len(rule.ResourceNames) == 0 { + return true + } + + return indexOf(rule.ResourceNames, attr.GetName()) > -1 +} diff --git a/plugin/pkg/admission/webhook/rules_test.go b/plugin/pkg/admission/webhook/rules_test.go new file mode 100644 index 00000000000..036dd8059ff --- /dev/null +++ b/plugin/pkg/admission/webhook/rules_test.go @@ -0,0 +1,309 @@ +/* +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 ( + "fmt" + "strings" + "testing" + + "k8s.io/apiserver/pkg/admission" + "k8s.io/client-go/pkg/api" +) + +type ruleTest struct { + test string + rule Rule + attrAPIGroup string + attrNamespace string + attrOperation admission.Operation + attrResource string + attrResourceName string + attrSubResource string + shouldMatch bool +} + +func TestAPIGroupMatches(t *testing.T) { + tests := []ruleTest{ + ruleTest{ + test: "apiGroups empty match", + rule: Rule{}, + shouldMatch: true, + }, + ruleTest{ + test: "apiGroup match", + rule: Rule{ + APIGroups: []string{"my-group"}, + }, + attrAPIGroup: "my-group", + shouldMatch: true, + }, + ruleTest{ + test: "apiGroup mismatch", + rule: Rule{ + APIGroups: []string{"my-group"}, + }, + attrAPIGroup: "your-group", + shouldMatch: false, + }, + } + + runTests(t, "apiGroups", tests) +} + +func TestMatches(t *testing.T) { + tests := []ruleTest{ + ruleTest{ + test: "empty rule matches", + rule: Rule{}, + shouldMatch: true, + }, + ruleTest{ + test: "all properties match", + rule: Rule{ + APIGroups: []string{"my-group"}, + Namespaces: []string{"my-ns"}, + Operations: []admission.Operation{admission.Create}, + ResourceNames: []string{"my-name"}, + Resources: []string{"pods/status"}, + }, + shouldMatch: true, + attrAPIGroup: "my-group", + attrNamespace: "my-ns", + attrOperation: admission.Create, + attrResource: "pods", + attrResourceName: "my-name", + attrSubResource: "status", + }, + ruleTest{ + test: "no properties match", + rule: Rule{ + APIGroups: []string{"my-group"}, + Namespaces: []string{"my-ns"}, + Operations: []admission.Operation{admission.Create}, + ResourceNames: []string{"my-name"}, + Resources: []string{"pods/status"}, + }, + shouldMatch: false, + attrAPIGroup: "your-group", + attrNamespace: "your-ns", + attrOperation: admission.Delete, + attrResource: "secrets", + attrResourceName: "your-name", + }, + } + + runTests(t, "", tests) +} + +func TestNamespaceMatches(t *testing.T) { + tests := []ruleTest{ + ruleTest{ + test: "namespaces empty match", + rule: Rule{}, + shouldMatch: true, + }, + ruleTest{ + test: "namespace match", + rule: Rule{ + Namespaces: []string{"my-ns"}, + }, + attrNamespace: "my-ns", + shouldMatch: true, + }, + ruleTest{ + test: "namespace mismatch", + rule: Rule{ + Namespaces: []string{"my-ns"}, + }, + attrNamespace: "your-ns", + shouldMatch: false, + }, + } + + runTests(t, "namespaces", tests) +} + +func TestOperationMatches(t *testing.T) { + tests := []ruleTest{ + ruleTest{ + test: "operations empty match", + rule: Rule{}, + shouldMatch: true, + }, + ruleTest{ + test: "operation match", + rule: Rule{ + Operations: []admission.Operation{admission.Create}, + }, + attrOperation: admission.Create, + shouldMatch: true, + }, + ruleTest{ + test: "operation mismatch", + rule: Rule{ + Operations: []admission.Operation{admission.Create}, + }, + attrOperation: admission.Delete, + shouldMatch: false, + }, + } + + runTests(t, "operations", tests) +} + +func TestResourceMatches(t *testing.T) { + tests := []ruleTest{ + ruleTest{ + test: "resources empty match", + rule: Rule{}, + shouldMatch: true, + }, + ruleTest{ + test: "resource match", + rule: Rule{ + Resources: []string{"pods"}, + }, + attrResource: "pods", + shouldMatch: true, + }, + ruleTest{ + test: "resource mismatch", + rule: Rule{ + Resources: []string{"pods"}, + }, + attrResource: "secrets", + shouldMatch: false, + }, + ruleTest{ + test: "resource with subresource match", + rule: Rule{ + Resources: []string{"pods/status"}, + }, + attrResource: "pods", + attrSubResource: "status", + shouldMatch: true, + }, + ruleTest{ + test: "resource with subresource mismatch", + rule: Rule{ + Resources: []string{"pods"}, + }, + attrResource: "pods", + attrSubResource: "status", + shouldMatch: false, + }, + } + + runTests(t, "resources", tests) +} + +func TestResourceNameMatches(t *testing.T) { + tests := []ruleTest{ + ruleTest{ + test: "resourceNames empty match", + rule: Rule{}, + shouldMatch: true, + }, + ruleTest{ + test: "resourceName match", + rule: Rule{ + ResourceNames: []string{"my-name"}, + }, + attrResourceName: "my-name", + shouldMatch: true, + }, + ruleTest{ + test: "resourceName mismatch", + rule: Rule{ + ResourceNames: []string{"my-name"}, + }, + attrResourceName: "your-name", + shouldMatch: false, + }, + } + + runTests(t, "resourceNames", tests) +} + +func runTests(t *testing.T, prop string, tests []ruleTest) { + for _, tt := range tests { + if tt.attrResource == "" { + tt.attrResource = "pods" + } + + res := api.Resource(tt.attrResource).WithVersion("version") + + if tt.attrAPIGroup != "" { + res.Group = tt.attrAPIGroup + } + + attr := admission.NewAttributesRecord(nil, nil, api.Kind("Pod").WithVersion("version"), tt.attrNamespace, tt.attrResourceName, res, tt.attrSubResource, tt.attrOperation, nil) + var attrVal string + var ruleVal []string + var matches bool + + switch prop { + case "": + matches = Matches(tt.rule, attr) + case "apiGroups": + attrVal = tt.attrAPIGroup + matches = APIGroupMatches(tt.rule, attr) + ruleVal = tt.rule.APIGroups + case "namespaces": + attrVal = tt.attrNamespace + matches = NamespaceMatches(tt.rule, attr) + ruleVal = tt.rule.Namespaces + case "operations": + attrVal = string(tt.attrOperation) + matches = OperationMatches(tt.rule, attr) + ruleVal = make([]string, len(tt.rule.Operations)) + + for _, rOp := range tt.rule.Operations { + ruleVal = append(ruleVal, string(rOp)) + } + case "resources": + attrVal = tt.attrResource + matches = ResourceMatches(tt.rule, attr) + ruleVal = tt.rule.Resources + case "resourceNames": + attrVal = tt.attrResourceName + matches = ResourceNamesMatches(tt.rule, attr) + ruleVal = tt.rule.ResourceNames + default: + t.Errorf("Unexpected test property: %s", prop) + } + + if matches && !tt.shouldMatch { + if prop == "" { + testError(t, tt.test, "Expected match") + } else { + testError(t, tt.test, fmt.Sprintf("Expected %s rule property not to match %s against one of the following: %s", prop, attrVal, strings.Join(ruleVal, ", "))) + } + } else if !matches && tt.shouldMatch { + if prop == "" { + testError(t, tt.test, "Unexpected match") + } else { + testError(t, tt.test, fmt.Sprintf("Expected %s rule property to match %s against one of the following: %s", prop, attrVal, strings.Join(ruleVal, ", "))) + } + } + } +} + +func testError(t *testing.T, name, msg string) { + t.Errorf("test failed (%s): %s", name, msg) +} diff --git a/plugin/pkg/admission/webhook/types.go b/plugin/pkg/admission/webhook/types.go new file mode 100644 index 00000000000..157fdb1edc3 --- /dev/null +++ b/plugin/pkg/admission/webhook/types.go @@ -0,0 +1,69 @@ +/* +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 checks a webhook for configured operation admission +package webhook + +import ( + "time" + + "k8s.io/apiserver/pkg/admission" +) + +// FailAction is the action to take whenever a webhook fails and there are no more retries available +type FailAction string + +const ( + // Allow is the fail action taken whenever the webhook call fails and there are no more retries + Allow FailAction = "ALLOW" + // Deny is the fail action taken whenever the webhook call fails and there are no more retries + Deny FailAction = "DENY" +) + +// GenericAdmissionWebhookConfig holds configuration for an admission webhook +type GenericAdmissionWebhookConfig struct { + KubeConfigFile string `json:"kubeConfigFile"` + RetryBackoff time.Duration `json:"retryBackoff"` + Rules []Rule `json:"rules"` +} + +// Rule is the type defining an admission rule in the admission controller configuration file +type Rule struct { + // APIGroups is a list of API groups that contain the resource this rule applies to. + APIGroups []string `json:"apiGroups"` + // FailAction is the action to take whenever the webhook fails and there are no more retries (Default: DENY) + FailAction FailAction `json:"failAction"` + // Namespaces is a list of namespaces this rule applies to. + Namespaces []string `json:"namespaces"` + // Operations is a list of admission operations this rule applies to. + Operations []admission.Operation `json:"operations"` + // Resources is a list of resources this rule applies to. + Resources []string `json:"resources"` + // ResourceNames is a list of resource names this rule applies to. + ResourceNames []string `json:"resourceNames"` + // Type is the admission rule type + Type RuleType `json:"type"` +} + +// RuleType is the type of admission rule +type RuleType string + +const ( + // Send is the rule type for when a matching admission.Attributes should be sent to the webhook + Send RuleType = "SEND" + // Skip is the rule type for when a matching admission.Attributes should not be sent to the webhook + Skip RuleType = "SKIP" +)