diff --git a/backend/cmd/headlamp.go b/backend/cmd/headlamp.go index cc4798aae28..27b57b1fc37 100644 --- a/backend/cmd/headlamp.go +++ b/backend/cmd/headlamp.go @@ -45,6 +45,7 @@ import ( auth "github.com/kubernetes-sigs/headlamp/backend/pkg/auth" "github.com/kubernetes-sigs/headlamp/backend/pkg/cache" cfg "github.com/kubernetes-sigs/headlamp/backend/pkg/config" + "github.com/kubernetes-sigs/headlamp/backend/pkg/serviceproxy" headlampcfg "github.com/kubernetes-sigs/headlamp/backend/pkg/headlampconfig" "github.com/kubernetes-sigs/headlamp/backend/pkg/helm" @@ -293,7 +294,7 @@ func addPluginDeleteRoute(config *HeadlampConfig, r *mux.Router) { logger.Log(logger.LevelInfo, nil, nil, "Received DELETE request for plugin: "+mux.Vars(r)["name"]) - if err := checkHeadlampBackendToken(w, r); err != nil { + if err := config.checkHeadlampBackendToken(w, r); err != nil { config.telemetryHandler.RecordError(span, err, " Invalid backend token") logger.Log(logger.LevelWarn, nil, err, "Invalid backend token for DELETE /plugins/{name}") return @@ -623,8 +624,11 @@ func createHeadlampHandler(config *HeadlampConfig) http.Handler { oidcAuthConfig, err := kContext.OidcConfig() if err != nil { - logger.Log(logger.LevelError, map[string]string{"cluster": cluster}, - err, "failed to get oidc config") + // Avoid the noise in the pod log while accessing Headlamp using Service Token + if config.oidcIdpIssuerURL != "" { + logger.Log(logger.LevelError, map[string]string{"cluster": cluster}, + err, "failed to get oidc config") + } http.Error(w, err.Error(), http.StatusInternalServerError) return @@ -1087,6 +1091,23 @@ func getHelmHandler(c *HeadlampConfig, w http.ResponseWriter, r *http.Request) ( return nil, errors.New("not found") } + tokenFromCookie, err := auth.GetTokenFromCookie(r, clusterName) + + bearerToken := r.Header.Get("Authorization") + if err == nil && tokenFromCookie != "" && bearerToken == "" { + r.Header.Set("Authorization", fmt.Sprintf("Bearer %s", tokenFromCookie)) + } + + // If the request contains a bearer token in the Authorization header, set it in AuthInfo. + // This token will be used authentication to the Kubernetes cluster. + bearerToken = r.Header.Get("Authorization") + if bearerToken != "" { + reqToken := strings.TrimPrefix(bearerToken, "Bearer ") + if reqToken != "" { + context.AuthInfo.Token = reqToken + } + } + namespace := r.URL.Query().Get("namespace") helmHandler, err := helm.NewHandler(context.ClientConfig(), c.cache, namespace) @@ -1110,7 +1131,11 @@ func getHelmHandler(c *HeadlampConfig, w http.ResponseWriter, r *http.Request) ( // Check request for header "X-HEADLAMP_BACKEND-TOKEN" matches HEADLAMP_BACKEND_TOKEN env // This check is to prevent access except for from the app. // The app sets HEADLAMP_BACKEND_TOKEN, and gives the token to the frontend. -func checkHeadlampBackendToken(w http.ResponseWriter, r *http.Request) error { +func (c *HeadlampConfig) checkHeadlampBackendToken(w http.ResponseWriter, r *http.Request) error { + if c.UseInCluster { + return nil + } + backendToken := r.Header.Get("X-HEADLAMP_BACKEND-TOKEN") backendTokenEnv := os.Getenv("HEADLAMP_BACKEND_TOKEN") @@ -1122,6 +1147,16 @@ func checkHeadlampBackendToken(w http.ResponseWriter, r *http.Request) error { return nil } +// handleClusterServiceProxy registers a new route for the path serviceproxy/{namespace}/{name} +// to proxy requests to in-cluster services. +func handleClusterServiceProxy(c *HeadlampConfig, router *mux.Router) { + router.HandleFunc("/clusters/{clusterName}/serviceproxy/{namespace}/{name}", + func(w http.ResponseWriter, r *http.Request) { + serviceproxy.RequestHandler(c.KubeConfigStore, w, r) + }).Queries("request", "{request}"). + Methods("GET") +} + //nolint:funlen func handleClusterHelm(c *HeadlampConfig, router *mux.Router) { router.PathPrefix("/clusters/{clusterName}/helm/{.*}").HandlerFunc(func(w http.ResponseWriter, r *http.Request) { @@ -1137,7 +1172,7 @@ func handleClusterHelm(c *HeadlampConfig, router *mux.Router) { c.telemetryHandler.RecordRequestCount(ctx, r, attribute.String("cluster", clusterName)) - if err := checkHeadlampBackendToken(w, r); err != nil { + if err := c.checkHeadlampBackendToken(w, r); err != nil { c.handleError(w, ctx, span, err, "failed to check headlamp backend token", http.StatusForbidden) return @@ -1399,6 +1434,7 @@ func (c *HeadlampConfig) handleClusterRequests(router *mux.Router) { handleClusterHelm(c, router) } + handleClusterServiceProxy(c, router) handleClusterAPI(c, router) } @@ -1569,7 +1605,7 @@ func (c *HeadlampConfig) addCluster(w http.ResponseWriter, r *http.Request) { defer recordRequestCompletion(c, ctx, start, r) c.telemetryHandler.RecordRequestCount(ctx, r) - if err := checkHeadlampBackendToken(w, r); err != nil { + if err := c.checkHeadlampBackendToken(w, r); err != nil { c.telemetryHandler.RecordError(span, err, "invalid backend token") c.telemetryHandler.RecordErrorCount(ctx, attribute.String("error.type", "invalid token")) logger.Log(logger.LevelError, nil, err, "invalid token") @@ -1777,7 +1813,7 @@ func (c *HeadlampConfig) deleteCluster(w http.ResponseWriter, r *http.Request) { name := mux.Vars(r)["name"] - if err := checkHeadlampBackendToken(w, r); err != nil { + if err := c.checkHeadlampBackendToken(w, r); err != nil { c.telemetryHandler.RecordError(span, err, "invalid backend token") c.telemetryHandler.RecordErrorCount(ctx, attribute.String("error.type", "invalid_token")) logger.Log(logger.LevelError, nil, err, "invalid token") diff --git a/backend/cmd/headlamp_test.go b/backend/cmd/headlamp_test.go index b0d511054ac..86a0b7ea5c3 100644 --- a/backend/cmd/headlamp_test.go +++ b/backend/cmd/headlamp_test.go @@ -25,12 +25,14 @@ import ( "encoding/json" "fmt" "io" + "net" "net/http" "net/http/httptest" "net/url" "os" "path/filepath" "strconv" + "strings" "testing" "time" @@ -43,6 +45,8 @@ import ( "github.com/kubernetes-sigs/headlamp/backend/pkg/telemetry" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/client-go/tools/clientcmd" "k8s.io/client-go/tools/clientcmd/api" ) @@ -1555,3 +1559,123 @@ func TestCacheMiddleware_CacheInvalidation(t *testing.T) { assert.Equal(t, "true", resp1.Header.Get("X-HEADLAMP-CACHE")) assert.Equal(t, http.StatusOK, resp1.StatusCode) } + +//nolint:funlen +func TestHandleClusterServiceProxy(t *testing.T) { + cfg := &HeadlampConfig{ + HeadlampCFG: &headlampconfig.HeadlampCFG{KubeConfigStore: kubeconfig.NewContextStore()}, + telemetryHandler: &telemetry.RequestHandler{}, + telemetryConfig: GetDefaultTestTelemetryConfig(), + } + + // Backend service the proxy should call + backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/healthz" { + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte("OK")) + + return + } + + http.NotFound(w, r) + })) + t.Cleanup(backend.Close) + + // Extract host:port to feed into the Service external name + port + bu, err := url.Parse(backend.URL) + require.NoError(t, err) + host, portStr, err := net.SplitHostPort(strings.TrimPrefix(bu.Host, "[")) + require.NoError(t, err) + portNum, err := strconv.Atoi(strings.TrimSuffix(portStr, "]")) + require.NoError(t, err) + + // Fake k8s API that returns a Service pointing to backend + kubeAPI := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodGet && r.URL.Path == "/api/v1/namespaces/default/services/my-service" { + svc := &corev1.Service{ + ObjectMeta: metav1.ObjectMeta{ + Name: "my-service", + Namespace: "default", + }, + Spec: corev1.ServiceSpec{ + ExternalName: host, + Ports: []corev1.ServicePort{ + { + Name: "http", + Port: int32(portNum), //nolint:gosec + }, + }, + }, + } + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _ = json.NewEncoder(w).Encode(svc) + + return + } + + http.NotFound(w, r) + })) + t.Cleanup(kubeAPI.Close) + + // Add a context that matches clusterName in URL + err = cfg.KubeConfigStore.AddContext(&kubeconfig.Context{ + Name: "kubernetes", + KubeContext: &api.Context{ + Cluster: "kubernetes", + AuthInfo: "kubernetes", + }, + Cluster: &api.Cluster{Server: kubeAPI.URL}, // client-go will talk to this + AuthInfo: &api.AuthInfo{}, + }) + require.NoError(t, err) + + router := mux.NewRouter() + handleClusterServiceProxy(cfg, router) + + cluster := "kubernetes" + ns := "default" + svc := "my-service" + + // Case 1: Missing ?request => route doesn't match => 404, no headers set + { + req := httptest.NewRequest(http.MethodGet, + "/clusters/"+cluster+"/serviceproxy/"+ns+"/"+svc, nil) + rr := httptest.NewRecorder() + router.ServeHTTP(rr, req) + assert.Equal(t, http.StatusNotFound, rr.Code) + assert.Empty(t, rr.Header().Get("Cache-Control")) + } + + // Case 2: ?request present but missing Authorization => 401, headers set + { + req := httptest.NewRequest(http.MethodGet, + "/clusters/"+cluster+"/serviceproxy/"+ns+"/"+svc+"?request=/healthz", nil) + rr := httptest.NewRecorder() + router.ServeHTTP(rr, req) + assert.Equal(t, http.StatusUnauthorized, rr.Code) + assert.Equal(t, "no-cache, private, max-age=0", rr.Header().Get("Cache-Control")) + assert.Equal(t, "no-cache", rr.Header().Get("Pragma")) + assert.Equal(t, "0", rr.Header().Get("X-Accel-Expires")) + } + + // Case 3 (Happy path): ?request present and Authorization provided => proxy reaches backend => 200 OK + { + req := httptest.NewRequest(http.MethodGet, + "/clusters/"+cluster+"/serviceproxy/"+ns+"/"+svc+"?request=/healthz", nil) + req.Header.Set("Authorization", "Bearer test-token") + + rr := httptest.NewRecorder() + router.ServeHTTP(rr, req) + + // Handler always sets no-cache headers + assert.Equal(t, "no-cache, private, max-age=0", rr.Header().Get("Cache-Control")) + assert.Equal(t, "no-cache", rr.Header().Get("Pragma")) + assert.Equal(t, "0", rr.Header().Get("X-Accel-Expires")) + + // Happy path: backend returns OK + assert.Equal(t, http.StatusOK, rr.Code) + assert.Equal(t, "OK", rr.Body.String()) + } +} diff --git a/backend/pkg/config/config.go b/backend/pkg/config/config.go index 0b78a318986..1f559d68a55 100644 --- a/backend/pkg/config/config.go +++ b/backend/pkg/config/config.go @@ -335,7 +335,6 @@ func flagset() *flag.FlagSet { f.String("listen-addr", "", "Address to listen on; default is empty, which means listening to any address") f.Uint("port", defaultPort, "Port to listen from") f.String("proxy-urls", "", "Allow proxy requests to specified URLs") - f.Bool("enable-helm", false, "Enable Helm operations") f.String("oidc-client-id", "", "ClientID for OIDC") f.String("oidc-client-secret", "", "ClientSecret for OIDC") diff --git a/backend/pkg/config/config_test.go b/backend/pkg/config/config_test.go index 9059482a23a..21e04885a9a 100644 --- a/backend/pkg/config/config_test.go +++ b/backend/pkg/config/config_test.go @@ -178,13 +178,6 @@ func TestParseFlags(t *testing.T) { assert.Equal(t, filepath.Join(getTestDataPath(), "valid_ca.pem"), conf.OidcCAFile) }, }, - { - name: "enable_helm", - args: []string{"go run ./cmd", "--enable-helm"}, - verify: func(t *testing.T, conf *config.Config) { - assert.Equal(t, true, conf.EnableHelm) - }, - }, } for _, tt := range tests { diff --git a/backend/pkg/helm/release.go b/backend/pkg/helm/release.go index ada250e2393..0bfb09cda43 100644 --- a/backend/pkg/helm/release.go +++ b/backend/pkg/helm/release.go @@ -17,6 +17,7 @@ limitations under the License. package helm import ( + "context" "encoding/base64" "encoding/json" "errors" @@ -36,6 +37,9 @@ import ( "helm.sh/helm/v3/pkg/getter" "helm.sh/helm/v3/pkg/release" "helm.sh/helm/v3/pkg/storage/driver" + authv1 "k8s.io/api/authentication/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/client-go/kubernetes" "sigs.k8s.io/yaml" ) @@ -576,8 +580,38 @@ func (h *Handler) getChart( return chart, nil } +// Verify the user has minimal privileges by performing a whoami check. +// This prevents spurious downloads by ensuring basic authentication before proceeding. +func VerifyUser(h *Handler, req InstallRequest) bool { + restConfig, err := h.Configuration.RESTClientGetter.ToRESTConfig() + if err != nil { + logger.Log(logger.LevelError, map[string]string{"chart": req.Chart, "releaseName": req.Name}, err, "getting chart") + return false + } + + cs, err := kubernetes.NewForConfig(restConfig) + if err != nil { + logger.Log(logger.LevelError, map[string]string{"chart": req.Chart, "releaseName": req.Name}, err, "getting chart") + return false + } + + review, err := cs.AuthenticationV1().SelfSubjectReviews().Create(context.Background(), + &authv1.SelfSubjectReview{}, metav1.CreateOptions{}) + if err != nil { + logger.Log(logger.LevelError, map[string]string{"chart": req.Chart, "releaseName": req.Name}, err, "getting chart") + return false + } + + if user := review.Status.UserInfo.Username; user == "" || user == "system:anonymous" { + logger.Log(logger.LevelError, map[string]string{"chart": req.Chart, "releaseName": req.Name}, + errors.New("insufficient privileges"), "getting chart: user is not authorized to perform this operation") + return false + } + + return true +} + func (h *Handler) installRelease(req InstallRequest) { - // Get install client installClient := action.NewInstall(h.Configuration) installClient.ReleaseName = req.Name installClient.Namespace = req.Namespace @@ -585,6 +619,10 @@ func (h *Handler) installRelease(req InstallRequest) { installClient.CreateNamespace = req.CreateNamespace installClient.ChartPathOptions.Version = req.Version + if !VerifyUser(h, req) { + return + } + chart, err := h.getChart("install", req.Chart, req.Name, installClient.ChartPathOptions, req.DependencyUpdate, h.EnvSettings) if err != nil { @@ -594,8 +632,6 @@ func (h *Handler) installRelease(req InstallRequest) { return } - values := make(map[string]interface{}) - decodedBytes, err := base64.StdEncoding.DecodeString(req.Values) if err != nil { logger.Log(logger.LevelError, map[string]string{"chart": req.Chart, "releaseName": req.Name}, @@ -605,8 +641,8 @@ func (h *Handler) installRelease(req InstallRequest) { return } - err = yaml.Unmarshal(decodedBytes, &values) - if err != nil { + values := make(map[string]interface{}) + if err = yaml.Unmarshal(decodedBytes, &values); err != nil { logger.Log(logger.LevelError, map[string]string{"chart": req.Chart, "releaseName": req.Name}, err, "unmarshalling values") h.setReleaseStatusSilent("install", req.Name, failed, err) @@ -614,9 +650,7 @@ func (h *Handler) installRelease(req InstallRequest) { return } - // Install chart - _, err = installClient.Run(chart, values) - if err != nil { + if _, err = installClient.Run(chart, values); err != nil { logger.Log(logger.LevelError, map[string]string{"chart": req.Chart, "releaseName": req.Name}, err, "installing chart") h.setReleaseStatusSilent("install", req.Name, failed, err) @@ -624,9 +658,6 @@ func (h *Handler) installRelease(req InstallRequest) { return } - logger.Log(logger.LevelInfo, map[string]string{"chart": req.Chart, "releaseName": req.Name}, - nil, "chart installed successfully") - h.setReleaseStatusSilent("install", req.Name, success, nil) } diff --git a/backend/pkg/helm/release_test.go b/backend/pkg/helm/release_test.go index 307b73e97be..f704a1911b8 100644 --- a/backend/pkg/helm/release_test.go +++ b/backend/pkg/helm/release_test.go @@ -33,6 +33,10 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "helm.sh/helm/v3/pkg/action" + "k8s.io/apimachinery/pkg/api/meta" + "k8s.io/cli-runtime/pkg/genericclioptions" + "k8s.io/client-go/discovery" + "k8s.io/client-go/rest" "k8s.io/client-go/tools/clientcmd" ) @@ -251,3 +255,65 @@ func TestUninstallRelease(t *testing.T) { pingStatusTillSuccess(t, "uninstall", "helm-test-asdf", helmHandler.Cache) } + +type staticRESTGetter struct{ cfg *rest.Config } + +var _ genericclioptions.RESTClientGetter = (*staticRESTGetter)(nil) + +func (s *staticRESTGetter) ToRESTConfig() (*rest.Config, error) { + return s.cfg, nil +} + +func (s *staticRESTGetter) ToDiscoveryClient() (discovery.CachedDiscoveryInterface, error) { + return nil, nil +} + +func (s *staticRESTGetter) ToRESTMapper() (meta.RESTMapper, error) { + return nil, nil +} + +func (s *staticRESTGetter) ToRawKubeConfigLoader() clientcmd.ClientConfig { + return nil +} + +func TestVerifyUser(t *testing.T) { + helmHandler := newHelmHandler(t) + + tests := []struct { + name string + req helm.InstallRequest + wantResult bool + }{ + { + name: "valid user", + req: helm.InstallRequest{ + CommonInstallUpdateRequest: helm.CommonInstallUpdateRequest{ + Name: "test-release", + }, + }, + wantResult: true, + }, + { + name: "invalid user", + req: helm.InstallRequest{ + CommonInstallUpdateRequest: helm.CommonInstallUpdateRequest{ + Name: "test-release", + }, + }, + wantResult: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if !tt.wantResult { + helmHandler.Configuration.RESTClientGetter = &staticRESTGetter{ + cfg: &rest.Config{Host: ""}, // invalid/empty host triggers failure + } + } + + result := helm.VerifyUser(helmHandler, tt.req) + assert.Equal(t, result, tt.wantResult) + }) + } +} diff --git a/backend/pkg/serviceproxy/connection.go b/backend/pkg/serviceproxy/connection.go new file mode 100644 index 00000000000..834dbe08c4f --- /dev/null +++ b/backend/pkg/serviceproxy/connection.go @@ -0,0 +1,46 @@ +package serviceproxy + +import ( + "context" + "fmt" + "net/url" +) + +// ServiceConnection represents a connection to a service. +type ServiceConnection interface { + // Get - perform a get request and return the response + Get(string) ([]byte, error) +} +type Connection struct { + URI string +} + +// NewConnection creates a new connection to a service based on the provided proxyService. +func NewConnection(ps *proxyService) ServiceConnection { + return &Connection{ + URI: ps.URIPrefix, + } +} + +// Get sends a GET request to the specified URI. + +func (c *Connection) Get(requestURI string) ([]byte, error) { + base, err := url.Parse(c.URI) + if err != nil { + return nil, fmt.Errorf("invalid host uri: %w", err) + } + + rel, err := url.Parse(requestURI) + if err != nil { + return nil, fmt.Errorf("invalid request uri: %w", err) + } + + fullURL := base.ResolveReference(rel) + + body, err := HTTPGet(context.Background(), fullURL.String()) + if err != nil { + return nil, err + } + + return body, nil +} diff --git a/backend/pkg/serviceproxy/connection_test.go b/backend/pkg/serviceproxy/connection_test.go new file mode 100644 index 00000000000..3bdb1e6bcb9 --- /dev/null +++ b/backend/pkg/serviceproxy/connection_test.go @@ -0,0 +1,115 @@ +package serviceproxy //nolint + +import ( + "bytes" + "net/http" + "net/http/httptest" + "testing" +) + +func TestNewConnection(t *testing.T) { + tests := []struct { + name string + ps *proxyService + want ServiceConnection + }{ + { + name: "valid proxy service", + ps: &proxyService{ + URIPrefix: "http://example.com", + }, + want: &Connection{URI: "http://example.com"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + conn := NewConnection(tt.ps) + if conn == nil { + t.Errorf("NewConnection() returned nil") + } + + c, ok := conn.(*Connection) + if !ok { + t.Errorf("NewConnection() returned unexpected type") + } + + if c.URI != tt.want.(*Connection).URI { + t.Errorf("NewConnection() URI = %s, want %s", c.URI, tt.want.(*Connection).URI) + } + }) + } +} + +func TestGet(t *testing.T) { + tests := []struct { + name string + uri string + requestURI string + wantBody []byte + wantErr bool + }{ + { + name: "valid request", + uri: "http://example.com", + requestURI: "/test", + wantBody: []byte("Hello, World!"), + wantErr: false, + }, + { + name: "invalid URI", + uri: " invalid-uri", + requestURI: "/test", + wantBody: nil, + wantErr: true, + }, + { + name: "invalid request URI", + uri: "http://example.com", + requestURI: " invalid-request-uri", + wantBody: nil, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + conn := &Connection{URI: tt.uri} + + if tt.wantBody != nil { + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, err := w.Write(tt.wantBody) + if err != nil { + t.Fatal(err) + } + })) + defer ts.Close() + + conn.URI = ts.URL + } + + body, err := conn.Get(tt.requestURI) + if (err != nil) != tt.wantErr { + t.Errorf("Get() error = %v, wantErr %v", err, tt.wantErr) + } + + if !tt.wantErr && !bytes.Equal(body, tt.wantBody) { + t.Errorf("Get() body = %s, want %s", body, tt.wantBody) + } + }) + } +} + +func TestGetNonOKStatusCode(t *testing.T) { + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + })) + defer ts.Close() + + conn := &Connection{URI: ts.URL} + + _, err := conn.Get("/test") + if err == nil { + t.Errorf("Get() error = nil, want error") + } +} diff --git a/backend/pkg/serviceproxy/handler.go b/backend/pkg/serviceproxy/handler.go new file mode 100644 index 00000000000..7d24dd1a7b5 --- /dev/null +++ b/backend/pkg/serviceproxy/handler.go @@ -0,0 +1,127 @@ +package serviceproxy + +import ( + "fmt" + "net/http" + "strings" + "time" + + "github.com/gorilla/mux" + "github.com/kubernetes-sigs/headlamp/backend/pkg/auth" + "github.com/kubernetes-sigs/headlamp/backend/pkg/kubeconfig" + "github.com/kubernetes-sigs/headlamp/backend/pkg/logger" + "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/client-go/kubernetes" +) + +// RequestHandler is an HTTP handler that proxies requests to a Kubernetes service. +func RequestHandler(kubeConfigStore kubeconfig.ContextStore, w http.ResponseWriter, r *http.Request) { + clusterName, namespace, name, requestURI := parseInfoFromRequest(r) + + defer disableResponseCaching(w) + // Get the context + ctx, err := kubeConfigStore.GetContext(clusterName) + if err != nil { + logger.Log(logger.LevelError, nil, err, "failed to get context") + w.WriteHeader(http.StatusNotFound) + + return + } + + bearerToken, err := getAuthToken(r, clusterName) + if err != nil { + logger.Log(logger.LevelError, nil, err, "failed to get auth token") + w.WriteHeader(http.StatusUnauthorized) + + return + } + + // Get a ClientSet with the auth token + cs, err := ctx.ClientSetWithToken(bearerToken) + if err != nil { + logger.Log(logger.LevelError, nil, err, "failed to get ClientSet") + w.WriteHeader(http.StatusNotFound) + + return + } + + // Get the service + ps, status, err := getServiceFromCluster(cs, namespace, name) + if err != nil { + w.WriteHeader(status) + return + } + + // Get a service connection object and make the request + conn := NewConnection(ps) + + handleServiceProxy(conn, requestURI, w) +} + +func parseInfoFromRequest(r *http.Request) (string, string, string, string) { + clusterName := mux.Vars(r)["clusterName"] + namespace := mux.Vars(r)["namespace"] + name := mux.Vars(r)["name"] + requestURI := r.URL.Query().Get("request") + + return clusterName, namespace, name, requestURI +} + +func getAuthToken(r *http.Request, clusterName string) (string, error) { + // Try to get token from cookie first + tokenFromCookie, err := auth.GetTokenFromCookie(r, clusterName) + if err == nil && tokenFromCookie != "" { + return tokenFromCookie, nil + } + + // Fall back to Authorization header + authToken := r.Header.Get("Authorization") + if len(authToken) == 0 { + return "", fmt.Errorf("unauthorized") + } + + bearerToken := strings.TrimPrefix(authToken, "Bearer ") + if bearerToken == "" { + return "", fmt.Errorf("unauthorized") + } + + return bearerToken, nil +} + +func getServiceFromCluster(cs kubernetes.Interface, namespace string, name string) (*proxyService, int, error) { + ps, err := GetService(cs, namespace, name) + if err != nil { + if errors.IsUnauthorized(err) { + return nil, http.StatusUnauthorized, err + } + + return nil, http.StatusNotFound, err + } + + return ps, http.StatusOK, err +} + +func disableResponseCaching(w http.ResponseWriter) { + w.Header().Set("Cache-Control", "no-cache, private, max-age=0") + w.Header().Set("Expires", time.Unix(0, 0).Format(http.TimeFormat)) + w.Header().Set("Pragma", "no-cache") + w.Header().Set("X-Accel-Expires", "0") +} + +func handleServiceProxy(conn ServiceConnection, requestURI string, w http.ResponseWriter) { + resp, err := conn.Get(requestURI) + if err != nil { + logger.Log(logger.LevelError, nil, err, "service get request failed") + http.Error(w, err.Error(), http.StatusInternalServerError) + + return + } + + _, err = w.Write(resp) + if err != nil { + logger.Log(logger.LevelError, nil, err, "writing response") + http.Error(w, err.Error(), http.StatusInternalServerError) + + return + } +} diff --git a/backend/pkg/serviceproxy/handler_test.go b/backend/pkg/serviceproxy/handler_test.go new file mode 100644 index 00000000000..c8570ced3b7 --- /dev/null +++ b/backend/pkg/serviceproxy/handler_test.go @@ -0,0 +1,546 @@ +package serviceproxy //nolint + +import ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/gorilla/mux" + "github.com/stretchr/testify/assert" + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/client-go/kubernetes/fake" + k8stesting "k8s.io/client-go/testing" +) + +//nolint:funlen +func TestHandleServiceProxy(t *testing.T) { + tests := []struct { + name string + proxyService *proxyService + requestURI string + mockResponse string + mockStatusCode int + expectedCode int + expectedBody string + useMockServer bool + }{ + // Success cases + { + name: "successful request", + proxyService: &proxyService{URIPrefix: "http://example.com"}, + requestURI: "/test", + mockResponse: "Hello, World!", + mockStatusCode: http.StatusOK, + expectedCode: http.StatusOK, + expectedBody: "Hello, World!", + useMockServer: true, + }, + { + name: "successful request with different response", + proxyService: &proxyService{URIPrefix: "http://api.example.com"}, + requestURI: "/api/v1/data", + mockResponse: `{"status": "success", "data": "test"}`, + mockStatusCode: http.StatusOK, + expectedCode: http.StatusOK, + expectedBody: `{"status": "success", "data": "test"}`, + useMockServer: true, + }, + { + name: "request with query parameters", + proxyService: &proxyService{URIPrefix: "https://service.example.com"}, + requestURI: "/api?param=value&test=123", + mockResponse: "Query processed", + mockStatusCode: http.StatusOK, + expectedCode: http.StatusOK, + expectedBody: "Query processed", + useMockServer: true, + }, + { + name: "empty response", + proxyService: &proxyService{URIPrefix: "http://empty.example.com"}, + requestURI: "/empty", + mockResponse: "", + mockStatusCode: http.StatusOK, + expectedCode: http.StatusOK, + expectedBody: "", + useMockServer: true, + }, + // Error cases + { + name: "server returns 404", + proxyService: &proxyService{URIPrefix: "http://example.com"}, + requestURI: "/notfound", + mockResponse: "error response", + mockStatusCode: http.StatusNotFound, + expectedCode: http.StatusInternalServerError, + expectedBody: "failed HTTP GET, status code 404\n", + useMockServer: true, + }, + { + name: "server returns 500", + proxyService: &proxyService{URIPrefix: "http://example.com"}, + requestURI: "/error", + mockResponse: "error response", + mockStatusCode: http.StatusInternalServerError, + expectedCode: http.StatusInternalServerError, + expectedBody: "failed HTTP GET, status code 500\n", + useMockServer: true, + }, + { + name: "invalid URL in proxy service", + proxyService: &proxyService{URIPrefix: "://invalid-url"}, + requestURI: "/test", + mockResponse: "", + mockStatusCode: http.StatusOK, + expectedCode: http.StatusInternalServerError, + expectedBody: "invalid host uri: parse \"://invalid-url\": missing protocol scheme\n", + useMockServer: false, + }, + { + name: "invalid request URI", + proxyService: &proxyService{URIPrefix: "http://example.com"}, + requestURI: "://invalid-request-uri", + mockResponse: "", + mockStatusCode: http.StatusOK, + expectedCode: http.StatusInternalServerError, + expectedBody: "invalid request uri: parse \"://invalid-request-uri\": missing protocol scheme\n", + useMockServer: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Create a mock HTTP server for cases that need it + if tt.useMockServer { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(tt.mockStatusCode) + + if _, err := w.Write([]byte(tt.mockResponse)); err != nil { + t.Fatal(err) + } + })) + defer server.Close() + + // Update the proxy service to use the mock server + tt.proxyService.URIPrefix = server.URL + } + + // Create connection and test + conn := NewConnection(tt.proxyService) + w := httptest.NewRecorder() + handleServiceProxy(conn, tt.requestURI, w) + + assert.Equal(t, tt.expectedCode, w.Code) + assert.Equal(t, tt.expectedBody, w.Body.String()) + }) + } +} + +func TestDisableResponseCaching(t *testing.T) { + w := httptest.NewRecorder() + disableResponseCaching(w) + + assert.Equal(t, "no-cache, private, max-age=0", w.Header().Get("Cache-Control")) + assert.Equal(t, "no-cache", w.Header().Get("Pragma")) + assert.Equal(t, "0", w.Header().Get("X-Accel-Expires")) +} + +// createMockService creates a mock Kubernetes service for testing. +func createMockService(namespace, name string) *corev1.Service { + return &corev1.Service{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Namespace: namespace, + }, + Spec: corev1.ServiceSpec{ + Ports: []corev1.ServicePort{ + { + Name: "https", + Port: 443, + }, + }, + }, + } +} + +//nolint:funlen +func TestGetAuthToken(t *testing.T) { + tests := []struct { + name string + clusterName string + setupRequest func() *http.Request + expectedToken string + expectError bool + errorMsg string + }{ + { + name: "token from cookie", + clusterName: "my-cluster", + setupRequest: func() *http.Request { + req := httptest.NewRequest("GET", "/test", nil) + req.AddCookie(&http.Cookie{ + Name: "headlamp-auth-my-cluster.0", + Value: "cookie-token-xyz", + }) + return req + }, + expectedToken: "cookie-token-xyz", + expectError: false, + }, + { + name: "token from Authorization header when no cookie exists", + clusterName: "test-cluster", + setupRequest: func() *http.Request { + req := httptest.NewRequest("GET", "/test", nil) + req.Header.Set("Authorization", "Bearer header-token-123") + return req + }, + expectedToken: "header-token-123", + expectError: false, + }, + { + name: "cookie takes precedence over Authorization header", + clusterName: "test-cluster", + setupRequest: func() *http.Request { + req := httptest.NewRequest("GET", "/test", nil) + req.AddCookie(&http.Cookie{ + Name: "headlamp-auth-test-cluster.0", + Value: "cookie-token-wins", + }) + req.Header.Set("Authorization", "Bearer header-token-loses") + return req + }, + expectedToken: "cookie-token-wins", + expectError: false, + }, + { + name: "no Authorization header and no cookie returns error", + clusterName: "test-cluster", + setupRequest: func() *http.Request { + req := httptest.NewRequest("GET", "/test", nil) + return req + }, + expectError: true, + errorMsg: "unauthorized", + }, + { + name: "Authorization header with only Bearer keyword", + clusterName: "test-cluster", + setupRequest: func() *http.Request { + req := httptest.NewRequest("GET", "/test", nil) + req.Header.Set("Authorization", "Bearer") + return req + }, + expectedToken: "Bearer", + expectError: false, + }, + { + name: "Authorization header with Bearer and space only - error", + clusterName: "test-cluster", + setupRequest: func() *http.Request { + req := httptest.NewRequest("GET", "/test", nil) + req.Header.Set("Authorization", "Bearer ") + return req + }, + expectError: true, + errorMsg: "unauthorized", + }, + { + name: "valid token with Bearer prefix", + clusterName: "test-cluster", + setupRequest: func() *http.Request { + req := httptest.NewRequest("GET", "/test", nil) + req.Header.Set("Authorization", "Bearer valid-token-value") + return req + }, + expectedToken: "valid-token-value", + expectError: false, + }, + { + name: "Authorization header without Bearer prefix", + clusterName: "test-cluster", + setupRequest: func() *http.Request { + req := httptest.NewRequest("GET", "/test", nil) + req.Header.Set("Authorization", "just-a-token") + return req + }, + expectedToken: "just-a-token", + expectError: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + req := tt.setupRequest() + token, err := getAuthToken(req, tt.clusterName) + + if tt.expectError { + assert.Error(t, err) + + if tt.errorMsg != "" { + assert.Contains(t, err.Error(), tt.errorMsg) + } + } else { + assert.NoError(t, err) + assert.Equal(t, tt.expectedToken, token) + } + }) + } +} + +//nolint:funlen +func TestGetServiceFromCluster(t *testing.T) { + tests := []struct { + name string + namespace string + serviceName string + setupService bool + mockError error + expectedStatus int + expectError bool + }{ + { + name: "service not found", + namespace: "default", + serviceName: "nonexistent-service", + setupService: false, + mockError: nil, + expectedStatus: http.StatusNotFound, + expectError: true, + }, + { + name: "service found successfully", + namespace: "default", + serviceName: "test-service", + setupService: true, + mockError: nil, + expectedStatus: http.StatusOK, + expectError: false, + }, + { + name: "service in different namespace", + namespace: "kube-system", + serviceName: "metrics-server", + setupService: true, + mockError: nil, + expectedStatus: http.StatusOK, + expectError: false, + }, + { + name: "unauthorized access", + namespace: "default", + serviceName: "restricted-service", + setupService: false, + mockError: errors.NewUnauthorized("user does not have permission"), + expectedStatus: http.StatusUnauthorized, + expectError: true, + }, + { + name: "forbidden access", + namespace: "default", + serviceName: "forbidden-service", + setupService: false, + mockError: errors.NewForbidden( + schema.GroupResource{Resource: "services"}, + "forbidden-service", + nil, + ), + expectedStatus: http.StatusNotFound, + expectError: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var cs *fake.Clientset + + switch { + case tt.mockError != nil: + // Create a fake clientset with a reactor to simulate errors + cs = fake.NewSimpleClientset() + cs.PrependReactor("get", "services", func(action k8stesting.Action) (handled bool, ret runtime.Object, err error) { + return true, nil, tt.mockError + }) + case tt.setupService: + // Setup a mock service + service := createMockService(tt.namespace, tt.serviceName) + cs = fake.NewSimpleClientset(service) + default: + // Empty clientset (service not found) + cs = fake.NewSimpleClientset() + } + + ps, status, err := getServiceFromCluster(cs, tt.namespace, tt.serviceName) + + assert.Equal(t, tt.expectedStatus, status) + + if tt.expectError { + assert.Error(t, err) + assert.Nil(t, ps) + } else { + assert.NoError(t, err) + assert.NotNil(t, ps) + assert.Equal(t, tt.serviceName, ps.Name) + assert.Equal(t, tt.namespace, ps.Namespace) + } + }) + } +} + +//nolint:funlen +func TestParseInfoFromRequest(t *testing.T) { + tests := []struct { + name string + setupRequest func() *http.Request + expectedClusterName string + expectedNamespace string + expectedName string + expectedRequestURI string + }{ + { + name: "standard case with all parameters", + setupRequest: func() *http.Request { + req := httptest.NewRequest("GET", + "/clusters/test-cluster/namespaces/test-namespace/services/test-service/proxy?request=/api/v1/data", + nil) + req = mux.SetURLVars(req, map[string]string{ + "clusterName": "test-cluster", + "namespace": "test-namespace", + "name": "test-service", + }) + return req + }, + expectedClusterName: "test-cluster", + expectedNamespace: "test-namespace", + expectedName: "test-service", + expectedRequestURI: "/api/v1/data", + }, + { + name: "cluster name with hyphens and numbers", + setupRequest: func() *http.Request { + req := httptest.NewRequest("GET", + "/clusters/prod-cluster-123/namespaces/kube-system/services/metrics-server/proxy?request=/metrics", + nil) + req = mux.SetURLVars(req, map[string]string{ + "clusterName": "prod-cluster-123", + "namespace": "kube-system", + "name": "metrics-server", + }) + return req + }, + expectedClusterName: "prod-cluster-123", + expectedNamespace: "kube-system", + expectedName: "metrics-server", + expectedRequestURI: "/metrics", + }, + { + name: "request URI with query parameters", + setupRequest: func() *http.Request { + // The & in the request parameter needs to be URL encoded as %26 + req := httptest.NewRequest("GET", "/proxy?request=/api/endpoint?param1=value1%26param2=value2", nil) + req = mux.SetURLVars(req, map[string]string{ + "clusterName": "my-cluster", + "namespace": "default", + "name": "my-service", + }) + return req + }, + expectedClusterName: "my-cluster", + expectedNamespace: "default", + expectedName: "my-service", + expectedRequestURI: "/api/endpoint?param1=value1¶m2=value2", + }, + { + name: "empty request URI parameter", + setupRequest: func() *http.Request { + req := httptest.NewRequest("GET", "/proxy", nil) + req = mux.SetURLVars(req, map[string]string{ + "clusterName": "cluster1", + "namespace": "ns1", + "name": "svc1", + }) + return req + }, + expectedClusterName: "cluster1", + expectedNamespace: "ns1", + expectedName: "svc1", + expectedRequestURI: "", + }, + { + name: "request URI with special characters encoded", + setupRequest: func() *http.Request { + req := httptest.NewRequest("GET", "/proxy?request=/api/v1/users%2F123%2Fprofile", nil) + req = mux.SetURLVars(req, map[string]string{ + "clusterName": "test", + "namespace": "app", + "name": "backend", + }) + return req + }, + expectedClusterName: "test", + expectedNamespace: "app", + expectedName: "backend", + expectedRequestURI: "/api/v1/users/123/profile", + }, + { + name: "missing mux variables returns empty strings", + setupRequest: func() *http.Request { + req := httptest.NewRequest("GET", "/proxy?request=/test", nil) + // Not setting any mux vars + return req + }, + expectedClusterName: "", + expectedNamespace: "", + expectedName: "", + expectedRequestURI: "/test", + }, + { + name: "service name with dots (for headless services)", + setupRequest: func() *http.Request { + req := httptest.NewRequest("GET", "/proxy?request=/health", nil) + req = mux.SetURLVars(req, map[string]string{ + "clusterName": "cluster", + "namespace": "default", + "name": "my-service.default.svc.cluster.local", + }) + return req + }, + expectedClusterName: "cluster", + expectedNamespace: "default", + expectedName: "my-service.default.svc.cluster.local", + expectedRequestURI: "/health", + }, + { + name: "complex request URI with path and multiple query params", + setupRequest: func() *http.Request { + // The & in the request parameter needs to be URL encoded as %26 + req := httptest.NewRequest("GET", "/proxy?request=/api/v2/search?q=test%26limit=10%26offset=0", nil) + req = mux.SetURLVars(req, map[string]string{ + "clusterName": "production", + "namespace": "api-namespace", + "name": "search-service", + }) + return req + }, + expectedClusterName: "production", + expectedNamespace: "api-namespace", + expectedName: "search-service", + expectedRequestURI: "/api/v2/search?q=test&limit=10&offset=0", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + req := tt.setupRequest() + clusterName, namespace, name, requestURI := parseInfoFromRequest(req) + assert.Equal(t, tt.expectedClusterName, clusterName) + assert.Equal(t, tt.expectedNamespace, namespace) + assert.Equal(t, tt.expectedName, name) + assert.Equal(t, tt.expectedRequestURI, requestURI) + }) + } +} diff --git a/backend/pkg/serviceproxy/http.go b/backend/pkg/serviceproxy/http.go new file mode 100644 index 00000000000..e9372d41aae --- /dev/null +++ b/backend/pkg/serviceproxy/http.go @@ -0,0 +1,40 @@ +package serviceproxy + +import ( + "context" + "fmt" + "io" + "net/http" + "time" + + "github.com/kubernetes-sigs/headlamp/backend/pkg/logger" +) + +// HTTPGet sends an HTTP GET request to the specified URI. +func HTTPGet(ctx context.Context, uri string) ([]byte, error) { + cli := &http.Client{Timeout: 10 * time.Second} + + logger.Log(logger.LevelInfo, nil, nil, fmt.Sprintf("make request to %s", uri)) + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, uri, nil) + if err != nil { + return nil, fmt.Errorf("creating request: %v", err) + } + + resp, err := cli.Do(req) + if err != nil { + return nil, fmt.Errorf("failed HTTP GET: %v", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("failed HTTP GET, status code %v", resp.StatusCode) + } + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, err + } + + return body, nil +} diff --git a/backend/pkg/serviceproxy/http_test.go b/backend/pkg/serviceproxy/http_test.go new file mode 100644 index 00000000000..3f88029738d --- /dev/null +++ b/backend/pkg/serviceproxy/http_test.go @@ -0,0 +1,115 @@ +package serviceproxy_test + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/kubernetes-sigs/headlamp/backend/pkg/serviceproxy" +) + +//nolint:funlen +func TestHTTPGet(t *testing.T) { + tests := []struct { + name string + url string + statusCode int + body string + wantErr bool + }{ + { + name: "valid URL", + url: "http://example.com", + statusCode: http.StatusOK, + body: "Hello, World!", + wantErr: false, + }, + { + name: "invalid URL", + url: " invalid-url", + statusCode: 0, + body: "", + wantErr: true, + }, + { + name: "server returns error response", + url: "http://example.com/error", + statusCode: http.StatusInternalServerError, + body: "", + wantErr: true, + }, + { + name: "context cancellation", + url: "http://example.com/cancel", + statusCode: http.StatusOK, + body: "Hello, World!", + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case tt.url == "http://example.com/error": + w.WriteHeader(http.StatusInternalServerError) + case tt.name == "context cancellation": + <-r.Context().Done() + w.WriteHeader(http.StatusOK) + + if _, err := w.Write([]byte(tt.body)); err != nil { + t.Fatal(err) + } + default: + if _, err := w.Write([]byte(tt.body)); err != nil { + t.Fatalf("write test: %v", err) + } + } + })) + defer ts.Close() + + url := ts.URL + if tt.url == " invalid-url" { + url = tt.url + } else if tt.url == "http://example.com/error" { + url = ts.URL + "/error" + } + + if ctx := context.Background(); tt.name == "context cancellation" { + var cancel context.CancelFunc + _, cancel = context.WithCancel(ctx) + cancel() + } + + resp, err := serviceproxy.HTTPGet(context.Background(), url) + if (err != nil) != tt.wantErr { + t.Errorf("HTTPGet() error = %v, wantErr %v", err, tt.wantErr) + } + + if !tt.wantErr && string(resp) != tt.body { + t.Errorf("HTTPGet() response = %s, want %s", resp, tt.body) + } + }) + } +} + +func TestHTTPGetTimeout(t *testing.T) { + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + time.Sleep(15 * time.Second) + + if _, err := w.Write([]byte("Hello, World!")); err != nil { + t.Fatalf("write test: %v", err) + } + })) + defer ts.Close() + + ctx, cancel := context.WithTimeout(context.Background(), 1*time.Second) + defer cancel() + + _, err := serviceproxy.HTTPGet(ctx, ts.URL) + if err == nil { + t.Errorf("HTTPGet() error = nil, want error") + } +} diff --git a/backend/pkg/serviceproxy/service.go b/backend/pkg/serviceproxy/service.go new file mode 100644 index 00000000000..9bb309f5898 --- /dev/null +++ b/backend/pkg/serviceproxy/service.go @@ -0,0 +1,89 @@ +package serviceproxy + +import ( + "context" + "fmt" + + "github.com/kubernetes-sigs/headlamp/backend/pkg/logger" + + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/client-go/kubernetes" +) + +const ( + HTTPSchemeName = "http" + HTTPSSchemeName = "https" +) + +type proxyService struct { + IsExternal bool `yaml:"is_external"` + Port int32 `yaml:"port"` + Name string `yaml:"name"` + Namespace string `yaml:"namespace"` + Scheme string `yaml:"scheme"` + URIPrefix string `yaml:"URIPrefix"` +} + +// GetService returns the requested service based on the provided name and namespace. +func GetService(cs kubernetes.Interface, namespace string, name string) (*proxyService, error) { + service, err := cs.CoreV1().Services(namespace).Get(context.TODO(), name, metav1.GetOptions{}) + if err != nil { + return nil, err + } + + ps := &proxyService{ + Name: service.Name, + Namespace: service.Namespace, + IsExternal: len(service.Spec.ExternalName) > 0, + } + + port, err := GetPort(service.Spec.Ports) + if err != nil { + logger.Log(logger.LevelError, nil, err, "service port not found") + + return nil, err + } + + ps.Port = port.Port + + // Determine scheme - always use https for external + if port.Name == HTTPSchemeName { + ps.Scheme = HTTPSchemeName + } else { + ps.Scheme = HTTPSSchemeName + } + + ps.URIPrefix = getServiceURLPrefix(ps, service) + + return ps, nil +} + +// GetPort - return the first port named "http" or "https". +// Prefer "https" over "http" if both exist. +func GetPort(ports []corev1.ServicePort) (*corev1.ServicePort, error) { + for i, port := range ports { + if port.Name == HTTPSSchemeName { + return &ports[i], nil + } + } + + for i, port := range ports { + if port.Name == HTTPSchemeName { + return &ports[i], nil + } + } + + return nil, fmt.Errorf("no port found with the name http or https") +} + +// getServiceURLPrefix generates a URL prefix for a Kubernetes service based on the provided proxyService and service +// If the service is external, the function generates a URL prefix in the format ://: +// Otherwise, the function generates a URL prefix in the format ://.:. +func getServiceURLPrefix(ps *proxyService, service *corev1.Service) string { + if ps.IsExternal { + return fmt.Sprintf("%s://%s:%d", ps.Scheme, service.Spec.ExternalName, ps.Port) + } + + return fmt.Sprintf("%s://%s.%s:%d", ps.Scheme, ps.Name, ps.Namespace, ps.Port) +} diff --git a/backend/pkg/serviceproxy/service_test.go b/backend/pkg/serviceproxy/service_test.go new file mode 100644 index 00000000000..50f9624367f --- /dev/null +++ b/backend/pkg/serviceproxy/service_test.go @@ -0,0 +1,142 @@ +package serviceproxy_test + +import ( + "context" + "testing" + + "github.com/kubernetes-sigs/headlamp/backend/pkg/serviceproxy" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/client-go/kubernetes/fake" +) + +func TestGetServiceInternal(t *testing.T) { + // Test GetService() for internal services + cs := fake.NewSimpleClientset() + service := &corev1.Service{ + ObjectMeta: metav1.ObjectMeta{ + Name: "my-service", + Namespace: "default", + }, + Spec: corev1.ServiceSpec{ + Ports: []corev1.ServicePort{ + { + Name: "http", + Port: 80, + }, + }, + }, + } + + _, err := cs.CoreV1().Services("default").Create(context.TODO(), service, metav1.CreateOptions{}) + if err != nil { + t.Errorf("Failed to create test service: %v", err) + } + + ps, err := serviceproxy.GetService(cs, "default", "my-service") + if err != nil { + t.Errorf("GetService() error = %v", err) + } + + if ps.URIPrefix != "http://my-service.default:80" { + t.Errorf("GetService() URIPrefix = %s, wantPrefix http://my-service.default:80", ps.URIPrefix) + } +} + +func TestGetServiceExternal(t *testing.T) { + // Test GetService() for external services + cs := fake.NewSimpleClientset() + service := &corev1.Service{ + ObjectMeta: metav1.ObjectMeta{ + Name: "my-service", + Namespace: "default", + }, + Spec: corev1.ServiceSpec{ + ExternalName: "example.com", + Ports: []corev1.ServicePort{ + { + Name: "https", + Port: 443, + }, + }, + }, + } + + _, err := cs.CoreV1().Services("default").Create(context.TODO(), service, metav1.CreateOptions{}) + if err != nil { + t.Errorf("Failed to create test service: %v", err) + } + + ps, err := serviceproxy.GetService(cs, "default", "my-service") + if err != nil { + t.Errorf("GetService() error = %v", err) + } + + if ps.URIPrefix != "https://example.com:443" { + t.Errorf("GetService() URIPrefix = %s, wantPrefix https://example.com:443", ps.URIPrefix) + } +} + +func TestGetServiceNonExistent(t *testing.T) { + // Test GetService() for non-existent services + cs := fake.NewSimpleClientset() + + _, err := serviceproxy.GetService(cs, "default", "non-existent-service") + if err == nil { + t.Errorf("GetService() error = nil, wantErr not nil") + } +} + +func TestGetPort(t *testing.T) { + tests := []struct { + name string + ports []corev1.ServicePort + wantPort int32 + wantErr bool + }{ + { + name: "https port exists", + ports: []corev1.ServicePort{ + {Name: "https", Port: 443}, + {Name: "http", Port: 80}, + }, + wantPort: 443, + wantErr: false, + }, + { + name: "http port exists, https port does not exist", + ports: []corev1.ServicePort{ + {Name: "http", Port: 80}, + }, + wantPort: 80, + wantErr: false, + }, + { + name: "neither http nor https port exists", + ports: []corev1.ServicePort{ + {Name: "other", Port: 8080}, + }, + wantPort: 0, + wantErr: true, + }, + { + name: "empty ports list", + ports: []corev1.ServicePort{}, + wantPort: 0, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + port, err := serviceproxy.GetPort(tt.ports) + if (err != nil) != tt.wantErr { + t.Errorf("GetPort() error = %v, wantErr %v", err, tt.wantErr) + } + + if !tt.wantErr && port.Port != tt.wantPort { + t.Errorf("GetPort() port = %d, wantPort %d", port.Port, tt.wantPort) + } + }) + } +} diff --git a/charts/headlamp/README.md b/charts/headlamp/README.md index f4a2f401224..4852c1c0a88 100644 --- a/charts/headlamp/README.md +++ b/charts/headlamp/README.md @@ -67,15 +67,14 @@ $ helm install my-headlamp headlamp/headlamp \ ### Application Configuration -| Key | Type | Default | Description | -|-----|------|---------|-------------| -| config.inCluster | bool | `true` | Run Headlamp in-cluster | -| config.baseURL | string | `""` | Base URL path for Headlamp UI | -| config.pluginsDir | string | `"/headlamp/plugins"` | Directory to load Headlamp plugins from | -| config.enableHelm | bool | `false` | Enable Helm operations like install, upgrade and uninstall of Helm charts | -| config.extraArgs | array | `[]` | Additional arguments for Headlamp server | -| config.tlsCertPath | string | `""` | Certificate for serving TLS | -| config.tlsKeyPath | string | `""` | Key for serving TLS | +| Key | Type | Default | Description | +|--------------------|--------|-----------------------|---------------------------------------------------------------------------| +| config.inCluster | bool | `true` | Run Headlamp in-cluster | +| config.baseURL | string | `""` | Base URL path for Headlamp UI | +| config.pluginsDir | string | `"/headlamp/plugins"` | Directory to load Headlamp plugins from | +| config.extraArgs | array | `[]` | Additional arguments for Headlamp server | +| config.tlsCertPath | string | `""` | Certificate for serving TLS | +| config.tlsKeyPath | string | `""` | Key for serving TLS | ### OIDC Configuration diff --git a/charts/headlamp/templates/deployment.yaml b/charts/headlamp/templates/deployment.yaml index 505c9712feb..72455c89e0f 100644 --- a/charts/headlamp/templates/deployment.yaml +++ b/charts/headlamp/templates/deployment.yaml @@ -202,9 +202,6 @@ spec: {{- if .Values.config.inCluster }} - "-in-cluster" {{- end }} - {{- with .Values.config.enableHelm}} - - "-enable-helm" - {{- end }} {{- if .Values.config.watchPlugins }} - "-watch-plugins-changes" {{- end }} diff --git a/charts/headlamp/values.yaml b/charts/headlamp/values.yaml index 4f1281dbe7d..249fba901d7 100644 --- a/charts/headlamp/values.yaml +++ b/charts/headlamp/values.yaml @@ -96,7 +96,6 @@ config: name: "" # -- directory to look for plugins pluginsDir: "/headlamp/plugins" - enableHelm: false watchPlugins: false # tlsCertPath: "/headlamp-cert/headlamp-ca.crt" # tlsKeyPath: "/headlamp-cert/headlamp-tls.key"