diff --git a/deploy/05controller-deployment.yaml b/deploy/05controller-deployment.yaml index 78dbe2b8e..4c5890e3a 100644 --- a/deploy/05controller-deployment.yaml +++ b/deploy/05controller-deployment.yaml @@ -42,6 +42,9 @@ spec: - configMapRef: name: pf9-env optional: true + - secretRef: + name: pf9-proxy-creds + optional: true image: quay.io/platform9/vjailbreak-controller:main imagePullPolicy: IfNotPresent lifecycle: diff --git a/deploy/06vpwned-deployment.yaml b/deploy/06vpwned-deployment.yaml index e349176e9..d60497c54 100644 --- a/deploy/06vpwned-deployment.yaml +++ b/deploy/06vpwned-deployment.yaml @@ -38,6 +38,9 @@ spec: - configMapRef: name: pf9-env optional: true + - secretRef: + name: pf9-proxy-creds + optional: true image: quay.io/platform9/vjailbreak-vpwned:main imagePullPolicy: IfNotPresent name: vpwned diff --git a/deploy/installer.yaml b/deploy/installer.yaml index 03bf1871b..f0cdad2eb 100644 --- a/deploy/installer.yaml +++ b/deploy/installer.yaml @@ -5220,6 +5220,9 @@ spec: - configMapRef: name: pf9-env optional: true + - secretRef: + name: pf9-proxy-creds + optional: true image: quay.io/platform9/vjailbreak-controller:main imagePullPolicy: IfNotPresent lifecycle: @@ -5325,6 +5328,9 @@ spec: - configMapRef: name: pf9-env optional: true + - secretRef: + name: pf9-proxy-creds + optional: true image: quay.io/platform9/vjailbreak-vpwned:main imagePullPolicy: IfNotPresent name: vpwned diff --git a/k8s/migration/config/addons/k8s.svc.yaml b/k8s/migration/config/addons/k8s.svc.yaml index 7fa9ed3d5..4920322ae 100644 --- a/k8s/migration/config/addons/k8s.svc.yaml +++ b/k8s/migration/config/addons/k8s.svc.yaml @@ -69,6 +69,9 @@ spec: - configMapRef: name: pf9-env optional: true + - secretRef: + name: pf9-proxy-creds + optional: true affinity: nodeAffinity: requiredDuringSchedulingIgnoredDuringExecution: diff --git a/k8s/migration/config/manager/manager.yaml b/k8s/migration/config/manager/manager.yaml index df523759d..6b125ae21 100644 --- a/k8s/migration/config/manager/manager.yaml +++ b/k8s/migration/config/manager/manager.yaml @@ -71,6 +71,9 @@ spec: - configMapRef: name: pf9-env optional: true + - secretRef: + name: pf9-proxy-creds + optional: true args: - --leader-elect=false - --health-probe-bind-address=:8081 diff --git a/k8s/migration/internal/controller/migrationplan_controller.go b/k8s/migration/internal/controller/migrationplan_controller.go index c59510a7e..f7e1ff228 100644 --- a/k8s/migration/internal/controller/migrationplan_controller.go +++ b/k8s/migration/internal/controller/migrationplan_controller.go @@ -1294,6 +1294,14 @@ func (r *MigrationPlanReconciler) CreateJob(ctx context.Context, }, }, }) + envFrom = append(envFrom, corev1.EnvFromSource{ + SecretRef: &corev1.SecretEnvSource{ + LocalObjectReference: corev1.LocalObjectReference{ + Name: "pf9-proxy-creds", + }, + Optional: &pointtrue, + }, + }) return envFrom }(), VolumeMounts: []corev1.VolumeMount{ diff --git a/pkg/common/utils/net.go b/pkg/common/utils/net.go index 80d0c1522..ceb264890 100644 --- a/pkg/common/utils/net.go +++ b/pkg/common/utils/net.go @@ -26,6 +26,27 @@ type VjbNet struct { NoProxy string UseProxyFromEnv bool proxyCfg *httpproxy.Config + + HTTPProxyUsername string + HTTPProxyPassword string + HTTPSProxyUsername string + HTTPSProxyPassword string +} + +func withProxyCredentials(rawURL, username, password string) string { + if rawURL == "" || username == "" { + return rawURL + } + u, err := url.Parse(rawURL) + if err != nil || u.Host == "" { + u2, err2 := url.Parse("http://" + rawURL) + if err2 != nil || u2.Host == "" { + return rawURL + } + u = u2 + } + u.User = url.UserPassword(username, password) + return u.String() } func (v *VjbNet) getNetTransport(tlsConfig *tls.Config) *http.Transport { @@ -53,11 +74,14 @@ func (v *VjbNet) getNetTransport(tlsConfig *tls.Config) *http.Transport { } transport.Proxy = func(req *http.Request) (*url.URL, error) { - proxyURL, err := v.proxyCfg.ProxyFunc()(req.URL) + cfg := *v.proxyCfg + cfg.HTTPProxy = withProxyCredentials(cfg.HTTPProxy, v.HTTPProxyUsername, v.HTTPProxyPassword) + cfg.HTTPSProxy = withProxyCredentials(cfg.HTTPSProxy, v.HTTPSProxyUsername, v.HTTPSProxyPassword) + + proxyURL, err := cfg.ProxyFunc()(req.URL) if err != nil { return nil, err } - // Preserve existing logging behavior. if proxyURL != nil { fmt.Printf("Proxy config: HTTPProxy=%s, HTTPSProxy=%s, NoProxy=%s\n", v.proxyCfg.HTTPProxy, v.proxyCfg.HTTPSProxy, v.proxyCfg.NoProxy) @@ -80,9 +104,6 @@ func (v *VjbNet) CreateHTTPClient() error { } transport := v.getNetTransport(tlsConfig) - if v.UseProxyFromEnv { - transport.Proxy = http.ProxyFromEnvironment - } v.Client = &http.Client{ Transport: transport, Timeout: v.timeout, @@ -102,10 +123,6 @@ func (v *VjbNet) CreateSecureHTTPClient() error { transport := v.getNetTransport(tlsConfig) - if v.UseProxyFromEnv { - transport.Proxy = http.ProxyFromEnvironment - } - v.Client = &http.Client{ Transport: transport, Timeout: v.timeout, @@ -137,6 +154,16 @@ func (v *VjbNet) SetUseProxyFromEnv(use bool) { v.UseProxyFromEnv = use } +func (v *VjbNet) SetHTTPProxyCredentials(username, password string) { + v.HTTPProxyUsername = username + v.HTTPProxyPassword = password +} + +func (v *VjbNet) SetHTTPSProxyCredentials(username, password string) { + v.HTTPSProxyUsername = username + v.HTTPSProxyPassword = password +} + func (v *VjbNet) GetClient() *http.Client { return v.Client } @@ -157,9 +184,14 @@ func (v *VjbNet) proxy4URL(reqURL *url.URL) (*url.URL, error) { if v.NoProxy != "" { v.proxyCfg.NoProxy = v.NoProxy } + + cfg := *v.proxyCfg + cfg.HTTPProxy = withProxyCredentials(cfg.HTTPProxy, v.HTTPProxyUsername, v.HTTPProxyPassword) + cfg.HTTPSProxy = withProxyCredentials(cfg.HTTPSProxy, v.HTTPSProxyUsername, v.HTTPSProxyPassword) + // Delegate proxy decision to httpproxy's ProxyFunc for correct // NO_PROXY and scheme handling. - proxyURL, err := v.proxyCfg.ProxyFunc()(reqURL) + proxyURL, err := cfg.ProxyFunc()(reqURL) if err != nil { return nil, err } @@ -207,6 +239,11 @@ func NewVjbNet() *VjbNet { NoProxy: "", UseProxyFromEnv: true, proxyCfg: httpproxy.FromEnvironment(), + + HTTPProxyUsername: os.Getenv("HTTP_PROXY_USERNAME"), + HTTPProxyPassword: os.Getenv("HTTP_PROXY_PASSWORD"), + HTTPSProxyUsername: os.Getenv("HTTPS_PROXY_USERNAME"), + HTTPSProxyPassword: os.Getenv("HTTPS_PROXY_PASSWORD"), } } diff --git a/pkg/common/utils/net_test.go b/pkg/common/utils/net_test.go index a85a7d657..b8b9bc4a8 100644 --- a/pkg/common/utils/net_test.go +++ b/pkg/common/utils/net_test.go @@ -1,8 +1,12 @@ package utils import ( + "bytes" + "io" "net/http" "net/url" + "os" + "strings" "testing" "time" ) @@ -367,3 +371,208 @@ func TestVjbNet_GetClient_Default(t *testing.T) { t.Errorf("GetClient did not return *http.Client") } } + +func TestVjbNet_SetProxyCredentials(t *testing.T) { + n := NewVjbNet() + + n.SetHTTPProxyCredentials("http-user", "http-pass") + if n.HTTPProxyUsername != "http-user" || n.HTTPProxyPassword != "http-pass" { + t.Errorf("SetHTTPProxyCredentials did not set fields, got user=%q pass=%q", n.HTTPProxyUsername, n.HTTPProxyPassword) + } + + n.SetHTTPSProxyCredentials("https-user", "https-pass") + if n.HTTPSProxyUsername != "https-user" || n.HTTPSProxyPassword != "https-pass" { + t.Errorf("SetHTTPSProxyCredentials did not set fields, got user=%q pass=%q", n.HTTPSProxyUsername, n.HTTPSProxyPassword) + } + + // HTTP and HTTPS credentials must stay fully independent - setting one + // must never leak into the other. + if n.HTTPProxyUsername == n.HTTPSProxyUsername { + t.Errorf("expected independent HTTP/HTTPS proxy usernames, both are %q", n.HTTPProxyUsername) + } +} + +func TestNewVjbNet_CredentialsAutoReadFromEnv(t *testing.T) { + t.Setenv("HTTP_PROXY_USERNAME", "env-http-user") + t.Setenv("HTTP_PROXY_PASSWORD", "env-http-pass") + t.Setenv("HTTPS_PROXY_USERNAME", "env-https-user") + t.Setenv("HTTPS_PROXY_PASSWORD", "env-https-pass") + + n := NewVjbNet() + + if n.HTTPProxyUsername != "env-http-user" || n.HTTPProxyPassword != "env-http-pass" { + t.Errorf("expected HTTP proxy credentials from env, got user=%q pass=%q", n.HTTPProxyUsername, n.HTTPProxyPassword) + } + if n.HTTPSProxyUsername != "env-https-user" || n.HTTPSProxyPassword != "env-https-pass" { + t.Errorf("expected HTTPS proxy credentials from env, got user=%q pass=%q", n.HTTPSProxyUsername, n.HTTPSProxyPassword) + } +} + +func TestNewVjbNet_CredentialsEmptyByDefault(t *testing.T) { + n := NewVjbNet() + + if n.HTTPProxyUsername != "" || n.HTTPProxyPassword != "" { + t.Errorf("expected empty HTTP proxy credentials by default, got user=%q pass=%q", n.HTTPProxyUsername, n.HTTPProxyPassword) + } + if n.HTTPSProxyUsername != "" || n.HTTPSProxyPassword != "" { + t.Errorf("expected empty HTTPS proxy credentials by default, got user=%q pass=%q", n.HTTPSProxyUsername, n.HTTPSProxyPassword) + } +} + +func TestWithProxyCredentials(t *testing.T) { + tests := []struct { + name string + rawURL string + username string + password string + want string + }{ + {"empty raw URL returns unchanged", "", "user", "pass", ""}, + {"empty username returns unchanged", "proxy.example:8080", "", "pass", "proxy.example:8080"}, + {"scheme-qualified URL gets userinfo", "http://proxy.example:8080", "user", "pass", "http://user:pass@proxy.example:8080"}, + {"bare host:port gets userinfo via http:// fallback", "proxy.example:8080", "user", "pass", "http://user:pass@proxy.example:8080"}, + {"special characters in password are percent-encoded", "http://proxy.example:8080", "user", "p@ss:w/rd", "http://user:p%40ss%3Aw%2Frd@proxy.example:8080"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := withProxyCredentials(tt.rawURL, tt.username, tt.password) + if got != tt.want { + t.Errorf("withProxyCredentials(%q, %q, %q) = %q, want %q", tt.rawURL, tt.username, tt.password, got, tt.want) + } + }) + } +} + +func TestVjbNet_proxy4URL_CredentialsEmbeddedIndependently(t *testing.T) { + n := NewVjbNet() + + httpProxy := "http-proxy.example:8080" + httpsProxy := "https-proxy.example:8443" + n.SetHTTPProxy(httpProxy) + n.SetHTTPSProxy(httpsProxy) + n.SetHTTPProxyCredentials("http-user", "http-pass") + n.SetHTTPSProxyCredentials("https-user", "https-pass") + + uHTTP, err := url.Parse("http://some-http-host") + if err != nil { + t.Fatalf("failed to parse HTTP URL: %v", err) + } + proxyHTTP, err := n.proxy4URL(uHTTP) + if err != nil { + t.Fatalf("proxy4URL(http) returned error: %v", err) + } + if proxyHTTP == nil || proxyHTTP.User == nil { + t.Fatalf("expected HTTP proxy URL with userinfo, got %v", proxyHTTP) + } + if gotUser := proxyHTTP.User.Username(); gotUser != "http-user" { + t.Errorf("HTTP proxy username = %q, want %q", gotUser, "http-user") + } + if gotPass, _ := proxyHTTP.User.Password(); gotPass != "http-pass" { + t.Errorf("HTTP proxy password = %q, want %q", gotPass, "http-pass") + } + if proxyHTTP.Host != httpProxy { + t.Errorf("HTTP proxy host = %q, want %q", proxyHTTP.Host, httpProxy) + } + + uHTTPS, err := url.Parse("https://some-https-host") + if err != nil { + t.Fatalf("failed to parse HTTPS URL: %v", err) + } + proxyHTTPS, err := n.proxy4URL(uHTTPS) + if err != nil { + t.Fatalf("proxy4URL(https) returned error: %v", err) + } + if proxyHTTPS == nil || proxyHTTPS.User == nil { + t.Fatalf("expected HTTPS proxy URL with userinfo, got %v", proxyHTTPS) + } + if gotUser := proxyHTTPS.User.Username(); gotUser != "https-user" { + t.Errorf("HTTPS proxy username = %q, want %q", gotUser, "https-user") + } + if gotPass, _ := proxyHTTPS.User.Password(); gotPass != "https-pass" { + t.Errorf("HTTPS proxy password = %q, want %q", gotPass, "https-pass") + } + + // Credentials must never cross over between HTTP and HTTPS. + if proxyHTTP.User.Username() == proxyHTTPS.User.Username() { + t.Errorf("expected independent HTTP/HTTPS proxy usernames on resolved URLs") + } +} + +func TestVjbNet_proxy4URL_NoCredentials_UserInfoAbsent(t *testing.T) { + n := NewVjbNet() + + httpProxy := "http-proxy.example:8080" + n.SetHTTPProxy(httpProxy) + // Deliberately not setting any credentials. + + u, err := url.Parse("http://some-http-host") + if err != nil { + t.Fatalf("failed to parse URL: %v", err) + } + proxyURL, err := n.proxy4URL(u) + if err != nil { + t.Fatalf("proxy4URL returned error: %v", err) + } + if proxyURL == nil { + t.Fatalf("expected non-nil proxy URL") + } + if proxyURL.User != nil { + t.Errorf("expected no userinfo on resolved proxy URL when no credentials are set, got %v", proxyURL.User) + } +} + +// TestVjbNet_ProxyCredentials_NeverLoggedInPlaintext guards the core safety +// property behind embedding credentials directly in VjbNet: the debug log +// lines in getNetTransport/proxy4URL print v.proxyCfg/v.HTTPProxy/ +// v.HTTPSProxy, which must stay credential-free even once HTTP(S) proxy +// credentials are configured and actively used to resolve a proxy decision. +func TestVjbNet_ProxyCredentials_NeverLoggedInPlaintext(t *testing.T) { + n := NewVjbNet() + + n.SetHTTPProxy("http-proxy.example:8080") + n.SetHTTPSProxy("https-proxy.example:8443") + const secretPassword = "super-secret-password" + n.SetHTTPProxyCredentials("http-user", secretPassword) + n.SetHTTPSProxyCredentials("https-user", secretPassword) + n.SetUseProxyFromEnv(false) + + if err := n.CreateHTTPClient(); err != nil { + t.Fatalf("CreateHTTPClient returned error: %v", err) + } + client := n.GetClient() + transport, ok := client.Transport.(*http.Transport) + if !ok { + t.Fatalf("client.Transport is not *http.Transport") + } + + origStdout := os.Stdout + r, w, err := os.Pipe() + if err != nil { + t.Fatalf("failed to create pipe: %v", err) + } + os.Stdout = w + + httpURL, _ := url.Parse("http://some-http-host") + if _, err := transport.Proxy(&http.Request{URL: httpURL}); err != nil { + os.Stdout = origStdout + t.Fatalf("Proxy(http) returned error: %v", err) + } + httpsURL, _ := url.Parse("https://some-https-host") + if _, err := transport.Proxy(&http.Request{URL: httpsURL}); err != nil { + os.Stdout = origStdout + t.Fatalf("Proxy(https) returned error: %v", err) + } + + w.Close() + os.Stdout = origStdout + + var buf bytes.Buffer + if _, err := io.Copy(&buf, r); err != nil { + t.Fatalf("failed to read captured stdout: %v", err) + } + + if strings.Contains(buf.String(), secretPassword) { + t.Fatalf("proxy debug logging leaked the password; captured output:\n%s", buf.String()) + } +} diff --git a/pkg/vpwned/server/proxy_creds_handler.go b/pkg/vpwned/server/proxy_creds_handler.go new file mode 100644 index 000000000..866e8483b --- /dev/null +++ b/pkg/vpwned/server/proxy_creds_handler.go @@ -0,0 +1,135 @@ +package server + +import ( + "context" + "encoding/json" + "net/http" + "strconv" + + "github.com/sirupsen/logrus" + corev1 "k8s.io/api/core/v1" + k8serrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" + "sigs.k8s.io/controller-runtime/pkg/client" +) + +const ( + proxyCredsSecretName = "pf9-proxy-creds" + proxyCredsSecretNS = "migration-system" +) + +type proxyCredsHandler struct { + k8sClient client.Client +} + +type proxyCredsRequest struct { + Username string `json:"username"` + Password string `json:"password"` + HTTPSOverride bool `json:"https_override"` + HTTPSUsername string `json:"https_username"` + HTTPSPassword string `json:"https_password"` +} + +type proxyCredsResponse struct { + Configured bool `json:"configured"` + HTTPSOverride bool `json:"https_override"` +} + +func (h *proxyCredsHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case http.MethodGet: + h.getCreds(w, r) + case http.MethodPost: + h.saveCreds(w, r) + case http.MethodDelete: + h.deleteCreds(w, r) + default: + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + } +} + +func (h *proxyCredsHandler) getCreds(w http.ResponseWriter, r *http.Request) { + ctx := context.Background() + var secret corev1.Secret + err := h.k8sClient.Get(ctx, types.NamespacedName{Name: proxyCredsSecretName, Namespace: proxyCredsSecretNS}, &secret) + resp := proxyCredsResponse{} + if err == nil { + resp.Configured = len(secret.Data["HTTP_PROXY_USERNAME"]) > 0 + resp.HTTPSOverride = string(secret.Data["HTTPS_PROXY_OVERRIDE"]) == "true" + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(resp) //nolint:errcheck +} + +func (h *proxyCredsHandler) saveCreds(w http.ResponseWriter, r *http.Request) { + var req proxyCredsRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.Username == "" || req.Password == "" { + http.Error(w, "username and password are required", http.StatusBadRequest) + return + } + if req.HTTPSOverride && (req.HTTPSUsername == "" || req.HTTPSPassword == "") { + http.Error(w, "https_username and https_password are required when https_override is true", http.StatusBadRequest) + return + } + + // HTTP and HTTPS credentials are always stored as fully independent keys. + // When there's no override, the shared credentials are duplicated into the + // HTTPS_* keys here so VjbNet never needs any HTTP/HTTPS fallback logic. + httpsUsername, httpsPassword := req.Username, req.Password + if req.HTTPSOverride { + httpsUsername, httpsPassword = req.HTTPSUsername, req.HTTPSPassword + } + + data := map[string][]byte{ + "HTTP_PROXY_USERNAME": []byte(req.Username), + "HTTP_PROXY_PASSWORD": []byte(req.Password), + "HTTPS_PROXY_USERNAME": []byte(httpsUsername), + "HTTPS_PROXY_PASSWORD": []byte(httpsPassword), + "HTTPS_PROXY_OVERRIDE": []byte(strconv.FormatBool(req.HTTPSOverride)), + } + + ctx := context.Background() + var existing corev1.Secret + getErr := h.k8sClient.Get(ctx, types.NamespacedName{Name: proxyCredsSecretName, Namespace: proxyCredsSecretNS}, &existing) + switch { + case k8serrors.IsNotFound(getErr): + newSecret := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{Name: proxyCredsSecretName, Namespace: proxyCredsSecretNS}, + Data: data, + } + if err := h.k8sClient.Create(ctx, newSecret); err != nil { + logrus.Errorf("proxy_creds_handler: create secret failed: %v", err) + http.Error(w, "failed to save proxy credentials", http.StatusInternalServerError) + return + } + case getErr == nil: + existing.Data = data + if err := h.k8sClient.Update(ctx, &existing); err != nil { + logrus.Errorf("proxy_creds_handler: update secret failed: %v", err) + http.Error(w, "failed to update proxy credentials", http.StatusInternalServerError) + return + } + default: + logrus.Errorf("proxy_creds_handler: get secret failed: %v", getErr) + http.Error(w, "unexpected error", http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(proxyCredsResponse{Configured: true, HTTPSOverride: req.HTTPSOverride}) //nolint:errcheck +} + +// deleteCreds removes the whole secret - it holds nothing but proxy +// credentials, so "clear" means delete, not clear individual keys. +func (h *proxyCredsHandler) deleteCreds(w http.ResponseWriter, r *http.Request) { + ctx := context.Background() + secret := &corev1.Secret{ObjectMeta: metav1.ObjectMeta{Name: proxyCredsSecretName, Namespace: proxyCredsSecretNS}} + if err := h.k8sClient.Delete(ctx, secret); err != nil && !k8serrors.IsNotFound(err) { + logrus.Errorf("proxy_creds_handler: delete secret failed: %v", err) + http.Error(w, "failed to clear proxy credentials", http.StatusInternalServerError) + return + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(proxyCredsResponse{}) //nolint:errcheck +} diff --git a/pkg/vpwned/server/proxy_creds_handler_test.go b/pkg/vpwned/server/proxy_creds_handler_test.go new file mode 100644 index 000000000..05d67907b --- /dev/null +++ b/pkg/vpwned/server/proxy_creds_handler_test.go @@ -0,0 +1,250 @@ +package server + +import ( + "bytes" + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" +) + +func TestProxyCredsHandler_GetAbsent(t *testing.T) { + h := &proxyCredsHandler{k8sClient: fakeK8sClientForKeyTest()} + req := httptest.NewRequest(http.MethodGet, "/vpw/v1/proxy/credentials", nil) + w := httptest.NewRecorder() + h.ServeHTTP(w, req) + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d", w.Code) + } + var resp proxyCredsResponse + json.NewDecoder(w.Body).Decode(&resp) + if resp.Configured { + t.Error("expected configured=false when secret absent") + } +} + +func TestProxyCredsHandler_GetPresent_SharedOnly(t *testing.T) { + secret := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{Name: proxyCredsSecretName, Namespace: proxyCredsSecretNS}, + Data: map[string][]byte{ + "HTTP_PROXY_USERNAME": []byte("shared-user"), + "HTTP_PROXY_PASSWORD": []byte("shared-pass"), + "HTTPS_PROXY_USERNAME": []byte("shared-user"), + "HTTPS_PROXY_PASSWORD": []byte("shared-pass"), + "HTTPS_PROXY_OVERRIDE": []byte("false"), + }, + } + h := &proxyCredsHandler{k8sClient: fakeK8sClientForKeyTest(secret)} + req := httptest.NewRequest(http.MethodGet, "/vpw/v1/proxy/credentials", nil) + w := httptest.NewRecorder() + h.ServeHTTP(w, req) + var resp proxyCredsResponse + json.NewDecoder(w.Body).Decode(&resp) + if !resp.Configured { + t.Error("expected configured=true when secret present") + } + if resp.HTTPSOverride { + t.Error("expected https_override=false for shared-only credentials") + } +} + +func TestProxyCredsHandler_GetPresent_WithOverride(t *testing.T) { + secret := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{Name: proxyCredsSecretName, Namespace: proxyCredsSecretNS}, + Data: map[string][]byte{ + "HTTP_PROXY_USERNAME": []byte("http-user"), + "HTTP_PROXY_PASSWORD": []byte("http-pass"), + "HTTPS_PROXY_USERNAME": []byte("https-user"), + "HTTPS_PROXY_PASSWORD": []byte("https-pass"), + "HTTPS_PROXY_OVERRIDE": []byte("true"), + }, + } + h := &proxyCredsHandler{k8sClient: fakeK8sClientForKeyTest(secret)} + req := httptest.NewRequest(http.MethodGet, "/vpw/v1/proxy/credentials", nil) + w := httptest.NewRecorder() + h.ServeHTTP(w, req) + var resp proxyCredsResponse + json.NewDecoder(w.Body).Decode(&resp) + if !resp.Configured || !resp.HTTPSOverride { + t.Errorf("expected configured=true, https_override=true, got %+v", resp) + } +} + +func TestProxyCredsHandler_PostCreates_SharedOnly(t *testing.T) { + k8s := fakeK8sClientForKeyTest() + h := &proxyCredsHandler{k8sClient: k8s} + body, _ := json.Marshal(proxyCredsRequest{Username: "shared-user", Password: "shared-pass"}) + req := httptest.NewRequest(http.MethodPost, "/vpw/v1/proxy/credentials", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + h.ServeHTTP(w, req) + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) + } + + var secret corev1.Secret + if err := k8s.Get(context.Background(), types.NamespacedName{Name: proxyCredsSecretName, Namespace: proxyCredsSecretNS}, &secret); err != nil { + t.Fatalf("secret not found: %v", err) + } + if string(secret.Data["HTTP_PROXY_USERNAME"]) != "shared-user" || string(secret.Data["HTTP_PROXY_PASSWORD"]) != "shared-pass" { + t.Errorf("HTTP credentials not stored correctly: %+v", secret.Data) + } + if string(secret.Data["HTTPS_PROXY_USERNAME"]) != "shared-user" || string(secret.Data["HTTPS_PROXY_PASSWORD"]) != "shared-pass" { + t.Errorf("expected shared credentials duplicated into HTTPS_* keys, got %+v", secret.Data) + } + if string(secret.Data["HTTPS_PROXY_OVERRIDE"]) != "false" { + t.Errorf("expected HTTPS_PROXY_OVERRIDE=false, got %q", secret.Data["HTTPS_PROXY_OVERRIDE"]) + } +} + +func TestProxyCredsHandler_PostCreates_WithOverride(t *testing.T) { + k8s := fakeK8sClientForKeyTest() + h := &proxyCredsHandler{k8sClient: k8s} + body, _ := json.Marshal(proxyCredsRequest{ + Username: "http-user", + Password: "http-pass", + HTTPSOverride: true, + HTTPSUsername: "https-user", + HTTPSPassword: "https-pass", + }) + req := httptest.NewRequest(http.MethodPost, "/vpw/v1/proxy/credentials", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + h.ServeHTTP(w, req) + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) + } + + var secret corev1.Secret + if err := k8s.Get(context.Background(), types.NamespacedName{Name: proxyCredsSecretName, Namespace: proxyCredsSecretNS}, &secret); err != nil { + t.Fatalf("secret not found: %v", err) + } + if string(secret.Data["HTTP_PROXY_USERNAME"]) != "http-user" || string(secret.Data["HTTP_PROXY_PASSWORD"]) != "http-pass" { + t.Errorf("HTTP credentials not stored correctly: %+v", secret.Data) + } + if string(secret.Data["HTTPS_PROXY_USERNAME"]) != "https-user" || string(secret.Data["HTTPS_PROXY_PASSWORD"]) != "https-pass" { + t.Errorf("HTTPS override credentials not stored correctly: %+v", secret.Data) + } + if string(secret.Data["HTTPS_PROXY_OVERRIDE"]) != "true" { + t.Errorf("expected HTTPS_PROXY_OVERRIDE=true, got %q", secret.Data["HTTPS_PROXY_OVERRIDE"]) + } +} + +func TestProxyCredsHandler_PostUpdates(t *testing.T) { + existing := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{Name: proxyCredsSecretName, Namespace: proxyCredsSecretNS}, + Data: map[string][]byte{ + "HTTP_PROXY_USERNAME": []byte("old-user"), + "HTTP_PROXY_PASSWORD": []byte("old-pass"), + "HTTPS_PROXY_USERNAME": []byte("old-user"), + "HTTPS_PROXY_PASSWORD": []byte("old-pass"), + "HTTPS_PROXY_OVERRIDE": []byte("false"), + }, + } + k8s := fakeK8sClientForKeyTest(existing) + h := &proxyCredsHandler{k8sClient: k8s} + body, _ := json.Marshal(proxyCredsRequest{Username: "new-user", Password: "new-pass"}) + req := httptest.NewRequest(http.MethodPost, "/vpw/v1/proxy/credentials", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + h.ServeHTTP(w, req) + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d", w.Code) + } + + var secret corev1.Secret + if err := k8s.Get(context.Background(), types.NamespacedName{Name: proxyCredsSecretName, Namespace: proxyCredsSecretNS}, &secret); err != nil { + t.Fatalf("secret not found: %v", err) + } + if string(secret.Data["HTTP_PROXY_USERNAME"]) != "new-user" { + t.Errorf("expected updated username, got %q", secret.Data["HTTP_PROXY_USERNAME"]) + } +} + +func TestProxyCredsHandler_PostMissingUsername(t *testing.T) { + h := &proxyCredsHandler{k8sClient: fakeK8sClientForKeyTest()} + req := httptest.NewRequest(http.MethodPost, "/vpw/v1/proxy/credentials", bytes.NewBufferString(`{"password":"pass"}`)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + h.ServeHTTP(w, req) + if w.Code != http.StatusBadRequest { + t.Errorf("expected 400, got %d", w.Code) + } +} + +func TestProxyCredsHandler_PostMissingPassword(t *testing.T) { + h := &proxyCredsHandler{k8sClient: fakeK8sClientForKeyTest()} + req := httptest.NewRequest(http.MethodPost, "/vpw/v1/proxy/credentials", bytes.NewBufferString(`{"username":"user"}`)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + h.ServeHTTP(w, req) + if w.Code != http.StatusBadRequest { + t.Errorf("expected 400, got %d", w.Code) + } +} + +func TestProxyCredsHandler_PostOverrideMissingFields(t *testing.T) { + h := &proxyCredsHandler{k8sClient: fakeK8sClientForKeyTest()} + body, _ := json.Marshal(proxyCredsRequest{Username: "user", Password: "pass", HTTPSOverride: true}) + req := httptest.NewRequest(http.MethodPost, "/vpw/v1/proxy/credentials", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + h.ServeHTTP(w, req) + if w.Code != http.StatusBadRequest { + t.Errorf("expected 400 when https_override is set without https_username/https_password, got %d", w.Code) + } +} + +func TestProxyCredsHandler_Delete_RemovesSecret(t *testing.T) { + existing := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{Name: proxyCredsSecretName, Namespace: proxyCredsSecretNS}, + Data: map[string][]byte{ + "HTTP_PROXY_USERNAME": []byte("user"), + "HTTP_PROXY_PASSWORD": []byte("pass"), + }, + } + k8s := fakeK8sClientForKeyTest(existing) + h := &proxyCredsHandler{k8sClient: k8s} + req := httptest.NewRequest(http.MethodDelete, "/vpw/v1/proxy/credentials", nil) + w := httptest.NewRecorder() + h.ServeHTTP(w, req) + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d", w.Code) + } + var resp proxyCredsResponse + json.NewDecoder(w.Body).Decode(&resp) + if resp.Configured { + t.Error("expected configured=false after delete") + } + + var secret corev1.Secret + err := k8s.Get(context.Background(), types.NamespacedName{Name: proxyCredsSecretName, Namespace: proxyCredsSecretNS}, &secret) + if err == nil { + t.Error("expected secret to be deleted, but it still exists") + } +} + +func TestProxyCredsHandler_Delete_IdempotentWhenAbsent(t *testing.T) { + h := &proxyCredsHandler{k8sClient: fakeK8sClientForKeyTest()} + req := httptest.NewRequest(http.MethodDelete, "/vpw/v1/proxy/credentials", nil) + w := httptest.NewRecorder() + h.ServeHTTP(w, req) + if w.Code != http.StatusOK { + t.Errorf("expected 200 when deleting an absent secret, got %d", w.Code) + } +} + +func TestProxyCredsHandler_MethodNotAllowed(t *testing.T) { + h := &proxyCredsHandler{k8sClient: fakeK8sClientForKeyTest()} + req := httptest.NewRequest(http.MethodPut, "/vpw/v1/proxy/credentials", nil) + w := httptest.NewRecorder() + h.ServeHTTP(w, req) + if w.Code != http.StatusMethodNotAllowed { + t.Errorf("expected 405, got %d", w.Code) + } +} diff --git a/pkg/vpwned/server/server.go b/pkg/vpwned/server/server.go index 5b09e4498..d812916b2 100644 --- a/pkg/vpwned/server/server.go +++ b/pkg/vpwned/server/server.go @@ -237,6 +237,14 @@ func getHTTPServer(ctx context.Context, port, grpcSocket string) (*http.ServeMux mux.Handle("/vpw/v1/ai/key", &aiKeyHandler{k8sClient: aiK8sClient, rawK8s: rawK8s}) } + // Proxy credentials endpoint + proxyCredsK8sClient, credsErr := CreateInClusterClient() + if credsErr != nil { + logrus.Warnf("proxy creds handler: failed to create k8s client (non-cluster env): %v", credsErr) + } else { + mux.Handle("/vpw/v1/proxy/credentials", &proxyCredsHandler{k8sClient: proxyCredsK8sClient}) + } + // Wrap gatewayMuxer to handle all other routes mux.HandleFunc("/vpw/", func(w http.ResponseWriter, r *http.Request) { // Skip VDDK endpoints - they're already registered diff --git a/ui/src/api/proxy/proxyCredentials.ts b/ui/src/api/proxy/proxyCredentials.ts new file mode 100644 index 000000000..13f5be403 --- /dev/null +++ b/ui/src/api/proxy/proxyCredentials.ts @@ -0,0 +1,26 @@ +import api from 'src/api/axios' + +export interface ProxyCredsStatus { + configured: boolean + https_override: boolean +} + +export interface SaveProxyCredsRequest { + username: string + password: string + https_override: boolean + https_username?: string + https_password?: string +} + +export async function getProxyCredsStatus(): Promise { + return api.get({ endpoint: '/dev-api/sdk/vpw/v1/proxy/credentials' }) +} + +export async function saveProxyCreds(req: SaveProxyCredsRequest): Promise { + return api.post({ endpoint: '/dev-api/sdk/vpw/v1/proxy/credentials', data: req }) +} + +export async function deleteProxyCreds(): Promise { + return api.del({ endpoint: '/dev-api/sdk/vpw/v1/proxy/credentials' }) +} diff --git a/ui/src/features/globalSettings/components/GlobalSettingsPage.tsx b/ui/src/features/globalSettings/components/GlobalSettingsPage.tsx index 99478c4fc..761e54e16 100644 --- a/ui/src/features/globalSettings/components/GlobalSettingsPage.tsx +++ b/ui/src/features/globalSettings/components/GlobalSettingsPage.tsx @@ -10,6 +10,8 @@ import { CircularProgress, FormControl, FormHelperText, + IconButton, + InputAdornment, MenuItem, Select, SelectChangeEvent, @@ -27,6 +29,8 @@ import SettingsOutlinedIcon from '@mui/icons-material/SettingsOutlined' import HistoryToggleOffOutlinedIcon from '@mui/icons-material/HistoryToggleOffOutlined' import TuneOutlinedIcon from '@mui/icons-material/TuneOutlined' import LanOutlinedIcon from '@mui/icons-material/LanOutlined' +import Visibility from '@mui/icons-material/Visibility' +import VisibilityOff from '@mui/icons-material/VisibilityOff' import FieldLabel from 'src/components/design-system/ui/FieldLabel' import FormGrid from 'src/components/design-system/ui/FormGrid' import InlineHelp from 'src/components/design-system/ui/InlineHelp' @@ -56,6 +60,11 @@ import { useVddkStatusQuery } from 'src/hooks/api/useVddkStatusQuery' import { useMigrationsQuery } from 'src/hooks/api/useMigrationsQuery' import { Phase } from 'src/api/migrations/model' import { getAIKeyStatus, saveAIKey } from 'src/api/ai/aiAnalysis' +import { + getProxyCredsStatus, + saveProxyCreds, + deleteProxyCreds +} from 'src/api/proxy/proxyCredentials' import axios from 'axios' const VDDK_UPLOADED_KEY = 'vddk-uploaded' @@ -385,6 +394,20 @@ type UseGlobalSettingsControllerReturn = { setActiveTab: React.Dispatch> notification: NotificationState proxyUpdateSuccess: boolean + proxyAuthEnabled: boolean + setProxyAuthEnabled: React.Dispatch> + proxyAuthConfigured: boolean + proxyAuthStatusMessage: string | null + proxyAuthUsername: string + setProxyAuthUsername: React.Dispatch> + proxyAuthPassword: string + setProxyAuthPassword: React.Dispatch> + proxyAuthHttpsOverride: boolean + setProxyAuthHttpsOverride: React.Dispatch> + proxyAuthHttpsUsername: string + setProxyAuthHttpsUsername: React.Dispatch> + proxyAuthHttpsPassword: string + setProxyAuthHttpsPassword: React.Dispatch> timezoneOptions: TimezoneOption[] isTimeSettingsDisabled: boolean onText: (e: React.ChangeEvent) => void @@ -406,6 +429,14 @@ const useGlobalSettingsController = (): UseGlobalSettingsControllerReturn => { const [notification, setNotification] = useState(DEFAULT_NOTIFICATION) const [proxyUpdateSuccess, setProxyUpdateSuccess] = useState(false) + const [proxyAuthEnabled, setProxyAuthEnabled] = useState(false) + const [proxyAuthConfigured, setProxyAuthConfigured] = useState(false) + const [proxyAuthHttpsOverride, setProxyAuthHttpsOverride] = useState(false) + const [proxyAuthUsername, setProxyAuthUsername] = useState('') + const [proxyAuthPassword, setProxyAuthPassword] = useState('') + const [proxyAuthHttpsUsername, setProxyAuthHttpsUsername] = useState('') + const [proxyAuthHttpsPassword, setProxyAuthHttpsPassword] = useState('') + const rhfForm = useForm({ defaultValues: DEFAULTS, mode: 'onChange' @@ -704,6 +735,23 @@ const useGlobalSettingsController = (): UseGlobalSettingsControllerReturn => { fetchSettings() }, [fetchSettings]) + useEffect(() => { + getProxyCredsStatus() + .then((status) => { + setProxyAuthEnabled(status.configured) + setProxyAuthConfigured(status.configured) + setProxyAuthHttpsOverride(status.https_override) + }) + .catch(() => {}) + }, []) + + const proxyAuthStatusMessage = useMemo(() => { + if (!proxyAuthConfigured) return null + return proxyAuthHttpsOverride + ? 'HTTP and HTTPS proxy credentials are configured separately.' + : 'Proxy credentials are configured.' + }, [proxyAuthConfigured, proxyAuthHttpsOverride]) + const show = useCallback((message: string, severity: NotificationSeverity = 'info') => { setNotification({ open: true, message, severity }) }, []) @@ -756,6 +804,41 @@ const useGlobalSettingsController = (): UseGlobalSettingsControllerReturn => { rhfForm.reset({ ...DEFAULTS }) }, [rhfForm, isTimeSettingsDisabled, form.TIMEZONE, form.NTP_SERVERS]) + const validateProxyAuthFields = useCallback((): string | null => { + if (!proxyAuthEnabled) return null + + const hasAnyCredInput = + proxyAuthUsername.trim() !== '' || + proxyAuthPassword.trim() !== '' || + proxyAuthHttpsUsername.trim() !== '' || + proxyAuthHttpsPassword.trim() !== '' + + if (!hasAnyCredInput) { + if (!proxyAuthConfigured) { + return 'Proxy username and password are required when proxy authentication is enabled.' + } + return null + } + + if (!proxyAuthUsername.trim() || !proxyAuthPassword.trim()) { + return 'Both proxy username and password are required to update credentials.' + } + + if (proxyAuthHttpsOverride && (!proxyAuthHttpsUsername.trim() || !proxyAuthHttpsPassword.trim())) { + return 'Both HTTPS proxy username and password are required when using different HTTPS credentials.' + } + + return null + }, [ + proxyAuthEnabled, + proxyAuthConfigured, + proxyAuthUsername, + proxyAuthPassword, + proxyAuthHttpsOverride, + proxyAuthHttpsUsername, + proxyAuthHttpsPassword + ]) + const onSave = useCallback( async (e: React.FormEvent) => { e.preventDefault() @@ -766,6 +849,26 @@ const useGlobalSettingsController = (): UseGlobalSettingsControllerReturn => { return } + const proxyAuthError = validateProxyAuthFields() + if (proxyAuthError) { + show(proxyAuthError, 'error') + return + } + + const hasNewProxyCredInput = + proxyAuthUsername.trim() !== '' || + proxyAuthPassword.trim() !== '' || + proxyAuthHttpsUsername.trim() !== '' || + proxyAuthHttpsPassword.trim() !== '' + + const proxyAuthAction: 'save' | 'delete' | 'none' = proxyAuthEnabled + ? hasNewProxyCredInput + ? 'save' + : 'none' + : proxyAuthConfigured + ? 'delete' + : 'none' + const proxyChanged = form.PROXY_ENABLED !== initial.PROXY_ENABLED || form.PROXY_HTTP_SCHEME !== initial.PROXY_HTTP_SCHEME || @@ -809,12 +912,45 @@ const useGlobalSettingsController = (): UseGlobalSettingsControllerReturn => { } as any) stage = 'env' + let proxyCredsFailed = false + if (proxyAuthAction === 'save') { + try { + const credsResult = await saveProxyCreds({ + username: proxyAuthUsername.trim(), + password: proxyAuthPassword.trim(), + https_override: proxyAuthHttpsOverride, + https_username: proxyAuthHttpsOverride ? proxyAuthHttpsUsername.trim() : undefined, + https_password: proxyAuthHttpsOverride ? proxyAuthHttpsPassword.trim() : undefined + }) + setProxyAuthConfigured(credsResult.configured) + setProxyAuthHttpsOverride(credsResult.https_override) + setProxyAuthUsername('') + setProxyAuthPassword('') + setProxyAuthHttpsUsername('') + setProxyAuthHttpsPassword('') + } catch (credsErr) { + proxyCredsFailed = true + console.error('Failed to save proxy credentials:', credsErr) + } + } else if (proxyAuthAction === 'delete') { + try { + await deleteProxyCreds() + setProxyAuthConfigured(false) + setProxyAuthHttpsOverride(false) + } catch (credsErr) { + proxyCredsFailed = true + console.error('Failed to clear proxy credentials:', credsErr) + } + } + let envInjectionFailed = false - try { - await injectEnvVariables(buildEnvPayload(form)) - } catch (envErr) { - envInjectionFailed = true - console.error('Failed to inject proxy env variables:', envErr) + if (!proxyCredsFailed) { + try { + await injectEnvVariables(buildEnvPayload(form)) + } catch (envErr) { + envInjectionFailed = true + console.error('Failed to inject proxy env variables:', envErr) + } } if (timeSettingsChanged) { @@ -838,13 +974,18 @@ const useGlobalSettingsController = (): UseGlobalSettingsControllerReturn => { setInitial(nextState) setErrors(buildErrors(nextState)) - if (envInjectionFailed) { + if (proxyCredsFailed) { + show( + 'Settings saved, but saving proxy credentials failed. Proxy environment variables were not updated — please retry.', + 'warning' + ) + } else if (envInjectionFailed) { show( 'Settings saved, but applying proxy environment variables failed. Please verify connectivity and try again.', 'warning' ) } else { - if (proxyChanged) { + if (proxyChanged || proxyAuthAction !== 'none') { setProxyUpdateSuccess(true) } show( @@ -876,7 +1017,22 @@ const useGlobalSettingsController = (): UseGlobalSettingsControllerReturn => { setSaving(false) } }, - [form, initial, validateForm, show, buildErrors, rhfForm] + [ + form, + initial, + validateForm, + show, + buildErrors, + rhfForm, + validateProxyAuthFields, + proxyAuthEnabled, + proxyAuthConfigured, + proxyAuthUsername, + proxyAuthPassword, + proxyAuthHttpsOverride, + proxyAuthHttpsUsername, + proxyAuthHttpsPassword + ] ) const tabErrorFlags = useMemo( @@ -907,6 +1063,20 @@ const useGlobalSettingsController = (): UseGlobalSettingsControllerReturn => { setActiveTab, notification, proxyUpdateSuccess, + proxyAuthEnabled, + setProxyAuthEnabled, + proxyAuthConfigured, + proxyAuthStatusMessage, + proxyAuthUsername, + setProxyAuthUsername, + proxyAuthPassword, + setProxyAuthPassword, + proxyAuthHttpsOverride, + setProxyAuthHttpsOverride, + proxyAuthHttpsUsername, + setProxyAuthHttpsUsername, + proxyAuthHttpsPassword, + setProxyAuthHttpsPassword, timezoneOptions, isTimeSettingsDisabled, onText, @@ -933,6 +1103,20 @@ export default function GlobalSettingsPage() { setActiveTab, notification, proxyUpdateSuccess, + proxyAuthEnabled, + setProxyAuthEnabled, + proxyAuthConfigured, + proxyAuthStatusMessage, + proxyAuthUsername, + setProxyAuthUsername, + proxyAuthPassword, + setProxyAuthPassword, + proxyAuthHttpsOverride, + setProxyAuthHttpsOverride, + proxyAuthHttpsUsername, + setProxyAuthHttpsUsername, + proxyAuthHttpsPassword, + setProxyAuthHttpsPassword, onText, onBool, onSelect, @@ -945,6 +1129,9 @@ export default function GlobalSettingsPage() { isTimeSettingsDisabled, } = useGlobalSettingsController() + const [showProxyPassword, setShowProxyPassword] = useState(false) + const [showProxyHttpsPassword, setShowProxyHttpsPassword] = useState(false) + const activeTabRef = useRef(activeTab) useEffect(() => { activeTabRef.current = activeTab @@ -1483,6 +1670,121 @@ export default function GlobalSettingsPage() { helperText={errors.NO_PROXY} /> + + + setProxyAuthEnabled(checked)} + description="Provide credentials if the proxy server requires basic authentication." + data-testid="global-settings-toggle-PROXY_AUTH_ENABLED" + /> + + + {proxyAuthEnabled && ( + + {proxyAuthStatusMessage ? ( + + {proxyAuthStatusMessage} + + ) : null} + + + setProxyAuthUsername(e.target.value)} + helperText={ + proxyAuthConfigured + ? 'Leave blank to keep the existing username and password.' + : 'Required' + } + data-testid="global-settings-input-PROXY_AUTH_USERNAME" + /> + setProxyAuthPassword(e.target.value)} + helperText={ + proxyAuthConfigured + ? 'Leave blank to keep the existing username and password.' + : 'Required' + } + data-testid="global-settings-input-PROXY_AUTH_PASSWORD" + InputProps={{ + endAdornment: ( + + setShowProxyPassword((prev) => !prev)} + edge="end" + size="small" + > + {showProxyPassword ? : } + + + ) + }} + /> + + + + setProxyAuthHttpsOverride(checked)} + data-testid="global-settings-toggle-PROXY_AUTH_HTTPS_OVERRIDE" + /> + + + {proxyAuthHttpsOverride && ( + + setProxyAuthHttpsUsername(e.target.value)} + helperText={ + proxyAuthConfigured + ? 'Leave blank to keep the existing username and password.' + : 'Required' + } + data-testid="global-settings-input-PROXY_AUTH_HTTPS_USERNAME" + /> + setProxyAuthHttpsPassword(e.target.value)} + helperText={ + proxyAuthConfigured + ? 'Leave blank to keep the existing username and password.' + : 'Required' + } + data-testid="global-settings-input-PROXY_AUTH_HTTPS_PASSWORD" + InputProps={{ + endAdornment: ( + + setShowProxyHttpsPassword((prev) => !prev)} + edge="end" + size="small" + > + {showProxyHttpsPassword ? : } + + + ) + }} + /> + + )} + + )} )}