diff --git a/cli/cmds/cluster_create.go b/cli/cmds/cluster_create.go index 191d1b1e..32e7654f 100644 --- a/cli/cmds/cluster_create.go +++ b/cli/cmds/cluster_create.go @@ -43,6 +43,7 @@ type CreateConfig struct { agentEnvs []string labels []string annotations []string + tlsSANs []string persistenceType string storageClassName string storageRequestSize string @@ -130,17 +131,14 @@ func createAction(appCtx *AppContext, config *CreateConfig) func(cmd *cobra.Comm } // add Host IP address as an extra TLS-SAN to expose the k3k cluster - url, err := url.Parse(appCtx.RestConfig.Host) + host, err := extractHost(appCtx.RestConfig.Host, config.kubeconfigServerHost) if err != nil { return err } - host := strings.Split(url.Host, ":") - if config.kubeconfigServerHost != "" { - host = []string{config.kubeconfigServerHost} - } + sans := append([]string{host}, config.tlsSANs...) - cluster.Spec.TLSSANs = []string{host[0]} + cluster.Spec.TLSSANs = uniqueStrings(sans) if err := client.Create(ctx, cluster); err != nil { if apierrors.IsAlreadyExists(err) { @@ -482,3 +480,34 @@ func getClusterDetails(cluster *v1beta1.Cluster) (string, error) { return buf.String(), nil } + +// extractHost returns the hostname from the given server URL, stripping any +// port and correctly handling IPv6 addresses (e.g. "[2001:db8::1]:6443"). +// If override is non-empty it is returned as-is. +func extractHost(serverURL, override string) (string, error) { + if override != "" { + return override, nil + } + + u, err := url.Parse(serverURL) + if err != nil { + return "", err + } + + return u.Hostname(), nil +} + +// uniqueStrings returns a new slice with duplicates removed, preserving the first occurrence order. +func uniqueStrings(ss []string) []string { + seen := make(map[string]struct{}, len(ss)) + result := make([]string, 0, len(ss)) + + for _, s := range ss { + if _, ok := seen[s]; !ok { + seen[s] = struct{}{} + result = append(result, s) + } + } + + return result +} diff --git a/cli/cmds/cluster_create_flags.go b/cli/cmds/cluster_create_flags.go index 35526afc..c728f9ee 100644 --- a/cli/cmds/cluster_create_flags.go +++ b/cli/cmds/cluster_create_flags.go @@ -25,6 +25,7 @@ func createFlags(cmd *cobra.Command, cfg *CreateConfig) { cmd.Flags().StringSliceVar(&cfg.agentArgs, "agent-args", []string{}, "agents extra arguments") cmd.Flags().StringSliceVar(&cfg.serverEnvs, "server-envs", []string{}, "servers extra Envs") cmd.Flags().StringSliceVar(&cfg.agentEnvs, "agent-envs", []string{}, "agents extra Envs") + cmd.Flags().StringSliceVar(&cfg.tlsSANs, "tls-sans", []string{}, "additional Subject Alternative Names for the cluster server TLS certificate") cmd.Flags().StringArrayVar(&cfg.labels, "labels", []string{}, "Labels to add to the cluster object (e.g. key=value)") cmd.Flags().StringArrayVar(&cfg.annotations, "annotations", []string{}, "Annotations to add to the cluster object (e.g. key=value)") cmd.Flags().StringVar(&cfg.version, "version", "", "k3s version") diff --git a/cli/cmds/cluster_create_test.go b/cli/cmds/cluster_create_test.go index 1c3153a4..95086fbe 100644 --- a/cli/cmds/cluster_create_test.go +++ b/cli/cmds/cluster_create_test.go @@ -94,3 +94,52 @@ func Test_printClusterDetails(t *testing.T) { }) } } + +func Test_extractHost(t *testing.T) { + tests := []struct { + name string + serverURL string + override string + want string + }{ + { + name: "plain host", + serverURL: "https://example.com", + want: "example.com", + }, + { + name: "host with port", + serverURL: "https://example.com:6443", + want: "example.com", + }, + { + name: "ipv4 with port", + serverURL: "https://192.0.2.1:6443", + want: "192.0.2.1", + }, + { + name: "ipv6 without port", + serverURL: "https://[2001:db8::1]", + want: "2001:db8::1", + }, + { + name: "ipv6 with port", + serverURL: "https://[2001:db8::1]:6443", + want: "2001:db8::1", + }, + { + name: "override wins", + serverURL: "https://example.com:6443", + override: "custom.host", + want: "custom.host", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + host, err := extractHost(tt.serverURL, tt.override) + assert.NoError(t, err) + assert.Equal(t, tt.want, host) + }) + } +} diff --git a/docs/cli/k3kcli.adoc b/docs/cli/k3kcli.adoc index b6fbebf5..85d43679 100644 --- a/docs/cli/k3kcli.adoc +++ b/docs/cli/k3kcli.adoc @@ -65,6 +65,7 @@ k3kcli cluster create [command options] NAME --storage-class-name string storage class name for dynamic persistence type --storage-request-size string storage size for dynamic persistence type --timeout duration The timeout for waiting for the cluster to become ready (e.g., 10s, 5m, 1h). (default 3m0s) + --tls-sans strings additional Subject Alternative Names for the cluster server TLS certificate --token string token of the cluster --version string k3s version ---- diff --git a/docs/cli/k3kcli_cluster_create.md b/docs/cli/k3kcli_cluster_create.md index 7afccb21..b57704be 100644 --- a/docs/cli/k3kcli_cluster_create.md +++ b/docs/cli/k3kcli_cluster_create.md @@ -36,6 +36,7 @@ k3kcli cluster create [command options] NAME --storage-class-name string storage class name for dynamic persistence type --storage-request-size string storage size for dynamic persistence type --timeout duration The timeout for waiting for the cluster to become ready (e.g., 10s, 5m, 1h). (default 3m0s) + --tls-sans strings additional Subject Alternative Names for the cluster server TLS certificate --token string token of the cluster --version string k3s version ``` diff --git a/tests/cli/cli_test.go b/tests/cli/cli_test.go index 977339a7..d6ce52e7 100644 --- a/tests/cli/cli_test.go +++ b/tests/cli/cli_test.go @@ -113,6 +113,45 @@ var _ = When("using the k3kcli", Label("cli"), func() { Expect(err).To(Not(HaveOccurred()), string(stderr)) Expect(stderr).To(ContainSubstring("You can start using the cluster")) }) + + It("can create a cluster with multiple --tls-sans values", func() { + var ( + stderr string + err error + ) + + clusterName := "cluster-" + rand.String(5) + namespace := fwk3k.CreateNamespace(k8s) + clusterNamespace := namespace.Name + + DeferCleanup(func() { + fwk3k.DeleteNamespaces(k8s, clusterNamespace) + }) + + _, stderr, err = K3kcli("cluster", "create", + "--namespace", clusterNamespace, + "--tls-sans", "extra.example.com", + "--tls-sans", "192.0.2.1", + "--tls-sans", "host.example.com:6443", + "--tls-sans", "2001:db8::1", + "--tls-sans", "[2001:db8::1]:6443", + clusterName, + ) + Expect(err).To(Not(HaveOccurred()), string(stderr)) + Expect(stderr).To(ContainSubstring("You can start using the cluster")) + + var cluster v1beta1.Cluster + + err = k8sClient.Get(context.Background(), types.NamespacedName{Name: clusterName, Namespace: clusterNamespace}, &cluster) + Expect(err).To(Not(HaveOccurred())) + Expect(cluster.Spec.TLSSANs).To(ContainElements( + "extra.example.com", + "192.0.2.1", + "host.example.com:6443", + "2001:db8::1", + "[2001:db8::1]:6443", + )) + }) }) When("trying the policy commands", func() {