diff --git a/webhook/Dockerfile b/webhook/Dockerfile new file mode 100644 index 000000000..7237a773f --- /dev/null +++ b/webhook/Dockerfile @@ -0,0 +1,19 @@ +FROM golang:1.10 as builder + +RUN go get github.com/Masterminds/glide + +ENV PKG_NAME=github.com/intel/multus-cni/webhook +ENV PKG_PATH=$GOPATH/src/$PKG_NAME +WORKDIR $PKG_PATH + +COPY *.go glide.yaml $PKG_PATH/ +RUN glide install -v +RUN go build -v -o webhook + +FROM golang:1.10 +ENV PKG_NAME=github.com/intel/multus-cni/webhook +ENV PKG_PATH=$GOPATH/src/$PKG_NAME +RUN mkdir /webhook +COPY --from=builder $PKG_PATH/webhook /webhook/ +WORKDIR /webhook +CMD ["./webhook"] diff --git a/webhook/build b/webhook/build new file mode 100755 index 000000000..2f03b48f0 --- /dev/null +++ b/webhook/build @@ -0,0 +1 @@ +docker build -t multus-webhook . diff --git a/webhook/glide.yaml b/webhook/glide.yaml new file mode 100644 index 000000000..e55de86d2 --- /dev/null +++ b/webhook/glide.yaml @@ -0,0 +1,26 @@ +package: github.com/intel/multus-cni/webhook +import: +- package: github.com/containernetworking/cni + version: ~0.7.0-alpha1 + subpackages: + - libcni +- package: github.com/intel/multus-cni + version: ~3.1.0 + subpackages: + - logging + - types +- package: k8s.io/api + subpackages: + - admission/v1beta1 +- package: k8s.io/apimachinery + subpackages: + - pkg/apis/meta/v1 + - pkg/runtime + - pkg/runtime/serializer +testImport: +- package: github.com/onsi/ginkgo + version: ~1.6.0 + subpackages: + - extensions/table +- package: github.com/onsi/gomega + version: ~1.4.1 diff --git a/webhook/webhook.go b/webhook/webhook.go new file mode 100644 index 000000000..df5505495 --- /dev/null +++ b/webhook/webhook.go @@ -0,0 +1,190 @@ +// Copyright (c) 2018 Intel Corporation +// +// 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 main + +import ( + "encoding/json" + "flag" + "fmt" + "io/ioutil" + "net/http" + "regexp" + + "github.com/intel/multus-cni/logging" + "github.com/intel/multus-cni/types" + + "github.com/containernetworking/cni/libcni" + "k8s.io/api/admission/v1beta1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/serializer" +) + +func ValidateNetworkAttachmentDefinition(netAttachDef types.NetworkAttachmentDefinition) (bool, error) { + nameRegex := `^[a-z0-9]([-a-z0-9]*[a-z0-9])?$` + isNameCorrect, err := regexp.MatchString(nameRegex, netAttachDef.Metadata.Name) + if !isNameCorrect { + logging.Errorf("Invalid name.") + return false, fmt.Errorf("Invalid name") + } + if err != nil { + logging.Errorf("Error validating name: %s.", err) + return false, err + } + + if netAttachDef.Spec.Config == "" { + logging.Errorf("Network Config is empty.") + return false, fmt.Errorf("Network Config is empty") + } + + logging.Printf(logging.DebugLevel, "Validating network config spec: %s", netAttachDef.Spec.Config) + + /* try to unmarshal config into NetworkConfig or NetworkConfigList + using actual code from libcni - if succesful, it means that the config + will be accepted by CNI itseld as well */ + confBytes := []byte(netAttachDef.Spec.Config) + _, err = libcni.ConfListFromBytes(confBytes) + if err != nil { + logging.Printf(logging.DebugLevel, "Spec is not a valid network config: %s. Trying to parse into config list", err) + _, err = libcni.ConfFromBytes(confBytes) + if err != nil { + logging.Printf(logging.DebugLevel, "Spec is not a valid network config list: %s", err) + logging.Errorf("Invalid config: %s", err) + return false, fmt.Errorf("Invalid network config spec") + } + } + + logging.Printf(logging.DebugLevel, "Network Attachment Defintion is valid. Admission Review request allowed") + return true, nil +} + +func prepareAdmissionReviewResponse(allowed bool, message string, ar *v1beta1.AdmissionReview) error { + if ar.Request != nil { + ar.Response = &v1beta1.AdmissionResponse{ + UID: ar.Request.UID, + Allowed: allowed, + } + if message != "" { + ar.Response.Result = &metav1.Status{ + Message: message, + } + } + } + return nil +} + +func deserializeAdmissionReview(body []byte) (v1beta1.AdmissionReview, error) { + ar := v1beta1.AdmissionReview{} + runtimeScheme := runtime.NewScheme() + codecs := serializer.NewCodecFactory(runtimeScheme) + deserializer := codecs.UniversalDeserializer() + _, _, err := deserializer.Decode(body, nil, &ar) + + /* Decode() won't return an error if the data wasn't actual AdmissionReview */ + if err == nil && ar.TypeMeta.Kind != "AdmissionReview" { + err = fmt.Errorf("Object is not an AdmissionReview") + } + + return ar, err +} + +func deserializeNetworkAttachmentDefinition(ar v1beta1.AdmissionReview) (types.NetworkAttachmentDefinition, error) { + /* unmarshal NetworkAttachmentDefinition from AdmissionReview request */ + netAttachDef := types.NetworkAttachmentDefinition{} + err := json.Unmarshal(ar.Request.Object.Raw, &netAttachDef) + return netAttachDef, err +} + +func handleValidationError(w http.ResponseWriter, ar v1beta1.AdmissionReview, err error) { + prepareAdmissionReviewResponse(false, err.Error(), &ar) + writeResponse(w, ar) +} + +func writeResponse(w http.ResponseWriter, ar v1beta1.AdmissionReview) { + logging.Printf(logging.DebugLevel, "Sending response to the API server") + resp, _ := json.Marshal(ar) + w.Write(resp) +} + +func validateHandler(w http.ResponseWriter, req *http.Request) { + var body []byte + + if req.Body != nil { + if data, err := ioutil.ReadAll(req.Body); err == nil { + body = data + } + } + + if len(body) == 0 { + logging.Errorf("Error reading HTTP request: empty body") + http.Error(w, "Error reading HTTP request: empty body", http.StatusBadRequest) + return + } + + /* validate HTTP request headers */ + contentType := req.Header.Get("Content-Type") + if contentType != "application/json" { + logging.Errorf("Invalid Content-Type='%s', expected 'application/json'", contentType) + http.Error(w, "Invalid Content-Type='%s', expected 'application/json'", http.StatusUnsupportedMediaType) + return + } + + /* read AdmissionReview from the request body */ + ar, err := deserializeAdmissionReview(body) + if err != nil { + logging.Errorf("Error deserializing AdmissionReview: %s", err.Error()) + http.Error(w, fmt.Sprintf("Error deserializing AdmissionReview: %s", err.Error()), http.StatusBadRequest) + return + } + + netAttachDef, err := deserializeNetworkAttachmentDefinition(ar) + if err != nil { + handleValidationError(w, ar, err) + return + } + + /* perform actual object validation */ + allowed, err := ValidateNetworkAttachmentDefinition(netAttachDef) + if err != nil { + handleValidationError(w, ar, err) + return + } + + prepareAdmissionReviewResponse(allowed, "", &ar) + writeResponse(w, ar) +} + +func main() { + /* load configuration */ + port := flag.Int("port", 443, "The port on which to serve.") + address := flag.String("bind-address", "0.0.0.0", "The IP address on which to listen for the --port port.") + cert := flag.String("tls-cert-file", "cert.pem", "File containing the default x509 Certificate for HTTPS.") + key := flag.String("tls-private-key-file", "key.pem", "File containing the default x509 private key matching --tls-cert-file.") + flag.Parse() + + /* enable logging */ + logging.SetLogLevel("debug") + logging.Printf(logging.DebugLevel, "Starting Multus webhook server") + + /* register handlers */ + http.HandleFunc("/validate", validateHandler) + + /* start serving */ + err := http.ListenAndServeTLS(fmt.Sprintf("%s:%d", *address, *port), *cert, *key, nil) + if err != nil { + logging.Errorf("Error starting web server: %s", err.Error()) + return + } +} diff --git a/webhook/webhook_suite_test.go b/webhook/webhook_suite_test.go new file mode 100644 index 000000000..593f2eba6 --- /dev/null +++ b/webhook/webhook_suite_test.go @@ -0,0 +1,27 @@ +// Copyright (c) 2017 Intel Corporation +// +// 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 main_test + +import ( + "testing" + + . "github.com/onsi/ginkgo" + . "github.com/onsi/gomega" +) + +func TestWebhook(t *testing.T) { + RegisterFailHandler(Fail) + RunSpecs(t, "Webhook Suite") +} diff --git a/webhook/webhook_test.go b/webhook/webhook_test.go new file mode 100644 index 000000000..d55d39f0c --- /dev/null +++ b/webhook/webhook_test.go @@ -0,0 +1,117 @@ +// Copyright (c) 2017 Intel Corporation +// +// 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 main_test + +import ( + . "github.com/onsi/ginkgo" + . "github.com/onsi/ginkgo/extensions/table" + . "github.com/onsi/gomega" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + "github.com/intel/multus-cni/types" + . "github.com/intel/multus-cni/webhook" +) + +var _ = Describe("Webhook", func() { + DescribeTable("Network Attachment Definition validation", + func(in types.NetworkAttachmentDefinition, out bool, shouldFail bool) { + actualOut, err := ValidateNetworkAttachmentDefinition(in) + Expect(actualOut).To(Equal(out)) + if shouldFail { + Expect(err).To(HaveOccurred()) + } + }, + Entry( + "empty config", + types.NetworkAttachmentDefinition{ + Metadata: metav1.ObjectMeta{ + Name: "some-valid-name", + }, + }, + false, true, + ), + Entry( + "invalid name", + types.NetworkAttachmentDefinition{ + Metadata: metav1.ObjectMeta{ + Name: "some_invalid_name", + }, + }, + false, true, + ), + Entry( + "invalid network config", + types.NetworkAttachmentDefinition{ + Metadata: metav1.ObjectMeta{ + Name: "some-valid-name", + }, + Spec: types.NetworkAttachmentDefinitionSpec{ + Config: `{"some-invalid": "config"}`, + }, + }, + false, true, + ), + Entry( + "valid network config", + types.NetworkAttachmentDefinition{ + Metadata: metav1.ObjectMeta{ + Name: "some-valid-name", + }, + Spec: types.NetworkAttachmentDefinitionSpec{ + Config: `{ + "cniVersion": "0.3.0", + "type": "some-plugin" + }`, + }, + }, + true, false, + ), + Entry( + "valid network config list", + types.NetworkAttachmentDefinition{ + Metadata: metav1.ObjectMeta{ + Name: "some-valid-name", + }, + Spec: types.NetworkAttachmentDefinitionSpec{ + Config: `{ + "cniVersion": "0.3.0", + "name": "some-bridge-network", + "plugins": [ + { + "type": "bridge", + "bridge": "br0", + "ipam": { + "type": "host-local", + "subnet": "192.168.1.0/24" + } + }, + { + "type": "some-plugin" + }, + { + "type": "another-plugin", + "sysctl": { + "net.ipv4.conf.all.log_martians": "1" + } + } + ] + }`, + }, + }, + true, false, + ), + ) +})