Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 6 additions & 4 deletions controllers/workspace/devworkspace_controller.go
Original file line number Diff line number Diff line change
Expand Up @@ -144,9 +144,6 @@ func (r *DevWorkspaceReconciler) Reconcile(ctx context.Context, req ctrl.Request
reqLogger = reqLogger.WithValues(constants.DevWorkspaceIDLoggerKey, workspace.Status.DevWorkspaceId)
reqLogger.Info("Reconciling Workspace", "resolvedConfig", configString)

// Inject ca certificates to the http client, if the certificates configmap is created and defined in the config.
InjectCertificates(r.Client, r.Log)

// Check if the DevWorkspaceRouting instance is marked to be deleted, which is
// indicated by the deletion timestamp being set.
if workspace.GetDeletionTimestamp() != nil {
Expand Down Expand Up @@ -260,6 +257,8 @@ func (r *DevWorkspaceReconciler) Reconcile(ctx context.Context, req ctrl.Request
return reconcile.Result{Requeue: true}, err
}

httpClient := httpClientsFactory.GetHttpClient(ctx, config.Routing)

flattenHelpers := flatten.ResolverTools{
WorkspaceNamespace: workspace.Namespace,
Context: ctx,
Expand Down Expand Up @@ -788,7 +787,10 @@ func (r *DevWorkspaceReconciler) getWorkspaceId(ctx context.Context, workspace *
}

func (r *DevWorkspaceReconciler) SetupWithManager(mgr ctrl.Manager) error {
setupHttpClients(mgr.GetClient(), mgr.GetLogger())
err := SetupHttpClientsFactory(mgr.GetClient(), mgr.GetLogger())
if err != nil {
return err
}

maxConcurrentReconciles, err := wkspConfig.GetMaxConcurrentReconciles()
if err != nil {
Expand Down
273 changes: 215 additions & 58 deletions controllers/workspace/http.go
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
// Copyright (c) 2019-2025 Red Hat, Inc.
// Copyright (c) 2019-2026 Red Hat, Inc.
// 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
Expand All @@ -17,12 +17,14 @@ import (
"context"
"crypto/tls"
"crypto/x509"
"fmt"
"net/http"
"net/url"
"reflect"
"sync"
"time"

"github.com/devfile/devworkspace-operator/pkg/config"

controller "github.com/devfile/devworkspace-operator/apis/controller/v1alpha1"
"k8s.io/apimachinery/pkg/types"

"github.com/go-logr/logr"
Expand All @@ -32,87 +34,242 @@ import (
"golang.org/x/net/http/httpproxy"
)

var (
var httpClientsFactory HttpClientsFactory

type HttpClientsFactory interface {
// GetHttpClient returns an HTTP client configured with proxy, TLS, and custom CA certificates
// from routingConfig.
GetHttpClient(context.Context, *controller.RoutingConfig) *http.Client

// GetHealthCheckHttpClient returns an HTTP client that skips TLS verification.
// This client MUST only be used for workspace health/readiness checks, not for
// fetching external content or making security-sensitive requests.
GetHealthCheckHttpClient(*controller.RoutingConfig) *http.Client
}

// DefaultHttpClientsFactory is a thread-safe, caching implementation of HttpClientsFactory.
// It caches one HTTP client and one health-check client, rebuilding either only when the
// relevant routing configuration (proxy settings, TLS certificates) changes.
type DefaultHttpClientsFactory struct {
k8s client.Client
logger logr.Logger

httpClient *http.Client
healthCheckHttpClient *http.Client
)

func setupHttpClients(k8s client.Client, logger logr.Logger) {
transport := http.DefaultTransport.(*http.Transport).Clone()
healthCheckTransport := http.DefaultTransport.(*http.Transport).Clone()
healthCheckTransport.TLSClientConfig = &tls.Config{
InsecureSkipVerify: true,
mu sync.RWMutex

httpClientProxyConfig *controller.Proxy
httpClientConfigmapRef *controller.ConfigmapReference
httpClientCertsVersion string

healthCheckHttpClientProxyConfig *controller.Proxy

systemCertPool *x509.CertPool
}

func SetupHttpClientsFactory(k8s client.Client, logger logr.Logger) error {
systemCertPool, err := x509.SystemCertPool()
if err != nil {
return fmt.Errorf("failed to load system cert pool: %w", err)
}

globalConfig := config.GetGlobalConfig()
httpClientsFactory = &DefaultHttpClientsFactory{
k8s: k8s,
logger: logger,
systemCertPool: systemCertPool,
}

if globalConfig.Routing != nil && globalConfig.Routing.ProxyConfig != nil {
proxyConf := httpproxy.Config{}
if globalConfig.Routing.ProxyConfig.HttpProxy != nil {
proxyConf.HTTPProxy = *globalConfig.Routing.ProxyConfig.HttpProxy
}
if globalConfig.Routing.ProxyConfig.HttpsProxy != nil {
proxyConf.HTTPSProxy = *globalConfig.Routing.ProxyConfig.HttpsProxy
}
if globalConfig.Routing.ProxyConfig.NoProxy != nil {
proxyConf.NoProxy = *globalConfig.Routing.ProxyConfig.NoProxy
return nil
}

func (h *DefaultHttpClientsFactory) GetHttpClient(ctx context.Context, routingConfig *controller.RoutingConfig) *http.Client {
certsCM := h.readCertificates(ctx, routingConfig)

h.mu.RLock()
if !h.shouldCreateHttpClient(routingConfig, certsCM) {
defer h.mu.RUnlock()
return h.httpClient
}
h.mu.RUnlock()

h.mu.Lock()
defer h.mu.Unlock()

if h.shouldCreateHttpClient(routingConfig, certsCM) {
h.httpClient = h.createHttpClient(routingConfig, certsCM)

if routingConfig == nil {
h.httpClientProxyConfig = nil
} else {
h.httpClientProxyConfig = routingConfig.ProxyConfig.DeepCopy()
}

proxyFunc := func(req *http.Request) (*url.URL, error) {
return proxyConf.ProxyFunc()(req.URL)
if certsCM == nil {
h.httpClientCertsVersion = ""
h.httpClientConfigmapRef = nil
} else {
h.httpClientCertsVersion = certsCM.ResourceVersion
h.httpClientConfigmapRef = &controller.ConfigmapReference{
Name: certsCM.Name,
Namespace: certsCM.Namespace,
}
}
transport.Proxy = proxyFunc
healthCheckTransport.Proxy = proxyFunc
}

httpClient = &http.Client{
return h.httpClient
}

func (h *DefaultHttpClientsFactory) createHttpClient(routingConfig *controller.RoutingConfig, certsCM *corev1.ConfigMap) *http.Client {
transport := http.DefaultTransport.(*http.Transport).Clone()
transport.Proxy = h.getProxyFunc(routingConfig)
transport.TLSClientConfig = &tls.Config{
RootCAs: h.getCaCertPool(certsCM),
}

return &http.Client{
Transport: transport,
Timeout: 10 * time.Second,
}
healthCheckHttpClient = &http.Client{
Transport: healthCheckTransport,
Timeout: 500 * time.Millisecond,
}

func (h *DefaultHttpClientsFactory) shouldCreateHttpClient(routingConfig *controller.RoutingConfig, certsCM *corev1.ConfigMap) bool {
if h.httpClient == nil {
return true
}

var certsVersion string
var configmapRef *controller.ConfigmapReference
var proxyConfig *controller.Proxy

if certsCM != nil {
certsVersion = certsCM.ResourceVersion
configmapRef = &controller.ConfigmapReference{
Name: certsCM.Name,
Namespace: certsCM.Namespace,
}
}
InjectCertificates(k8s, logger)

if routingConfig != nil {
proxyConfig = routingConfig.ProxyConfig
}

return certsVersion != h.httpClientCertsVersion ||
!reflect.DeepEqual(configmapRef, h.httpClientConfigmapRef) ||
!reflect.DeepEqual(proxyConfig, h.httpClientProxyConfig)
}

func InjectCertificates(k8s client.Client, logger logr.Logger) {
if certs, ok := readCertificates(k8s, logger); ok {
for _, certsPem := range certs {
injectCertificates([]byte(certsPem), httpClient.Transport.(*http.Transport), logger)
func (h *DefaultHttpClientsFactory) GetHealthCheckHttpClient(routingConfig *controller.RoutingConfig) *http.Client {
h.mu.RLock()
if !h.shouldCreateHealthCheckHttpClient(routingConfig) {
defer h.mu.RUnlock()
return h.healthCheckHttpClient
}
h.mu.RUnlock()

h.mu.Lock()
defer h.mu.Unlock()

if h.shouldCreateHealthCheckHttpClient(routingConfig) {
h.healthCheckHttpClient = h.createHealthCheckHttpClient(routingConfig)

if routingConfig == nil {
h.healthCheckHttpClientProxyConfig = nil
} else {
h.healthCheckHttpClientProxyConfig = routingConfig.ProxyConfig.DeepCopy()
}
}

return h.healthCheckHttpClient
}

func readCertificates(k8s client.Client, logger logr.Logger) (map[string]string, bool) {
configmapRef := config.GetGlobalConfig().Routing.TLSCertificateConfigmapRef
if configmapRef == nil {
return nil, false
func (h *DefaultHttpClientsFactory) shouldCreateHealthCheckHttpClient(routingConfig *controller.RoutingConfig) bool {
if h.healthCheckHttpClient == nil {
return true
}
configMap := &corev1.ConfigMap{}
namespacedName := &types.NamespacedName{
Name: configmapRef.Name,
Namespace: configmapRef.Namespace,

var proxyConfig *controller.Proxy

if routingConfig != nil {
proxyConfig = routingConfig.ProxyConfig
}
err := k8s.Get(context.Background(), *namespacedName, configMap)
if err != nil {
logger.Error(err, "Failed to read configmap with certificates")
return nil, false

return !reflect.DeepEqual(proxyConfig, h.healthCheckHttpClientProxyConfig)
}

func (h *DefaultHttpClientsFactory) createHealthCheckHttpClient(routingConfig *controller.RoutingConfig) *http.Client {
transport := http.DefaultTransport.(*http.Transport).Clone()
transport.Proxy = h.getProxyFunc(routingConfig)
transport.TLSClientConfig = &tls.Config{
InsecureSkipVerify: true,
}

return &http.Client{
Transport: transport,
Timeout: 500 * time.Millisecond,
}
return configMap.Data, true
}

func injectCertificates(certsPem []byte, transport *http.Transport, logger logr.Logger) {
caCertPool := transport.TLSClientConfig.RootCAs
if caCertPool == nil {
systemCertPool, err := x509.SystemCertPool()
if err != nil {
logger.Error(err, "Failed to load system cert pool")
caCertPool = x509.NewCertPool()
} else {
caCertPool = systemCertPool
// getProxyFunc returns a proxy function based on the proxy settings in routingConfig.
// Returns nil if no proxy is configured; a nil proxy func causes the HTTP transport to
// use the default proxy settings from environment variables.
func (h *DefaultHttpClientsFactory) getProxyFunc(routingConfig *controller.RoutingConfig) func(*http.Request) (*url.URL, error) {
if routingConfig == nil || routingConfig.ProxyConfig == nil {
return nil
}

proxyConfig := httpproxy.Config{}
if routingConfig.ProxyConfig.HttpProxy != nil {
proxyConfig.HTTPProxy = *routingConfig.ProxyConfig.HttpProxy
}
if routingConfig.ProxyConfig.HttpsProxy != nil {
proxyConfig.HTTPSProxy = *routingConfig.ProxyConfig.HttpsProxy
}
if routingConfig.ProxyConfig.NoProxy != nil {
proxyConfig.NoProxy = *routingConfig.ProxyConfig.NoProxy
}

return func(req *http.Request) (*url.URL, error) {
return proxyConfig.ProxyFunc()(req.URL)
}
}

// getCaCertPool returns a CA cert pool that includes system certs and any additional certs from the ConfigMap.
// A nil pool causes the HTTP client to use the system default root CAs.
func (h *DefaultHttpClientsFactory) getCaCertPool(certsCM *corev1.ConfigMap) *x509.CertPool {
if certsCM == nil || len(certsCM.Data) == 0 {
return nil
}

caCertPool := h.systemCertPool.Clone()

for _, certsPem := range certsCM.Data {
if !caCertPool.AppendCertsFromPEM([]byte(certsPem)) {
h.logger.Error(fmt.Errorf("failed to parse one or more certificates from ConfigMap"), "Could not append CA certificates to pool")
}
}
if ok := caCertPool.AppendCertsFromPEM(certsPem); ok {
transport.TLSClientConfig = &tls.Config{RootCAs: caCertPool}

return caCertPool
}

func (h *DefaultHttpClientsFactory) readCertificates(ctx context.Context, routingConfig *controller.RoutingConfig) *corev1.ConfigMap {
if routingConfig == nil || routingConfig.TLSCertificateConfigmapRef == nil {
return nil
}

configmapRef := routingConfig.TLSCertificateConfigmapRef

namespacedName := types.NamespacedName{
Name: configmapRef.Name,
Namespace: configmapRef.Namespace,
}

configMap := &corev1.ConfigMap{}
if err := h.k8s.Get(ctx, namespacedName, configMap); err != nil {
// print and ignore the error, http clients will be created with host's root CA set.
h.logger.Error(err, "Failed to read ConfigMap containing certificates", "namespace", configmapRef.Namespace, "name", configmapRef.Name)
return nil
}

return configMap
}
Loading
Loading