istio.io/istio@v0.0.0-20240520182934-d79c90f27776/pkg/webhooks/validation/server/server.go (about)

     1  // Copyright Istio Authors
     2  //
     3  // Licensed under the Apache License, Version 2.0 (the "License");
     4  // you may not use this file except in compliance with the License.
     5  // You may obtain a copy of the License at
     6  //
     7  //     http://www.apache.org/licenses/LICENSE-2.0
     8  //
     9  // Unless required by applicable law or agreed to in writing, software
    10  // distributed under the License is distributed on an "AS IS" BASIS,
    11  // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
    12  // See the License for the specific language governing permissions and
    13  // limitations under the License.
    14  
    15  package server
    16  
    17  import (
    18  	"bytes"
    19  	"encoding/json"
    20  	"errors"
    21  	"fmt"
    22  	"net/http"
    23  
    24  	multierror "github.com/hashicorp/go-multierror"
    25  	admissionv1 "k8s.io/api/admission/v1"
    26  	kubeApiAdmissionv1beta1 "k8s.io/api/admission/v1beta1"
    27  	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
    28  	"k8s.io/apimachinery/pkg/runtime"
    29  	"k8s.io/apimachinery/pkg/runtime/serializer"
    30  
    31  	"istio.io/istio/pilot/pkg/config/kube/crd"
    32  	"istio.io/istio/pkg/config/constants"
    33  	"istio.io/istio/pkg/config/schema/collection"
    34  	"istio.io/istio/pkg/config/schema/resource"
    35  	"istio.io/istio/pkg/config/validation"
    36  	"istio.io/istio/pkg/kube"
    37  	"istio.io/istio/pkg/log"
    38  )
    39  
    40  var scope = log.RegisterScope("validationServer", "validation webhook server")
    41  
    42  var (
    43  	runtimeScheme = runtime.NewScheme()
    44  	codecs        = serializer.NewCodecFactory(runtimeScheme)
    45  	deserializer  = codecs.UniversalDeserializer()
    46  
    47  	// Expect AdmissionRequest to only include these top-level field names
    48  	validFields = map[string]bool{
    49  		"apiVersion": true,
    50  		"kind":       true,
    51  		"metadata":   true,
    52  		"spec":       true,
    53  		"status":     true,
    54  	}
    55  )
    56  
    57  func init() {
    58  	_ = admissionv1.AddToScheme(runtimeScheme)
    59  	_ = kubeApiAdmissionv1beta1.AddToScheme(runtimeScheme)
    60  }
    61  
    62  // Options contains the configuration for the Istio Pilot validation
    63  // admission controller.
    64  type Options struct {
    65  	// Schemas provides a description of all configuration resources.
    66  	Schemas collection.Schemas
    67  
    68  	// DomainSuffix is the DNS domain suffix for Pilot CRD resources,
    69  	// e.g. cluster.local.
    70  	DomainSuffix string
    71  
    72  	// Port where the webhook is served. the number should be greater than 1024 for non-root
    73  	// user, because non-root user cannot bind port number less than 1024
    74  	// Mainly used for testing. Webhook server is started by Istiod.
    75  	Port uint
    76  
    77  	// Use an existing mux instead of creating our own.
    78  	Mux *http.ServeMux
    79  }
    80  
    81  // String produces a stringified version of the arguments for debugging.
    82  func (o Options) String() string {
    83  	buf := &bytes.Buffer{}
    84  
    85  	_, _ = fmt.Fprintf(buf, "DomainSuffix: %s\n", o.DomainSuffix)
    86  	_, _ = fmt.Fprintf(buf, "Port: %d\n", o.Port)
    87  
    88  	return buf.String()
    89  }
    90  
    91  // DefaultArgs allocates an Options struct initialized with Webhook's default configuration.
    92  func DefaultArgs() Options {
    93  	return Options{
    94  		Port: 9443,
    95  	}
    96  }
    97  
    98  // Webhook implements the validating admission webhook for validating Istio configuration.
    99  type Webhook struct {
   100  	// pilot
   101  	schemas      collection.Schemas
   102  	domainSuffix string
   103  }
   104  
   105  // New creates a new instance of the admission webhook server.
   106  func New(o Options) (*Webhook, error) {
   107  	if o.Mux == nil {
   108  		scope.Error("mux not set correctly")
   109  		return nil, errors.New("expected mux to be passed, but was not passed")
   110  	}
   111  	wh := &Webhook{
   112  		schemas:      o.Schemas,
   113  		domainSuffix: o.DomainSuffix,
   114  	}
   115  
   116  	o.Mux.HandleFunc("/validate", wh.serveValidate)
   117  	o.Mux.HandleFunc("/validate/", wh.serveValidate)
   118  
   119  	return wh, nil
   120  }
   121  
   122  func toAdmissionResponse(err error) *kube.AdmissionResponse {
   123  	return &kube.AdmissionResponse{Result: &metav1.Status{Message: err.Error()}}
   124  }
   125  
   126  type admitFunc func(*kube.AdmissionRequest) *kube.AdmissionResponse
   127  
   128  func serve(w http.ResponseWriter, r *http.Request, admit admitFunc) {
   129  	var body []byte
   130  	if r.Body != nil {
   131  		if data, err := kube.HTTPConfigReader(r); err == nil {
   132  			body = data
   133  		} else {
   134  			http.Error(w, err.Error(), http.StatusBadRequest)
   135  			return
   136  		}
   137  	}
   138  	if len(body) == 0 {
   139  		reportValidationHTTPError(http.StatusBadRequest)
   140  		http.Error(w, "no body found", http.StatusBadRequest)
   141  		return
   142  	}
   143  
   144  	// verify the content type is accurate
   145  	contentType := r.Header.Get("Content-Type")
   146  	if contentType != "application/json" {
   147  		reportValidationHTTPError(http.StatusUnsupportedMediaType)
   148  		http.Error(w, "invalid Content-Type, want `application/json`", http.StatusUnsupportedMediaType)
   149  		return
   150  	}
   151  
   152  	var reviewResponse *kube.AdmissionResponse
   153  	var obj runtime.Object
   154  	var ar *kube.AdmissionReview
   155  	if out, _, err := deserializer.Decode(body, nil, obj); err != nil {
   156  		reviewResponse = toAdmissionResponse(fmt.Errorf("could not decode body: %v", err))
   157  	} else {
   158  		ar, err = kube.AdmissionReviewKubeToAdapter(out)
   159  		if err != nil {
   160  			reviewResponse = toAdmissionResponse(fmt.Errorf("could not decode object: %v", err))
   161  		} else {
   162  			reviewResponse = admit(ar.Request)
   163  		}
   164  	}
   165  
   166  	response := kube.AdmissionReview{}
   167  	response.Response = reviewResponse
   168  	var responseKube runtime.Object
   169  	var apiVersion string
   170  	if ar != nil {
   171  		apiVersion = ar.APIVersion
   172  		response.TypeMeta = ar.TypeMeta
   173  		if response.Response != nil {
   174  			if ar.Request != nil {
   175  				response.Response.UID = ar.Request.UID
   176  			}
   177  		}
   178  	}
   179  	responseKube = kube.AdmissionReviewAdapterToKube(&response, apiVersion)
   180  	resp, err := json.Marshal(responseKube)
   181  	if err != nil {
   182  		reportValidationHTTPError(http.StatusInternalServerError)
   183  		http.Error(w, fmt.Sprintf("could encode response: %v", err), http.StatusInternalServerError)
   184  		return
   185  	}
   186  	if _, err := w.Write(resp); err != nil {
   187  		reportValidationHTTPError(http.StatusInternalServerError)
   188  		http.Error(w, fmt.Sprintf("could write response: %v", err), http.StatusInternalServerError)
   189  	}
   190  }
   191  
   192  func (wh *Webhook) serveValidate(w http.ResponseWriter, r *http.Request) {
   193  	serve(w, r, wh.validate)
   194  }
   195  
   196  func (wh *Webhook) validate(request *kube.AdmissionRequest) *kube.AdmissionResponse {
   197  	isDryRun := request.DryRun != nil && *request.DryRun
   198  	addDryRunMessageIfNeeded := func(errStr string) error {
   199  		err := fmt.Errorf("%s", errStr)
   200  		if isDryRun {
   201  			err = fmt.Errorf("%s (dry run)", err)
   202  		}
   203  		return err
   204  	}
   205  	switch request.Operation {
   206  	case kube.Create, kube.Update:
   207  	default:
   208  		scope.Warnf("Unsupported webhook operation %v", addDryRunMessageIfNeeded(request.Operation))
   209  		reportValidationFailed(request, reasonUnsupportedOperation, isDryRun)
   210  		return &kube.AdmissionResponse{Allowed: true}
   211  	}
   212  
   213  	var obj crd.IstioKind
   214  	if err := json.Unmarshal(request.Object.Raw, &obj); err != nil {
   215  		scope.Infof("cannot decode configuration: %v", addDryRunMessageIfNeeded(err.Error()))
   216  		reportValidationFailed(request, reasonYamlDecodeError, isDryRun)
   217  		return toAdmissionResponse(fmt.Errorf("cannot decode configuration: %v", err))
   218  	}
   219  
   220  	gvk := obj.GroupVersionKind()
   221  
   222  	s, exists := wh.schemas.FindByGroupVersionAliasesKind(resource.FromKubernetesGVK(&gvk))
   223  	if !exists {
   224  		scope.Infof("unrecognized type %v", addDryRunMessageIfNeeded(obj.GroupVersionKind().String()))
   225  		reportValidationFailed(request, reasonUnknownType, isDryRun)
   226  		return toAdmissionResponse(fmt.Errorf("unrecognized type %v", obj.GroupVersionKind()))
   227  	}
   228  
   229  	out, err := crd.ConvertObject(s, &obj, wh.domainSuffix)
   230  	if err != nil {
   231  		scope.Infof("error decoding configuration: %v", addDryRunMessageIfNeeded(err.Error()))
   232  		reportValidationFailed(request, reasonCRDConversionError, isDryRun)
   233  		return toAdmissionResponse(fmt.Errorf("error decoding configuration: %v", err))
   234  	}
   235  
   236  	warnings, err := s.ValidateConfig(*out)
   237  	if err != nil {
   238  		if _, f := out.Annotations[constants.AlwaysReject]; !f {
   239  			// Hide error message if it was intentionally rejected (by our own internal call)
   240  			scope.Infof("configuration is invalid: %v", addDryRunMessageIfNeeded(err.Error()))
   241  		}
   242  		reportValidationFailed(request, reasonInvalidConfig, isDryRun)
   243  		return toAdmissionResponse(fmt.Errorf("configuration is invalid: %v", err))
   244  	}
   245  
   246  	if reason, err := checkFields(request.Object.Raw, request.Kind.Kind, request.Namespace, obj.Name); err != nil {
   247  		reportValidationFailed(request, reason, isDryRun)
   248  		return toAdmissionResponse(err)
   249  	}
   250  
   251  	reportValidationPass(request)
   252  	return &kube.AdmissionResponse{Allowed: true, Warnings: toKubeWarnings(warnings)}
   253  }
   254  
   255  func toKubeWarnings(warn validation.Warning) []string {
   256  	if warn == nil {
   257  		return nil
   258  	}
   259  	me, ok := warn.(*multierror.Error)
   260  	if ok {
   261  		res := []string{}
   262  		for _, e := range me.Errors {
   263  			res = append(res, e.Error())
   264  		}
   265  		return res
   266  	}
   267  	return []string{warn.Error()}
   268  }
   269  
   270  func checkFields(raw []byte, kind string, namespace string, name string) (string, error) {
   271  	trial := make(map[string]json.RawMessage)
   272  	if err := json.Unmarshal(raw, &trial); err != nil {
   273  		scope.Infof("cannot decode configuration fields: %v", err)
   274  		return reasonYamlDecodeError, fmt.Errorf("cannot decode configuration fields: %v", err)
   275  	}
   276  
   277  	for key := range trial {
   278  		if _, ok := validFields[key]; !ok {
   279  			scope.Infof("unknown field %q on %s resource %s/%s",
   280  				key, kind, namespace, name)
   281  			return reasonInvalidConfig, fmt.Errorf("unknown field %q on %s resource %s/%s",
   282  				key, kind, namespace, name)
   283  		}
   284  	}
   285  
   286  	return "", nil
   287  }
   288  
   289  // validatePort checks that the network port is in range
   290  func validatePort(port int) error {
   291  	if 1 <= port && port <= 65535 {
   292  		return nil
   293  	}
   294  	return fmt.Errorf("port number %d must be in the range 1..65535", port)
   295  }
   296  
   297  // Validate tests if the Options has valid params.
   298  func (o Options) Validate() error {
   299  	var errs *multierror.Error
   300  	if err := validatePort(int(o.Port)); err != nil {
   301  		errs = multierror.Append(errs, err)
   302  	}
   303  	return errs.ErrorOrNil()
   304  }