From cae49f332e1919d84a189ceb2039e86152d6f8dc Mon Sep 17 00:00:00 2001 From: Randall McPherson Date: Tue, 7 Apr 2026 01:06:59 -0600 Subject: [PATCH 1/2] ENGPLAT-399 Add --secure flag for TLS verification (#3781) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * ENGPLAT-399 respect platform insecure config fix: respect platform insecure config instead of hardcoding InsecureSkipVerify Thread config.Platform.Insecure through all call sites so TLS verification is on by default and only skipped when the user explicitly opts in via --insecure. Closes ENGPLAT-399 * fix: preserve insecure TLS fallback during platform bootstrap The start command is the bootstrap flow — the platform always serves a self-signed certificate at this stage. Rather than reading config.Platform.Insecure (which defaults to false for fresh installs and has no --insecure flag on `vcluster platform start`), bootstrap probes and login should handle self-signed certs directly: - Readiness probes (port-forward, router, reachability) always skip TLS verification since they are unauthenticated health checks against a just-installed instance - Login tries secure first, falls back to insecure on TLS error, and persists the decision to config for future CLI operations The Transport(insecure), IsLoftReachable(insecure), and clihelper/http test additions from the original PR are preserved — post-bootstrap commands (login, connect) still respect config.Platform.Insecure. * Revert " fix: preserve insecure TLS fallback during platform bootstrap" This reverts commit d5181a10edbdd414a042b7221dab229630f1053b. * feat: add --insecure flag to vcluster platform start Allows users to skip TLS certificate verification during bootstrap when the platform serves a self-signed certificate. The flag sets config.Platform.Insecure in memory so all downstream health checks and login calls respect it, and LoginWithAccessKey persists the value for future CLI operations. * Switch flag from insecure to secure Defaults to current insecure behavior to preserve current bootstrapping functionality. (cherry picked from commit 1499106e9e533051583d676590760d3d39d766a9) # Conflicts: # .github/workflows/lint.yaml --- .github/workflows/lint.yaml | 57 +++++++++++ cmd/vclusterctl/cmd/platform/start.go | 10 +- cmd/vclusterctl/cmd/platform/start_test.go | 22 +++++ pkg/cli/start/docker.go | 2 +- pkg/cli/start/login.go | 4 +- pkg/cli/start/port_forwarding.go | 2 +- pkg/cli/start/start.go | 1 + pkg/cli/start/success.go | 11 ++- pkg/platform/clihelper/clihelper.go | 4 +- pkg/platform/clihelper/clihelper_test.go | 105 +++++++++++++++++++++ pkg/util/http/transport.go | 13 ++- pkg/util/http/transport_test.go | 33 +++++++ 12 files changed, 251 insertions(+), 13 deletions(-) create mode 100644 pkg/platform/clihelper/clihelper_test.go create mode 100644 pkg/util/http/transport_test.go diff --git a/.github/workflows/lint.yaml b/.github/workflows/lint.yaml index 26cbeb99b1..b318ef6da3 100644 --- a/.github/workflows/lint.yaml +++ b/.github/workflows/lint.yaml @@ -64,7 +64,64 @@ jobs: exit 1 fi +<<<<<<< HEAD - name: Run golangci-lint uses: golangci/golangci-lint-action@v9 with: version: v2.4 +======= + - name: Verify go mod tidy and vendor + if: github.event.pull_request.head.repo.full_name == github.repository + run: | + go mod tidy + go mod vendor + if [ -n "$(git status --porcelain go.mod go.sum vendor/)" ]; then + echo "❌ ERROR: go.mod, go.sum, or vendor/ directory is out of sync." + echo "Please run 'go mod tidy && go mod vendor' and commit the changes." + echo "" + echo "Changed files:" + git status --porcelain go.mod go.sum vendor/ + echo "" + echo "Diff (go.mod and go.sum):" + git diff go.mod go.sum + echo "" + echo "💡 TIP: If this works locally but fails in CI, check for gitignored" + echo " directories (e.g. licenses/) that may affect dependency resolution." + echo " Run 'git status --ignored' locally to see ignored files." + exit 1 + fi + env: + GOFLAGS: "" + + - name: Install golangci-lint + run: go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.7.0 + env: + GOFLAGS: "" + + - name: Build custom golangci-lint with plugins + if: github.event.pull_request.head.repo.full_name == github.repository + run: golangci-lint custom + env: + GOFLAGS: "" + + - name: Run golangci-lint (with custom linters) + if: github.event.pull_request.head.repo.full_name == github.repository + run: ./tools/golangci-lint run --timeout 15m -- ./... + + - name: Generate config without custom linters (fork PRs) + if: github.event.pull_request.head.repo.full_name != github.repository + run: | + # Remove custom plugin definitions and their enable/exclusion entries + # so stock golangci-lint can run without the compiled plugin binary. + CUSTOM_LINTERS=$(yq '.linters.settings.custom | keys | .[]' .golangci.yml) + cp .golangci.yml .golangci-fork.yml + for linter in $CUSTOM_LINTERS; do + yq -i "del(.linters.settings.custom.\"$linter\")" .golangci-fork.yml + yq -i ".linters.enable -= [\"$linter\"]" .golangci-fork.yml + yq -i "del(.linters.exclusions.rules[] | select(.linters[] == \"$linter\"))" .golangci-fork.yml + done + + - name: Run golangci-lint (fork PRs, without custom linters) + if: github.event.pull_request.head.repo.full_name != github.repository + run: golangci-lint run --timeout 15m --config .golangci-fork.yml -- ./... +>>>>>>> 1499106e9 (ENGPLAT-399 Add --secure flag for TLS verification (#3781)) diff --git a/cmd/vclusterctl/cmd/platform/start.go b/cmd/vclusterctl/cmd/platform/start.go index 9406940c20..dd0220bc50 100644 --- a/cmd/vclusterctl/cmd/platform/start.go +++ b/cmd/vclusterctl/cmd/platform/start.go @@ -82,13 +82,21 @@ before running this command: startCmd.Flags().StringVar(&cmd.ChartRepo, "chart-repo", "https://charts.loft.sh/", "The chart repo to deploy vCluster platform") startCmd.Flags().StringVar(&cmd.ChartName, "chart-name", "vcluster-platform", "The chart name to deploy vCluster platform") startCmd.Flags().BoolVar(&cmd.Docker, "docker", false, "If true, vCluster platform will be installed in Docker") + startCmd.Flags().BoolVar(&cmd.Secure, "secure", false, "If true, verify TLS certificates when connecting to the platform (by default, TLS verification is skipped during bootstrap because the platform starts with a self-signed certificate)") return startCmd } func (cmd *StartCmd) Run(ctx context.Context) error { - // automatically use docker mode if the driver is set to docker cfg := cmd.LoadedConfig(cmd.Log) + + // Bootstrap defaults to insecure because the platform starts with a + // self-signed certificate. Pass --secure to enforce TLS verification. + if !cmd.Secure { + cfg.Platform.Insecure = true + } + + // automatically use docker mode if the driver is set to docker if cfg.Driver.Type == config.DockerDriver && !cmd.Docker { cmd.Log.Info("Automatically using --docker flag because driver is set to 'docker'") cmd.Docker = true diff --git a/cmd/vclusterctl/cmd/platform/start_test.go b/cmd/vclusterctl/cmd/platform/start_test.go index 02b9e6d56b..87844f1786 100644 --- a/cmd/vclusterctl/cmd/platform/start_test.go +++ b/cmd/vclusterctl/cmd/platform/start_test.go @@ -8,6 +8,28 @@ import ( "github.com/loft-sh/vcluster/pkg/cli/start" ) +func TestNewStartCmd_SecureFlag(t *testing.T) { + globalFlags := &flags.GlobalFlags{} + cmd := NewStartCmd(globalFlags) + + // Verify --secure flag exists and defaults to false (insecure by default). + f := cmd.Flags().Lookup("secure") + if f == nil { + t.Fatal("--secure flag not registered on start command") + } + if f.DefValue != "false" { + t.Errorf("expected --secure default to be 'false', got %q", f.DefValue) + } + + // Simulate passing --secure on the command line. + if err := cmd.Flags().Set("secure", "true"); err != nil { + t.Fatalf("failed to set --secure flag: %v", err) + } + if f.Value.String() != "true" { + t.Errorf("expected --secure value to be 'true' after set, got %q", f.Value.String()) + } +} + func TestPlatformUsesNewActivationFlow(t *testing.T) { testCases := []struct { version string diff --git a/pkg/cli/start/docker.go b/pkg/cli/start/docker.go index 02ee225f63..cc5f97931a 100644 --- a/pkg/cli/start/docker.go +++ b/pkg/cli/start/docker.go @@ -133,7 +133,7 @@ func (l *LoftStarter) successDocker(ctx context.Context, containerID string) err return false, fmt.Errorf("container failed (status: %s):\n %s", containerDetails.State.Status, logs) } - return clihelper.IsLoftReachable(ctx, host) + return clihelper.IsLoftReachable(ctx, host, l.LoadedConfig(l.Log).Platform.Insecure) }) if err != nil { return fmt.Errorf(product.Replace("error waiting for loft: %v%w"), err) diff --git a/pkg/cli/start/login.go b/pkg/cli/start/login.go index fa998915f5..b40f4fb6c2 100644 --- a/pkg/cli/start/login.go +++ b/pkg/cli/start/login.go @@ -66,8 +66,9 @@ func (l *LoftStarter) loginViaCLI(url string) error { return err } + config := l.LoadedConfig(l.Log) httpClient := &http.Client{Transport: &http.Transport{ - TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, + TLSClientConfig: &tls.Config{InsecureSkipVerify: config.Platform.Insecure}, }} // try a couple of times to login @@ -99,7 +100,6 @@ func (l *LoftStarter) loginViaCLI(url string) error { } // log into loft - config := l.LoadedConfig(l.Log) loginClient := platform.NewLoginClientFromConfig(config) url = strings.TrimSuffix(url, "/") err = loginClient.LoginWithAccessKey(url, accessKey.AccessKey, config.Platform.Insecure) diff --git a/pkg/cli/start/port_forwarding.go b/pkg/cli/start/port_forwarding.go index 560f582099..cc356cdd92 100644 --- a/pkg/cli/start/port_forwarding.go +++ b/pkg/cli/start/port_forwarding.go @@ -21,7 +21,7 @@ func (l *LoftStarter) startPortForwarding(ctx context.Context, loftPod *corev1.P // wait until loft is reachable at the given url httpClient := &http.Client{ - Transport: utilhttp.InsecureTransport(), + Transport: utilhttp.Transport(l.LoadedConfig(l.Log).Platform.Insecure), } l.Log.Infof(product.Replace("Waiting until loft is reachable at https://localhost:%s"), l.LocalPort) err = wait.PollUntilContextTimeout(ctx, time.Second, clihelper.Timeout(), true, func(ctx context.Context) (bool, error) { diff --git a/pkg/cli/start/start.go b/pkg/cli/start/start.go index 9b20958f27..2591c628bc 100644 --- a/pkg/cli/start/start.go +++ b/pkg/cli/start/start.go @@ -70,6 +70,7 @@ type StartOptions struct { //nolint:revive // linter suggests renaming to option Upgrade bool ReuseValues bool Docker bool + Secure bool } func NewLoftStarter(options StartOptions) *LoftStarter { diff --git a/pkg/cli/start/success.go b/pkg/cli/start/success.go index b15de3c5dc..5f04919f43 100644 --- a/pkg/cli/start/success.go +++ b/pkg/cli/start/success.go @@ -68,7 +68,8 @@ func (l *LoftStarter) success(ctx context.Context) error { } // check if loft is reachable - reachable, err := clihelper.IsLoftReachable(ctx, host) + insecure := l.LoadedConfig(l.Log).Platform.Insecure + reachable, err := clihelper.IsLoftReachable(ctx, host, insecure) if !reachable || err != nil { const ( YesOption = "Yes" @@ -123,10 +124,11 @@ func (l *LoftStarter) pingLoftRouter(ctx context.Context, loftPod *corev1.Pod) ( loftRouterDomain := string(loftRouterSecret.Data["domain"]) // wait until loft is reachable at the given url + insecure := l.LoadedConfig(l.Log).Platform.Insecure httpClient := &http.Client{ Transport: &http.Transport{ TLSClientConfig: &tls.Config{ - InsecureSkipVerify: true, + InsecureSkipVerify: insecure, }, }, } @@ -190,7 +192,8 @@ func (l *LoftStarter) isLoggedIn(url string) bool { } func (l *LoftStarter) successRemote(ctx context.Context, host string) error { - ready, err := clihelper.IsLoftReachable(ctx, host) + insecure := l.LoadedConfig(l.Log).Platform.Insecure + ready, err := clihelper.IsLoftReachable(ctx, host, insecure) if err != nil { return err } else if ready { @@ -203,7 +206,7 @@ func (l *LoftStarter) successRemote(ctx context.Context, host string) error { l.Log.Info("Waiting for you to configure DNS, so loft can be reached on https://" + host) err = wait.PollUntilContextTimeout(ctx, 5*time.Second, clihelper.Timeout(), true, func(ctx context.Context) (done bool, err error) { - return clihelper.IsLoftReachable(ctx, host) + return clihelper.IsLoftReachable(ctx, host, insecure) }) if err != nil { return err diff --git a/pkg/platform/clihelper/clihelper.go b/pkg/platform/clihelper/clihelper.go index c1b610214d..5a9f0b94f3 100644 --- a/pkg/platform/clihelper/clihelper.go +++ b/pkg/platform/clihelper/clihelper.go @@ -344,10 +344,10 @@ func GetLoftDefaultPassword(ctx context.Context, kubeClient kubernetes.Interface return string(loftNamespace.UID), nil } -func IsLoftReachable(ctx context.Context, host string) (bool, error) { +func IsLoftReachable(ctx context.Context, host string, insecure bool) (bool, error) { // wait until loft is reachable at the given url client := &http.Client{ - Transport: utilhttp.InsecureTransport(), + Transport: utilhttp.Transport(insecure), } endpoint := fmt.Sprintf("https://%s/healthz", host) req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil) diff --git a/pkg/platform/clihelper/clihelper_test.go b/pkg/platform/clihelper/clihelper_test.go new file mode 100644 index 0000000000..ce7b50d5e7 --- /dev/null +++ b/pkg/platform/clihelper/clihelper_test.go @@ -0,0 +1,105 @@ +package clihelper + +import ( + "context" + "crypto/tls" + "crypto/x509" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "gotest.tools/v3/assert" +) + +func TestIsLoftReachable_InsecureTrueAgainstSelfSigned(t *testing.T) { + // Create an HTTPS server with a self-signed certificate. + server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/healthz" { + w.WriteHeader(http.StatusOK) + return + } + w.WriteHeader(http.StatusNotFound) + })) + defer server.Close() + + host := strings.TrimPrefix(server.URL, "https://") + + // With insecure=true, the self-signed cert should be accepted. + reachable, err := IsLoftReachable(context.Background(), host, true) + assert.NilError(t, err) + assert.Assert(t, reachable, "should be reachable with insecure=true against self-signed cert") +} + +func TestIsLoftReachable_InsecureFalseAgainstSelfSigned(t *testing.T) { + // Create an HTTPS server with a self-signed certificate. + server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/healthz" { + w.WriteHeader(http.StatusOK) + return + } + w.WriteHeader(http.StatusNotFound) + })) + defer server.Close() + + host := strings.TrimPrefix(server.URL, "https://") + + // With insecure=false, the self-signed cert should cause a TLS error + // and IsLoftReachable should return false (not reachable). + reachable, err := IsLoftReachable(context.Background(), host, false) + assert.NilError(t, err) + assert.Assert(t, !reachable, "should not be reachable with insecure=false against self-signed cert") +} + +func TestIsLoftReachable_InsecureFalseAgainstTrustedCert(t *testing.T) { + // Create an HTTPS server with a self-signed cert, but add the cert + // to the system pool so it's trusted. We do this by creating a custom + // test that validates the transport respects system certs. + server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/healthz" { + w.WriteHeader(http.StatusOK) + return + } + w.WriteHeader(http.StatusNotFound) + })) + defer server.Close() + + // Verify the server is actually using TLS with a self-signed cert. + conn, err := tls.Dial("tcp", strings.TrimPrefix(server.URL, "https://"), &tls.Config{ + InsecureSkipVerify: true, + }) + assert.NilError(t, err) + defer conn.Close() + + // Get the server certificate and create a cert pool that trusts it. + serverCert := conn.ConnectionState().PeerCertificates[0] + certPool := x509.NewCertPool() + certPool.AddCert(serverCert) + + // Verify the cert pool trusts the server - this validates our test setup. + _, err = serverCert.Verify(x509.VerifyOptions{ + Roots: certPool, + }) + assert.NilError(t, err, "cert should be verified with our custom pool") +} + +func TestIsLoftReachable_UnhealthyServer(t *testing.T) { + // Server that returns 500 on /healthz. + server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + })) + defer server.Close() + + host := strings.TrimPrefix(server.URL, "https://") + + reachable, err := IsLoftReachable(context.Background(), host, true) + assert.NilError(t, err) + assert.Assert(t, !reachable, "should not be reachable when server returns 500") +} + +func TestIsLoftReachable_UnreachableHost(t *testing.T) { + // Use a host that doesn't exist. + reachable, err := IsLoftReachable(context.Background(), "localhost:1", true) + assert.NilError(t, err) + assert.Assert(t, !reachable, "should not be reachable when host is unreachable") +} diff --git a/pkg/util/http/transport.go b/pkg/util/http/transport.go index c31c8796eb..d819dd91ab 100644 --- a/pkg/util/http/transport.go +++ b/pkg/util/http/transport.go @@ -12,8 +12,17 @@ func CloneDefaultTransport() *http.Transport { return transport } -func InsecureTransport() *http.Transport { +// Transport returns a cloned default transport with TLS verification +// controlled by the insecure parameter. When insecure is true, TLS +// certificate verification is skipped. +func Transport(insecure bool) *http.Transport { newTransport := CloneDefaultTransport() - newTransport.TLSClientConfig = &tls.Config{InsecureSkipVerify: true} + if insecure { + newTransport.TLSClientConfig = &tls.Config{InsecureSkipVerify: true} + } return newTransport } + +func InsecureTransport() *http.Transport { + return Transport(true) +} diff --git a/pkg/util/http/transport_test.go b/pkg/util/http/transport_test.go new file mode 100644 index 0000000000..a065cab647 --- /dev/null +++ b/pkg/util/http/transport_test.go @@ -0,0 +1,33 @@ +package http + +import ( + "testing" + + "gotest.tools/v3/assert" +) + +func TestTransport_Secure(t *testing.T) { + tr := Transport(false) + if tr.TLSClientConfig != nil { + assert.Assert(t, !tr.TLSClientConfig.InsecureSkipVerify, "TLS verification should be enabled when insecure=false") + } + assert.Assert(t, !tr.ForceAttemptHTTP2, "HTTP/2 should be disabled") +} + +func TestTransport_Insecure(t *testing.T) { + tr := Transport(true) + assert.Assert(t, tr.TLSClientConfig != nil, "TLSClientConfig should be set when insecure=true") + assert.Assert(t, tr.TLSClientConfig.InsecureSkipVerify, "TLS verification should be skipped when insecure=true") + assert.Assert(t, !tr.ForceAttemptHTTP2, "HTTP/2 should be disabled") +} + +func TestInsecureTransport(t *testing.T) { + tr := InsecureTransport() + assert.Assert(t, tr.TLSClientConfig != nil, "TLSClientConfig should be set") + assert.Assert(t, tr.TLSClientConfig.InsecureSkipVerify, "InsecureTransport should skip TLS verification") +} + +func TestCloneDefaultTransport(t *testing.T) { + tr := CloneDefaultTransport() + assert.Assert(t, !tr.ForceAttemptHTTP2, "HTTP/2 should be disabled") +} From fe9ac04f7952ae5f1edeea7269a48d682755e3ef Mon Sep 17 00:00:00 2001 From: rlmcpherson Date: Tue, 7 Apr 2026 11:10:49 -0600 Subject: [PATCH 2/2] fix linter conflict --- .github/workflows/lint.yaml | 57 ------------------------------------- 1 file changed, 57 deletions(-) diff --git a/.github/workflows/lint.yaml b/.github/workflows/lint.yaml index b318ef6da3..26cbeb99b1 100644 --- a/.github/workflows/lint.yaml +++ b/.github/workflows/lint.yaml @@ -64,64 +64,7 @@ jobs: exit 1 fi -<<<<<<< HEAD - name: Run golangci-lint uses: golangci/golangci-lint-action@v9 with: version: v2.4 -======= - - name: Verify go mod tidy and vendor - if: github.event.pull_request.head.repo.full_name == github.repository - run: | - go mod tidy - go mod vendor - if [ -n "$(git status --porcelain go.mod go.sum vendor/)" ]; then - echo "❌ ERROR: go.mod, go.sum, or vendor/ directory is out of sync." - echo "Please run 'go mod tidy && go mod vendor' and commit the changes." - echo "" - echo "Changed files:" - git status --porcelain go.mod go.sum vendor/ - echo "" - echo "Diff (go.mod and go.sum):" - git diff go.mod go.sum - echo "" - echo "💡 TIP: If this works locally but fails in CI, check for gitignored" - echo " directories (e.g. licenses/) that may affect dependency resolution." - echo " Run 'git status --ignored' locally to see ignored files." - exit 1 - fi - env: - GOFLAGS: "" - - - name: Install golangci-lint - run: go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.7.0 - env: - GOFLAGS: "" - - - name: Build custom golangci-lint with plugins - if: github.event.pull_request.head.repo.full_name == github.repository - run: golangci-lint custom - env: - GOFLAGS: "" - - - name: Run golangci-lint (with custom linters) - if: github.event.pull_request.head.repo.full_name == github.repository - run: ./tools/golangci-lint run --timeout 15m -- ./... - - - name: Generate config without custom linters (fork PRs) - if: github.event.pull_request.head.repo.full_name != github.repository - run: | - # Remove custom plugin definitions and their enable/exclusion entries - # so stock golangci-lint can run without the compiled plugin binary. - CUSTOM_LINTERS=$(yq '.linters.settings.custom | keys | .[]' .golangci.yml) - cp .golangci.yml .golangci-fork.yml - for linter in $CUSTOM_LINTERS; do - yq -i "del(.linters.settings.custom.\"$linter\")" .golangci-fork.yml - yq -i ".linters.enable -= [\"$linter\"]" .golangci-fork.yml - yq -i "del(.linters.exclusions.rules[] | select(.linters[] == \"$linter\"))" .golangci-fork.yml - done - - - name: Run golangci-lint (fork PRs, without custom linters) - if: github.event.pull_request.head.repo.full_name != github.repository - run: golangci-lint run --timeout 15m --config .golangci-fork.yml -- ./... ->>>>>>> 1499106e9 (ENGPLAT-399 Add --secure flag for TLS verification (#3781))