Skip to content

Commit dcb883e

Browse files
committed
ENGPLAT-399 Add --secure flag for TLS verification (#3781)
* 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 d5181a1. * 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 1499106) # Conflicts: # .github/workflows/lint.yaml
1 parent 53e2c69 commit dcb883e

12 files changed

Lines changed: 228 additions & 13 deletions

File tree

.github/workflows/lint.yaml

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,7 +81,41 @@ jobs:
8181
env:
8282
GOFLAGS: ""
8383

84+
<<<<<<< HEAD
8485
- name: Run golangci-lint
8586
uses: golangci/golangci-lint-action@v9
8687
with:
8788
version: v2.4
89+
=======
90+
- name: Install golangci-lint
91+
run: go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.7.0
92+
env:
93+
GOFLAGS: ""
94+
95+
- name: Build custom golangci-lint with plugins
96+
if: github.event.pull_request.head.repo.full_name == github.repository
97+
run: golangci-lint custom
98+
env:
99+
GOFLAGS: ""
100+
101+
- name: Run golangci-lint (with custom linters)
102+
if: github.event.pull_request.head.repo.full_name == github.repository
103+
run: ./tools/golangci-lint run --timeout 15m -- ./...
104+
105+
- name: Generate config without custom linters (fork PRs)
106+
if: github.event.pull_request.head.repo.full_name != github.repository
107+
run: |
108+
# Remove custom plugin definitions and their enable/exclusion entries
109+
# so stock golangci-lint can run without the compiled plugin binary.
110+
CUSTOM_LINTERS=$(yq '.linters.settings.custom | keys | .[]' .golangci.yml)
111+
cp .golangci.yml .golangci-fork.yml
112+
for linter in $CUSTOM_LINTERS; do
113+
yq -i "del(.linters.settings.custom.\"$linter\")" .golangci-fork.yml
114+
yq -i ".linters.enable -= [\"$linter\"]" .golangci-fork.yml
115+
yq -i "del(.linters.exclusions.rules[] | select(.linters[] == \"$linter\"))" .golangci-fork.yml
116+
done
117+
118+
- name: Run golangci-lint (fork PRs, without custom linters)
119+
if: github.event.pull_request.head.repo.full_name != github.repository
120+
run: golangci-lint run --timeout 15m --config .golangci-fork.yml -- ./...
121+
>>>>>>> 1499106e9 (ENGPLAT-399 Add --secure flag for TLS verification (#3781))

cmd/vclusterctl/cmd/platform/start.go

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -82,13 +82,21 @@ before running this command:
8282
startCmd.Flags().StringVar(&cmd.ChartRepo, "chart-repo", "https://charts.loft.sh/", "The chart repo to deploy vCluster platform")
8383
startCmd.Flags().StringVar(&cmd.ChartName, "chart-name", "vcluster-platform", "The chart name to deploy vCluster platform")
8484
startCmd.Flags().BoolVar(&cmd.Docker, "docker", false, "If true, vCluster platform will be installed in Docker")
85+
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)")
8586

8687
return startCmd
8788
}
8889

