diff --git a/internal/controller/bootstrap/controlplane_bootstrap_controller.go b/internal/controller/bootstrap/controlplane_bootstrap_controller.go index 0994e9503..36c7e3982 100644 --- a/internal/controller/bootstrap/controlplane_bootstrap_controller.go +++ b/internal/controller/bootstrap/controlplane_bootstrap_controller.go @@ -21,6 +21,7 @@ import ( "crypto/tls" "errors" "fmt" + "net" "net/http" "net/netip" "path/filepath" @@ -690,7 +691,7 @@ func (c *ControlPlaneController) detectJoinHost(ctx context.Context, scope *Cont if found && k0sAPIPort > 0 { port = strconv.Itoa(int(k0sAPIPort)) } - host := fmt.Sprintf("https://%s:%s", scope.Cluster.Spec.ControlPlaneEndpoint.Host, port) + host := joinHostPortURL(scope.Cluster.Spec.ControlPlaneEndpoint.Host, port) _, err = httpClient.Get(fmt.Sprintf("%s/v1beta1/ca", host)) if err == nil { @@ -702,7 +703,13 @@ func (c *ControlPlaneController) detectJoinHost(ctx context.Context, scope *Cont return "", fmt.Errorf("failed to get first controller IP: %w", err) } - return fmt.Sprintf("https://%s:%s", firstControllerIP, port), nil + return joinHostPortURL(firstControllerIP, port), nil +} + +// joinHostPortURL builds an https URL for the given host and port. It uses +// net.JoinHostPort so that IPv6 literals are bracketed correctly. +func joinHostPortURL(host, port string) string { + return "https://" + net.JoinHostPort(host, port) } func (c *ControlPlaneController) findFirstControllerIP(ctx context.Context, firstControllerMachine *clusterv1.Machine) (string, error) { diff --git a/internal/controller/bootstrap/controlplane_bootstrap_controller_test.go b/internal/controller/bootstrap/controlplane_bootstrap_controller_test.go index c09ab18e8..9c1018803 100644 --- a/internal/controller/bootstrap/controlplane_bootstrap_controller_test.go +++ b/internal/controller/bootstrap/controlplane_bootstrap_controller_test.go @@ -164,3 +164,22 @@ func TestController_genK0sCommands(t *testing.T) { }) } } + +func Test_joinHostPortURL(t *testing.T) { + tests := []struct { + name string + host string + port string + want string + }{ + {"hostname", "cp.example.com", "9443", "https://cp.example.com:9443"}, + {"ipv4", "10.0.0.1", "9443", "https://10.0.0.1:9443"}, + {"ipv6", "2001:db8::1", "9443", "https://[2001:db8::1]:9443"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + require.Equal(t, tt.want, joinHostPortURL(tt.host, tt.port)) + }) + } +} diff --git a/internal/controller/bootstrap/worker_bootstrap_controller.go b/internal/controller/bootstrap/worker_bootstrap_controller.go index febdffbf3..937307b10 100644 --- a/internal/controller/bootstrap/worker_bootstrap_controller.go +++ b/internal/controller/bootstrap/worker_bootstrap_controller.go @@ -21,7 +21,9 @@ import ( "errors" "fmt" "maps" + "net" "path/filepath" + "strconv" "strings" "time" @@ -529,9 +531,9 @@ func (r *Controller) getK0sToken(ctx context.Context, scope *Scope) (string, err } var joinToken string - joinURL := fmt.Sprintf("https://%s:%d", scope.Cluster.Spec.ControlPlaneEndpoint.Host, scope.Cluster.Spec.ControlPlaneEndpoint.Port) + joinURL := controlPlaneJoinURL(scope.Cluster.Spec.ControlPlaneEndpoint.Host, int64(scope.Cluster.Spec.ControlPlaneEndpoint.Port)) if scope.ingressSpec != nil { - joinURL = fmt.Sprintf("https://%s:%d", scope.ingressSpec.APIHost, scope.ingressSpec.Port) + joinURL = controlPlaneJoinURL(scope.ingressSpec.APIHost, scope.ingressSpec.Port) } joinToken, err := kutil.CreateK0sJoinToken(ca.KeyPair.Cert, token, joinURL, "kubelet-bootstrap") @@ -541,6 +543,12 @@ func (r *Controller) getK0sToken(ctx context.Context, scope *Scope) (string, err return joinToken, nil } +// controlPlaneJoinURL builds the k0s join URL for the given host and port. +// It uses net.JoinHostPort so that IPv6 literals are bracketed correctly. +func controlPlaneJoinURL(host string, port int64) string { + return "https://" + net.JoinHostPort(host, strconv.FormatInt(port, 10)) +} + func createIngressCommands(scope *Scope) []string { if scope.ingressSpec == nil { return []string{} diff --git a/internal/controller/bootstrap/worker_bootstrap_controller_test.go b/internal/controller/bootstrap/worker_bootstrap_controller_test.go index 2c21e5be6..d6ec1f585 100644 --- a/internal/controller/bootstrap/worker_bootstrap_controller_test.go +++ b/internal/controller/bootstrap/worker_bootstrap_controller_test.go @@ -145,3 +145,22 @@ func Test_getWindowsCommands(t *testing.T) { } } + +func Test_controlPlaneJoinURL(t *testing.T) { + tests := []struct { + name string + host string + port int64 + want string + }{ + {"hostname", "cp.example.com", 443, "https://cp.example.com:443"}, + {"ipv4", "10.0.0.1", 6443, "https://10.0.0.1:6443"}, + {"ipv6", "2001:db8::1", 443, "https://[2001:db8::1]:443"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + require.Equal(t, tt.want, controlPlaneJoinURL(tt.host, tt.port)) + }) + } +}