Skip to content
13 changes: 12 additions & 1 deletion components/notebook-controller/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,18 @@ All other fields will be filled in with default value if not specified.
| --- | --- |
|ADD_FSGROUP| If the value is true or unset, fsGroup: 100 will be included in the pod's security context. If this value is present and set to false, it will suppress the automatic addition of fsGroup: 100 to the security context of the pod.|
|DEV| If the value is false or unset, then the default implementation of the Notebook Controller will be used. If the admins want to use a custom implementation from their local machine, they should set this value to true.|

|USE_ISTIO| If the value is set to true, Istio will be used to route traffic. If set to false, Istio routing will be disabled.|
|ISTIO_GATEWAY|Name of the Istio Gateway resource used to route traffic to notebook servers. Only used if ``USE_ISTIO`` is enabled.|
|ISTIO_HOST|Specifies the host to use in the Istio VirtualService. Default is ``*``. Only used if ``USE_ISTIO`` is enabled.|
|ISTIO_USE_NOTEBOOK_SUBDOMAINS| If the value is true, notebooks hosted on a subdomain. This is recommended for security reasons but has some technical dependencies. If the value is set to false, secure subdomain feature is not enabled. Disabled by default.|
|ISTIO_HOST_NOTEBOOK| Domain template used by Istio to host notebooks (e.g., `${NAMESPACE}-notebook.kubeflow.example.com`). Required if ``ISTIO_USE_NOTEBOOK_SUBDOMAINS`` is enabled. |
|ISTIO_HOST_AUTH| Host used by Istio for handling authentication callbacks or login flows (e.g., `kubeflow.example.com`). Required if ``ISTIO_USE_NOTEBOOK_SUBDOMAINS`` is enabled. |
|ISTIO_AUTH_PATH|Specifies the path used for authentication callbacks or redirection when integrating with an identity provider through Istio. Default is ``/oauth2/``. Only used if ``ISTIO_USE_NOTEBOOK_SUBDOMAINS`` is enabled. |
|ISTIO_DISABLE_ENVOY_HEADER_MANIPULATION|If the value is true, [envoy's substitution commands syntax in headers](https://www.envoyproxy.io/docs/envoy/latest/configuration/http/http_conn_man/headers#custom-request-response-headers). This should be enabled for security reasons because this prevents access token leakage to user-controlled notebooks. Not enabled by default.|
|ENABLE_CULLING|If the value is true, the controller will do culling on idle notebooks. Not enabled by default.|
|CULL_IDLE_TIME|Specifies minutes until an idle notebook will be stopped. Default is 1 day.|
|IDLENESS_CHECK_PERIOD|Specifies minutes for the idleness check period. Default is 1 minute.|
|CLUSTER_DOMAIN| Specifies the Kubernetes internal cluster domain. Default is ``cluster.local``|


## Commandline parameters
Expand Down
25 changes: 25 additions & 0 deletions components/notebook-controller/config/manager/manager.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,31 @@ spec:
configMapKeyRef:
name: config
key: ISTIO_HOST
- name: ISTIO_USE_NOTEBOOK_SUBDOMAINS
valueFrom:
configMapKeyRef:
name: config
key: ISTIO_USE_NOTEBOOK_SUBDOMAINS
- name: ISTIO_HOST_NOTEBOOK
valueFrom:
configMapKeyRef:
name: config
key: ISTIO_HOST_NOTEBOOK
- name: ISTIO_HOST_AUTH
valueFrom:
configMapKeyRef:
name: config
key: ISTIO_HOST_AUTH
- name: ISTIO_AUTH_PATH
valueFrom:
configMapKeyRef:
name: config
key: ISTIO_AUTH_PATH
- name: ISTIO_DISABLE_ENVOY_HEADER_MANIPULATION
valueFrom:
configMapKeyRef:
name: config
key: ISTIO_DISABLE_ENVOY_HEADER_MANIPULATION
- name: CLUSTER_DOMAIN
valueFrom:
configMapKeyRef:
Expand Down
6 changes: 6 additions & 0 deletions components/notebook-controller/config/manager/params.env
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,9 @@ CLUSTER_DOMAIN=cluster.local
ENABLE_CULLING=false
CULL_IDLE_TIME=1440
IDLENESS_CHECK_PERIOD=1
ISTIO_DISABLE_ENVOY_HEADER_MANIPULATION=false # Set this value to true to prevent access token leakage
# Notebook multi-domain setup is disabled by default. Change the following variables to enable it:
ISTIO_USE_NOTEBOOK_SUBDOMAINS=false
ISTIO_HOST_NOTEBOOK=
ISTIO_HOST_AUTH=
ISTIO_AUTH_PATH=
257 changes: 221 additions & 36 deletions components/notebook-controller/controllers/notebook_controller.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ package controllers
import (
"context"
"encoding/json"
"errors"
"fmt"
"os"
"reflect"
Expand Down Expand Up @@ -468,7 +469,108 @@ func virtualServiceName(kfName string, namespace string) string {
return fmt.Sprintf("notebook-%s-%s", namespace, kfName)
}

func generateVirtualService(instance *v1beta1.Notebook) (*unstructured.Unstructured, error) {
func virtualServiceNameWithSuffix(kfName string, namespace string, suffix string) string {
return fmt.Sprintf("notebook-%s-%s-%s", namespace, kfName, suffix)
}

func generateVirtualServiceForNotebookRedirect(name string, namespace string, prefix string, istioHostNotebook string, istioGateway string) (*unstructured.Unstructured, error) {
vsvc := &unstructured.Unstructured{}
vsvc.SetAPIVersion("networking.istio.io/v1alpha3")
vsvc.SetKind("VirtualService")
vsvc.SetName(virtualServiceNameWithSuffix(name, namespace, "notebook-redirect"))
vsvc.SetNamespace(namespace)

istioHost := os.Getenv("ISTIO_HOST")
Comment thread
sven-ernw marked this conversation as resolved.
if len(istioHost) == 0 {
istioHost = "*"
}
if err := unstructured.SetNestedStringSlice(vsvc.Object, []string{istioHost}, "spec", "hosts"); err != nil {
return nil, fmt.Errorf("Set .spec.hosts error: %v", err)

}

if err := unstructured.SetNestedStringSlice(vsvc.Object, []string{istioGateway},
"spec", "gateways"); err != nil {
return nil, fmt.Errorf("set .spec.gateways error: %v", err)
}

// the http section of the istio VirtualService spec
http := []interface{}{
map[string]interface{}{
"match": []interface{}{
map[string]interface{}{
"uri": map[string]interface{}{
"prefix": prefix,
},
},
},
"redirect": map[string]interface{}{
"authority": istioHostNotebook,
"redirectCode": int64(307),
},
},
}

// add http section to istio VirtualService spec
if err := unstructured.SetNestedSlice(vsvc.Object, http, "spec", "http"); err != nil {
return nil, fmt.Errorf("set .spec.http error: %v", err)
}

return vsvc, nil
}

func generateVirtualServiceForAuthRedirect(name string, namespace string, prefix string, istioHostNotebook string, istioGateway string) (*unstructured.Unstructured, error) {
vsvc := &unstructured.Unstructured{}
vsvc.SetAPIVersion("networking.istio.io/v1alpha3")
vsvc.SetKind("VirtualService")
vsvc.SetName(virtualServiceNameWithSuffix(name, namespace, "auth-redirect"))
vsvc.SetNamespace(namespace)

if err := unstructured.SetNestedStringSlice(vsvc.Object, []string{istioHostNotebook}, "spec", "hosts"); err != nil {
return nil, fmt.Errorf("set .spec.hosts error: %v", err)
}

if err := unstructured.SetNestedStringSlice(vsvc.Object, []string{istioGateway},
"spec", "gateways"); err != nil {
return nil, fmt.Errorf("set .spec.gateways error: %v", err)
}

istioHostAuth := os.Getenv("ISTIO_HOST_AUTH")
if len(istioHostAuth) == 0 {
return nil, fmt.Errorf("invalid ISTIO_HOST_AUTH: When ISTIO_USE_NOTEBOOK_SUBDOMAINS is set, the ISTIO_HOST_AUTH environment variable must be defined.")
}

istioAuthPath := os.Getenv("ISTIO_AUTH_PATH")
if len(istioAuthPath) == 0 {
istioAuthPath = "/oauth2/"
}

// the http section of the istio VirtualService spec
http := []interface{}{
map[string]interface{}{
"match": []interface{}{
map[string]interface{}{
"uri": map[string]interface{}{
"prefix": istioAuthPath,
},
},
},
"redirect": map[string]interface{}{
"authority": istioHostAuth,
"redirectCode": int64(307),
},
},
}

// add http section to istio VirtualService spec
if err := unstructured.SetNestedSlice(vsvc.Object, http, "spec", "http"); err != nil {
return nil, fmt.Errorf("set .spec.http error: %v", err)
}

return vsvc, nil
}

func generateVirtualServices(instance *v1beta1.Notebook) ([]*unstructured.Unstructured, error) {
name := instance.Name
namespace := instance.Namespace
clusterDomain := "cluster.local"
Expand All @@ -492,20 +594,13 @@ func generateVirtualService(instance *v1beta1.Notebook) (*unstructured.Unstructu
service := fmt.Sprintf("%s.%s.svc.%s", name, namespace, clusterDomain)

vsvc := &unstructured.Unstructured{}
vsvcs := []*unstructured.Unstructured{vsvc}

vsvc.SetAPIVersion("networking.istio.io/v1alpha3")
vsvc.SetKind("VirtualService")
vsvc.SetName(virtualServiceName(name, namespace))
vsvc.SetNamespace(namespace)

istioHost := os.Getenv("ISTIO_HOST")
if len(istioHost) == 0 {
istioHost = "*"
}
if err := unstructured.SetNestedStringSlice(vsvc.Object, []string{istioHost}, "spec", "hosts"); err != nil {
return nil, fmt.Errorf("Set .spec.hosts error: %v", err)

}

istioGateway := os.Getenv("ISTIO_GATEWAY")
if len(istioGateway) == 0 {
istioGateway = "kubeflow/kubeflow-gateway"
Expand All @@ -515,6 +610,33 @@ func generateVirtualService(instance *v1beta1.Notebook) (*unstructured.Unstructu
return nil, fmt.Errorf("set .spec.gateways error: %v", err)
}

istioHost := os.Getenv("ISTIO_HOST")
if os.Getenv("ISTIO_USE_NOTEBOOK_SUBDOMAINS") == "true" {
istioHost = os.Getenv("ISTIO_HOST_NOTEBOOK")
if !strings.Contains(istioHost, "${NAMESPACE}") {
return nil, errors.New("invalid ISTIO_HOST_NOTEBOOK: When ISTIO_USE_NOTEBOOK_SUBDOMAINS is set, the placeholder ${NAMESPACE} must be defined in ISTIO_HOST_NOTEBOOK.")
}
istioHost = strings.ReplaceAll(istioHost, "${NAMESPACE}", namespace)

vsvcForNotebookRedirect, err := generateVirtualServiceForNotebookRedirect(name, namespace, prefix, istioHost, istioGateway)
if err != nil {
return nil, fmt.Errorf("generate virtual service for notebook redirect error: %v", err)
}

vsvcForAuthRedirect, err := generateVirtualServiceForAuthRedirect(name, namespace, prefix, istioHost, istioGateway)
if err != nil {
return nil, fmt.Errorf("generate virtual service for auth redirect error: %v", err)
}

vsvcs = append(vsvcs, vsvcForAuthRedirect, vsvcForNotebookRedirect)
} else if len(istioHost) == 0 {
istioHost = "*"
}
if err := unstructured.SetNestedStringSlice(vsvc.Object, []string{istioHost}, "spec", "hosts"); err != nil {
return nil, fmt.Errorf("Set .spec.hosts error: %v", err)

}

headersRequestSet := make(map[string]string)
// If AnnotationHeadersRequestSet is present, use its values in "headers.request.set"
if _, ok := annotations[AnnotationHeadersRequestSet]; ok && len(annotations[AnnotationHeadersRequestSet]) > 0 {
Expand All @@ -524,18 +646,28 @@ func generateVirtualService(instance *v1beta1.Notebook) (*unstructured.Unstructu
headersRequestSet = make(map[string]string)
}
}

modifyHeader := func(s string) string { return s }
if os.Getenv("ISTIO_DISABLE_ENVOY_HEADER_MANIPULATION") == "true" {
modifyHeader = escapeDynamicEnvoyValueSymbol
}
// cast from map[string]string, as SetNestedSlice needs map[string]interface{}
headersRequestSetInterface := make(map[string]interface{})
for key, element := range headersRequestSet {
headersRequestSetInterface[key] = element
headersRequestSetInterface[key] = modifyHeader(element)
}

// the http section of the istio VirtualService spec
http := []interface{}{
map[string]interface{}{
"headers": map[string]interface{}{
"request": map[string]interface{}{
"set": headersRequestSetInterface,
// remove the Authorization header to prevent token stealing.
// otherwise, the bearer token would be forwarded to the notebook container,
// allowing notebook code to capture a visitor's token.
"remove": []interface{}{
"Authorization",
},
Comment thread
sven-ernw marked this conversation as resolved.
Comment on lines +668 to +670

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is true. Good catch because Istio does not documented this feature.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We have added the environment setting ISTIO_DISABLE_ENVOY_HEADER_MANIPULATION to disable envoy's substitution commands syntax in headers. This should be enabled for security reasons because this prevents access token leakage to user-controlled notebooks.

It is not enabled by default to avoid breaking changes. What do you think @thesuperzapper @christian-heusel ?

See commit: ed4c42b for details.

},
},
"match": []interface{}{
Expand Down Expand Up @@ -566,46 +698,90 @@ func generateVirtualService(instance *v1beta1.Notebook) (*unstructured.Unstructu
return nil, fmt.Errorf("set .spec.http error: %v", err)
}

return vsvc, nil
return vsvcs, nil

}

func (r *NotebookReconciler) reconcileVirtualService(instance *v1beta1.Notebook) error {
log := r.Log.WithValues("notebook", instance.Namespace)
virtualService, err := generateVirtualService(instance)
virtualServices, err := generateVirtualServices(instance)
if err != nil {
log.Info("Unable to generate VirtualService...", err)
return err
}
if err := ctrl.SetControllerReference(instance, virtualService, r.Scheme); err != nil {
return err
}
// Check if the virtual service already exists.
foundVirtual := &unstructured.Unstructured{}
justCreated := false
foundVirtual.SetAPIVersion("networking.istio.io/v1alpha3")
foundVirtual.SetKind("VirtualService")
err = r.Get(context.TODO(), types.NamespacedName{Name: virtualServiceName(instance.Name,
instance.Namespace), Namespace: instance.Namespace}, foundVirtual)
if err != nil && apierrs.IsNotFound(err) {
log.Info("Creating virtual service", "namespace", instance.Namespace, "name",
virtualServiceName(instance.Name, instance.Namespace))
err = r.Create(context.TODO(), virtualService)
justCreated = true
if err != nil {

desiredVirtualServices := make(map[string]struct{}, len(virtualServices))

for _, virtualService := range virtualServices {
Comment thread
sven-ernw marked this conversation as resolved.

if err := ctrl.SetControllerReference(instance, virtualService, r.Scheme); err != nil {
return err
}
} else if err != nil {
return err
desiredVirtualServices[virtualService.GetName()] = struct{}{}

// Check if the virtual service already exists.
foundVirtual := &unstructured.Unstructured{}
justCreated := false
foundVirtual.SetAPIVersion("networking.istio.io/v1alpha3")
foundVirtual.SetKind("VirtualService")
virtualServiceName := virtualService.GetName()
virtualServiceNamespace := virtualService.GetNamespace()
err = r.Get(context.TODO(), types.NamespacedName{Name: virtualServiceName, Namespace: virtualServiceNamespace}, foundVirtual)
if err != nil && apierrs.IsNotFound(err) {
log.Info("Creating virtual service", "namespace", virtualServiceNamespace, "name",
virtualServiceName)
err = r.Create(context.TODO(), virtualService)
justCreated = true
if err != nil {
return err
}
} else if err != nil {
return err
}

if !justCreated && reconcilehelper.CopyVirtualService(virtualService, foundVirtual) {
log.Info("Updating virtual service", "namespace", virtualServiceNamespace, "name",
virtualServiceName)
err = r.Update(context.TODO(), foundVirtual)
if err != nil {
return err
}
}
}

if !justCreated && reconcilehelper.CopyVirtualService(virtualService, foundVirtual) {
log.Info("Updating virtual service", "namespace", instance.Namespace, "name",
virtualServiceName(instance.Name, instance.Namespace))
err = r.Update(context.TODO(), foundVirtual)
// Delete redirect VirtualServices that are no longer part of the desired
// state, for example after ISTIO_USE_NOTEBOOK_SUBDOMAINS is disabled.
for _, suffix := range []string{"auth-redirect", "notebook-redirect"} {
name := virtualServiceNameWithSuffix(instance.Name, instance.Namespace, suffix)
if _, desired := desiredVirtualServices[name]; desired {
continue
}

staleVirtualService := &unstructured.Unstructured{}
staleVirtualService.SetAPIVersion("networking.istio.io/v1alpha3")
staleVirtualService.SetKind("VirtualService")

err := r.Get(context.TODO(), types.NamespacedName{
Name: name,
Namespace: instance.Namespace,
}, staleVirtualService)
if apierrs.IsNotFound(err) {
continue
}
if err != nil {
return err
}

// Never delete a resource with the expected name unless this Notebook is
// still its controller owner.
if !metav1.IsControlledBy(staleVirtualService, instance) {
continue
}

log.Info("Deleting stale virtual service", "namespace", instance.Namespace, "name", name)
if err := r.Delete(context.TODO(), staleVirtualService); err != nil && !apierrs.IsNotFound(err) {
return err
}
}

return nil
Expand Down Expand Up @@ -737,3 +913,12 @@ func (r *NotebookReconciler) SetupWithManager(mgr ctrl.Manager) error {

return nil
}

// escapeDynamicEnvoyValueSymbol escapes literal percent symbols
// of envoy's substitution commands syntax in headers.
//
// example: "%REQUEST_HEADER(MY_CUSTOM_HEADER)%" becomes "%%REQUEST_HEADER(MY_CUSTOM_HEADER)%%".
// reference: https://www.envoyproxy.io/docs/envoy/latest/configuration/http/http_conn_man/headers#custom-request-response-headers
func escapeDynamicEnvoyValueSymbol(value string) string {
return strings.ReplaceAll(value, "%", "%%")
}
Loading
Loading