Skip to content

Commit 720ecce

Browse files
[v0.30] ENGPLAT-399 Add --secure flag for TLS verification (#3781) (#3799)
* 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 # cmd/vclusterctl/cmd/platform/start.go # cmd/vclusterctl/cmd/platform/start_test.go # pkg/cli/start/login.go * fix linter conflict * address merge conflicts --------- Co-authored-by: Randall McPherson <rlmcpherson@gmail.com>
1 parent f297b81 commit 720ecce

11 files changed

Lines changed: 290 additions & 34 deletions

File tree

cmd/vclusterctl/cmd/platform/start.go

Lines changed: 34 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -81,12 +81,21 @@ before running this command:
8181
startCmd.Flags().StringVar(&cmd.ChartRepo, "chart-repo", "https://charts.loft.sh/", "The chart repo to deploy vCluster platform")
8282
startCmd.Flags().StringVar(&cmd.ChartName, "chart-name", "vcluster-platform", "The chart name to deploy vCluster platform")
8383
startCmd.Flags().BoolVar(&cmd.Docker, "docker", false, "If true, vCluster platform will be installed in Docker")
84+
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)")
8485

8586
return startCmd
8687
}
8788

8889
func (cmd *StartCmd) Run(ctx context.Context) error {
89-
// get version to deploy
90+
cfg := cmd.LoadedConfig(cmd.Log)
91+
92+
// Bootstrap defaults to insecure because the platform starts with a
93+
// self-signed certificate. Pass --secure to enforce TLS verification.
94+
if !cmd.Secure {
95+
cfg.Platform.Insecure = true
96+
}
97+
98+
// get the version to deploy
9099
if cmd.Version == "latest" || cmd.Version == "" {
91100
cmd.Version = platform.MinimumVersionTag
92101
latestVersion, err := platform.LatestCompatibleVersion(ctx)
@@ -154,8 +163,10 @@ func (cmd *StartCmd) Run(ctx context.Context) error {
154163
}
155164
}
156165

157-
if err := cmd.ensureEmailWithDisclaimer(ctx, cmd.KubeClient, cmd.Namespace); err != nil {
158-
return err
166+
if !cmd.platformUsesNewActivationFlow(cmd.Version) {
167+
if err := cmd.ensureEmailWithDisclaimer(ctx, cmd.KubeClient, cmd.Namespace); err != nil {
168+
return err
169+
}
159170
}
160171

161172
return start.NewLoftStarter(cmd.StartOptions).Start(ctx)
@@ -203,6 +214,26 @@ func promptForEmail(emailAddress string) (string, error) {
203214
return emailAddress, nil
204215
}
205216

217+
// platformUsesNewActivationFlow checks if the platform version supports the new platform activation flow.
218+
//
219+
// The new platform activation flow is supported for the platform version 4.6.0-rc.8 and above.
220+
func (cmd *StartCmd) platformUsesNewActivationFlow(platformVersion string) bool {
221+
platformSemVerVersion, err := semver.ParseTolerant(platformVersion)
222+
if err != nil {
223+
cmd.Log.Warnf("Failed to parse platform version %s, falling back to the old platform activation flow with the admin email prompt", platformVersion)
224+
return false
225+
}
226+
227+
const minPlatformVersionWithNewActivationFlow = "4.6.0-rc.8"
228+
if platformSemVerVersion.GTE(semver.MustParse(minPlatformVersionWithNewActivationFlow)) {
229+
cmd.Log.Debugf("Platform version %s is greater than or equal to %s, platform is using the new activation flow, so skipping admin email prompt", platformVersion, minPlatformVersionWithNewActivationFlow)
230+
return true
231+
}
232+
233+
cmd.Log.Debugf("Platform version %s is not using the new activation flow, so admin email is required", platformVersion)
234+
return false
235+
}
236+
206237
func validateEmail(emailAddress string) error {
207238
if emailAddress == "" {
208239
return fmt.Errorf("admin email address is required")
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
package platform
2+
3+
import (
4+
"testing"
5+
6+
"github.com/loft-sh/log"
7+
"github.com/loft-sh/vcluster/pkg/cli/flags"
8+
"github.com/loft-sh/vcluster/pkg/cli/start"
9+
)
10+
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+
33+
func TestPlatformUsesNewActivationFlow(t *testing.T) {
34+
testCases := []struct {
35+
version string
36+
expected bool
37+
}{
38+
{"", false},
39+
{"dev", false},
40+
{"4.5.0", false},
41+
{"v4.5.0", false},
42+
{"4.5.1", false},
43+
{"4.6.0-alpha.5", false},
44+
{"4.6.0-rc.7", false},
45+
{"4.6.0-rc.8", true},
46+
{"4.6.0-rc.9", true},
47+
{"4.6.0", true},
48+
{"v4.6.0", true},
49+
}
50+
51+
globalFlags := &flags.GlobalFlags{}
52+
startCmd := &StartCmd{
53+
StartOptions: start.StartOptions{
54+
Options: start.Options{
55+
CommandName: "start",
56+
GlobalFlags: globalFlags,
57+
Log: log.GetInstance(),
58+
},
59+
},
60+
}
61+
62+
for _, testCase := range testCases {
63+
result := startCmd.platformUsesNewActivationFlow(testCase.version)
64+
if result != testCase.expected {
65+
t.Errorf("Expected %v, got %v for platform version %s", testCase.expected, result, testCase.version)
66+
}
67+
}
68+
}

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: 27 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -58,8 +58,6 @@ func (l *LoftStarter) login(url string) error {
5858
}
5959

6060
func (l *LoftStarter) loginViaCLI(url string) error {
61-
loginPath := "%s/auth/password/login"
62-
6361
loginRequest := types.PasswordLoginRequest{
6462
Username: defaultUser,
6563
Password: l.Password,
@@ -70,32 +68,40 @@ func (l *LoftStarter) loginViaCLI(url string) error {
7068
return err
7169
}
7270

73-
loginRequestBuf := bytes.NewBuffer(loginRequestBytes)
71+
config := l.LoadedConfig(l.Log)
72+
httpClient := &http.Client{Transport: &http.Transport{
73+
TLSClientConfig: &tls.Config{InsecureSkipVerify: config.Platform.Insecure},
74+
}}
7475

75-
tr := &http.Transport{
76-
TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
77-
}
78-
httpClient := &http.Client{Transport: tr}
76+
// try a couple of times to login
77+
accessKey := &types.AccessKey{}
78+
for i := 0; i < 3; i++ {
79+
resp, err := httpClient.Post(url+"/auth/password/login", "application/json", bytes.NewBuffer(loginRequestBytes))
80+
if err != nil {
81+
return err
82+
}
7983

80-
resp, err := httpClient.Post(fmt.Sprintf(loginPath, url), "application/json", loginRequestBuf)
81-
if err != nil {
82-
return err
83-
}
84-
defer resp.Body.Close()
84+
body, err := io.ReadAll(resp.Body)
85+
if err != nil {
86+
_ = resp.Body.Close()
87+
return err
88+
}
89+
_ = resp.Body.Close()
8590

86-
body, err := io.ReadAll(resp.Body)
87-
if err != nil {
88-
return err
91+
err = json.Unmarshal(body, accessKey)
92+
if err != nil {
93+
return err
94+
}
95+
if accessKey.AccessKey == "" {
96+
continue
97+
}
98+
break
8999
}
90-
91-
accessKey := &types.AccessKey{}
92-
err = json.Unmarshal(body, accessKey)
93-
if err != nil {
94-
return err
100+
if accessKey.AccessKey == "" {
101+
return fmt.Errorf("couldn't retrieve access key from platform to login")
95102
}
96103

97104
// log into loft
98-
config := l.LoadedConfig(l.Log)
99105
loginClient := platform.NewLoginClientFromConfig(config)
100106
url = strings.TrimSuffix(url, "/")
101107
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)