8990
func (cmd *StartCmd) Run(ctx context.Context) error {
90-
// automatically use docker mode if the driver is set to docker
9191
cfg := cmd.LoadedConfig(cmd.Log)
92+
93+
// Bootstrap defaults to insecure because the platform starts with a
94+
// self-signed certificate. Pass --secure to enforce TLS verification.
95+
if !cmd.Secure {
96+
cfg.Platform.Insecure = true
97+
}
98+
99+
// automatically use docker mode if the driver is set to docker
92100
if cfg.Driver.Type == config.DockerDriver && !cmd.Docker {
93101
cmd.Log.Info("Automatically using --docker flag because driver is set to 'docker'")
94102
cmd.Docker = true

cmd/vclusterctl/cmd/platform/start_test.go

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,28 @@ import (
88
"github.com/loft-sh/vcluster/pkg/cli/start"
99
)
1010

11+
func TestNewStartCmd_SecureFlag(t *testing.T) {
12+
globalFlags := &flags.GlobalFlags{}
13+
cmd := NewStartCmd(globalFlags)
14+
15+
// Verify --secure flag exists and defaults to false (insecure by default).
16+
f := cmd.Flags().Lookup("secure")
17+
if f == nil {
18+
t.Fatal("--secure flag not registered on start command")
19+
}
20+
if f.DefValue != "false" {
21+
t.Errorf("expected --secure default to be 'false', got %q", f.DefValue)
22+
}
23+
24+
// Simulate passing --secure on the command line.
25+
if err := cmd.Flags().Set("secure", "true"); err != nil {
26+
t.Fatalf("failed to set --secure flag: %v", err)
27+
}
28+
if f.Value.String() != "true" {
29+
t.Errorf("expected --secure value to be 'true' after set, got %q", f.Value.String())
30+
}
31+
}
32+
1133
func TestPlatformUsesNewActivationFlow(t *testing.T) {
1234
testCases := []struct {
1335
version string

pkg/cli/start/docker.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -133,7 +133,7 @@ func (l *LoftStarter) successDocker(ctx context.Context, containerID string) err
133133
return false, fmt.Errorf("container failed (status: %s):\n %s", containerDetails.State.Status, logs)
134134
}
135135

136-
return clihelper.IsLoftReachable(ctx, host)
136+
return clihelper.IsLoftReachable(ctx, host, l.LoadedConfig(l.Log).Platform.Insecure)
137137
})
138138
if err != nil {
139139
return fmt.Errorf(product.Replace("error waiting for loft: %v%w"), err)

pkg/cli/start/login.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -66,8 +66,9 @@ func (l *LoftStarter) loginViaCLI(url string) error {
6666
return err
6767
}
6868

69+
config := l.LoadedConfig(l.Log)
6970
httpClient := &http.Client{Transport: &http.Transport{
70-
TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
71+
TLSClientConfig: &tls.Config{InsecureSkipVerify: config.Platform.Insecure},
7172
}}
7273

7374
// try a couple of times to login
@@ -99,7 +100,6 @@ func (l *LoftStarter) loginViaCLI(url string) error {
99100
}
100101

101102
// log into loft
102-
config := l.LoadedConfig(l.Log)
103103
loginClient := platform.NewLoginClientFromConfig(config)
104104
url = strings.TrimSuffix(url, "/")
105105
err = loginClient.LoginWithAccessKey(url, accessKey.AccessKey, config.Platform.Insecure)

pkg/cli/start/port_forwarding.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ func (l *LoftStarter) startPortForwarding(ctx context.Context, loftPod *corev1.P
2121

2222
// wait until loft is reachable at the given url
2323
httpClient := &http.Client{
24-
Transport: utilhttp.InsecureTransport(),
24+
Transport: utilhttp.Transport(l.LoadedConfig(l.Log).Platform.Insecure),
2525
}
2626
l.Log.Infof(product.Replace("Waiting until loft is reachable at https://localhost:%s"), l.LocalPort)
2727
err = wait.PollUntilContextTimeout(ctx, time.Second, clihelper.Timeout(), true, func(ctx context.Context) (bool, error) {

pkg/cli/start/start.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,7 @@ type StartOptions struct { //nolint:revive // linter suggests renaming to option
7070
Upgrade bool
7171
ReuseValues bool
7272
Docker bool
73+
Secure bool
7374
}
7475

7576
func NewLoftStarter(options StartOptions) *LoftStarter {

pkg/cli/start/success.go

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -68,7 +68,8 @@ func (l *LoftStarter) success(ctx context.Context) error {
6868
}
6969

7070
// check if loft is reachable
71-
reachable, err := clihelper.IsLoftReachable(ctx, host)
71+
insecure := l.LoadedConfig(l.Log).Platform.Insecure
72+
reachable, err := clihelper.IsLoftReachable(ctx, host, insecure)
7273
if !reachable || err != nil {
7374
const (
7475
YesOption = "Yes"
@@ -123,10 +124,11 @@ func (l *LoftStarter) pingLoftRouter(ctx context.Context, loftPod *corev1.Pod) (
123124
loftRouterDomain := string(loftRouterSecret.Data["domain"])
124125

125126
// wait until loft is reachable at the given url
127+
insecure := l.LoadedConfig(l.Log).Platform.Insecure
126128
httpClient := &http.Client{
127129
Transport: &http.Transport{
128130
TLSClientConfig: &tls.Config{
129-
InsecureSkipVerify: true,
131+
InsecureSkipVerify: insecure,
130132
},
131133
},
132134
}
@@ -190,7 +192,8 @@ func (l *LoftStarter) isLoggedIn(url string) bool {
190192
}
191193

192194
func (l *LoftStarter) successRemote(ctx context.Context, host string) error {
193-
ready, err := clihelper.IsLoftReachable(ctx, host)
195+
insecure := l.LoadedConfig(l.Log).Platform.Insecure
196+
ready, err := clihelper.IsLoftReachable(ctx, host, insecure)
194197
if err != nil {
195198
return err
196199
} else if ready {
@@ -203,7 +206,7 @@ func (l *LoftStarter) successRemote(ctx context.Context, host string) error {
203206

204207
l.Log.Info("Waiting for you to configure DNS, so loft can be reached on https://" + host)
205208
err = wait.PollUntilContextTimeout(ctx, 5*time.Second, clihelper.Timeout(), true, func(ctx context.Context) (done bool, err error) {
206-
return clihelper.IsLoftReachable(ctx, host)
209+
return clihelper.IsLoftReachable(ctx, host, insecure)
207210
})
208211
if err != nil {
209212
return err

pkg/platform/clihelper/clihelper.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -344,10 +344,10 @@ func GetLoftDefaultPassword(ctx context.Context, kubeClient kubernetes.Interface
344344
return string(loftNamespace.UID), nil
345345
}
346346

347-
func IsLoftReachable(ctx context.Context, host string) (bool, error) {
347+
func IsLoftReachable(ctx context.Context, host string, insecure bool) (bool, error) {
348348
// wait until loft is reachable at the given url
349349
client := &http.Client{
350-
Transport: utilhttp.InsecureTransport(),
350+
Transport: utilhttp.Transport(insecure),
351351
}
352352
endpoint := fmt.Sprintf("https://%s/healthz", host)
353353
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
package clihelper
2+
3+
import (
4+
"context"
5+
"crypto/tls"
6+
"crypto/x509"
7+
"net/http"
8+
"net/http/httptest"
9+
"strings"
10+
"testing"
11+
12+
"gotest.tools/v3/assert"
13+
)
14+
15+
func TestIsLoftReachable_InsecureTrueAgainstSelfSigned(t *testing.T) {
16+
// Create an HTTPS server with a self-signed certificate.
17+
server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
18+
if r.URL.Path == "/healthz" {
19+
w.WriteHeader(http.StatusOK)
20+
return
21+
}
22+
w.WriteHeader(http.StatusNotFound)
23+
}))
24+
defer server.Close()
25+
26+
host := strings.TrimPrefix(server.URL, "https://")
27+
28+
// With insecure=true, the self-signed cert should be accepted.
29+
reachable, err := IsLoftReachable(context.Background(), host, true)
30+
assert.NilError(t, err)
31+
assert.Assert(t, reachable, "should be reachable with insecure=true against self-signed cert")
32+
}
33+
34+
func TestIsLoftReachable_InsecureFalseAgainstSelfSigned(t *testing.T) {
35+
// Create an HTTPS server with a self-signed certificate.
36+
server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
37+
if r.URL.Path == "/healthz" {
38+
w.WriteHeader(http.StatusOK)
39+
return
40+
}
41+
w.WriteHeader(http.StatusNotFound)
42+
}))
43+
defer server.Close()
44+
45+
host := strings.TrimPrefix(server.URL, "https://")
46+
47+
// With insecure=false, the self-signed cert should cause a TLS error
48+
// and IsLoftReachable should return false (not reachable).
49+
reachable, err := IsLoftReachable(context.Background(), host, false)
50+
assert.NilError(t, err)
51+
assert.Assert(t, !reachable, "should not be reachable with insecure=false against self-signed cert")
52+
}
53+
54+
func TestIsLoftReachable_InsecureFalseAgainstTrustedCert(t *testing.T) {
55+
// Create an HTTPS server with a self-signed cert, but add the cert
56+
// to the system pool so it's trusted. We do this by creating a custom
57+
// test that validates the transport respects system certs.
58+
server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
59+
if r.URL.Path == "/healthz" {
60+
w.WriteHeader(http.StatusOK)
61+
return
62+
}
63+
w.WriteHeader(http.StatusNotFound)
64+
}))
65+
defer server.Close()
66+
67+
// Verify the server is actually using TLS with a self-signed cert.
68+
conn, err := tls.Dial("tcp", strings.TrimPrefix(server.URL, "https://"), &tls.Config{
69+
InsecureSkipVerify: true,
70+
})
71+
assert.NilError(t, err)
72+
defer conn.Close()
73+
74+
// Get the server certificate and create a cert pool that trusts it.
75+
serverCert := conn.ConnectionState().PeerCertificates[0]
76+
certPool := x509.NewCertPool()
77+
certPool.AddCert(serverCert)
78+
79+
// Verify the cert pool trusts the server - this validates our test setup.
80+
_, err = serverCert.Verify(x509.VerifyOptions{
81+
Roots: certPool,
82+
})
83+
assert.NilError(t, err, "cert should be verified with our custom pool")
84+
}
85+
86+
func TestIsLoftReachable_UnhealthyServer(t *testing.T) {
87+
// Server that returns 500 on /healthz.
88+
server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
89+
w.WriteHeader(http.StatusInternalServerError)
90+
}))
91+
defer server.Close()
92+
93+
host := strings.TrimPrefix(server.URL, "https://")
94+
95+
reachable, err := IsLoftReachable(context.Background(), host, true)
96+
assert.NilError(t, err)
97+
assert.Assert(t, !reachable, "should not be reachable when server returns 500")
98+
}
99+
100+
func TestIsLoftReachable_UnreachableHost(t *testing.T) {
101+
// Use a host that doesn't exist.
102+
reachable, err := IsLoftReachable(context.Background(), "localhost:1", true)
103+
assert.NilError(t, err)
104+
assert.Assert(t, !reachable, "should not be reachable when host is unreachable")
105+
}

0 commit comments

Comments
 (0)