Files
k3k/pkg/controller/kubeconfig/kubeconfig.go
Enrico CandinoandGitHub 244011e68d Refactor kubeconfig URL generation (#938)
* Refactor kubeconfig generation to remove unused port parameter and update related functions

* Refactor kubeconfig generation to streamline error handling and remove unused imports

* Refactor kubeconfig URL generation functions and deprecate old implementation

* restore old behavior

* Add 'k3kcli kubeconfig get' command and update documentation

* Refactor URL generation by removing deprecated getURLFromService function and updating tests to use new implementation

* Fix expected URL for LoadBalancer test case to include hostname

* Refactor kubeconfig test documentation to clarify URL generation behavior for ClusterIP, NodePort, and LoadBalancer service types

* Refactor kubeconfig URL generation functions to improve clarity and maintainability

* Remove deprecated 'k3kcli kubeconfig get' command and update related documentation

* Set logger to discard in NewRootCmd for improved logging control

* Refactor getURLFromService to streamline ingress key retrieval
2026-06-26 11:16:54 +02:00

214 lines
6.2 KiB
Go

package kubeconfig
import (
"context"
"crypto/x509"
"fmt"
"net"
"net/url"
"slices"
"strconv"
"time"
"k8s.io/apimachinery/pkg/types"
"k8s.io/apiserver/pkg/authentication/user"
"sigs.k8s.io/controller-runtime/pkg/client"
certutil "github.com/rancher/dynamiclistener/cert"
corev1 "k8s.io/api/core/v1"
networkingv1 "k8s.io/api/networking/v1"
clientcmdapi "k8s.io/client-go/tools/clientcmd/api"
ctrl "sigs.k8s.io/controller-runtime"
"github.com/rancher/k3k/pkg/apis/k3k.io/v1beta1"
"github.com/rancher/k3k/pkg/controller"
"github.com/rancher/k3k/pkg/controller/certs"
"github.com/rancher/k3k/pkg/controller/cluster/server"
"github.com/rancher/k3k/pkg/controller/cluster/server/bootstrap"
)
type KubeConfig struct {
AltNames certutil.AltNames
CN string
ORG []string
ExpiryDate time.Duration
}
func New() *KubeConfig {
return &KubeConfig{
CN: controller.AdminCommonName,
ORG: []string{user.SystemPrivilegedGroup},
ExpiryDate: 0,
}
}
func (k *KubeConfig) Generate(ctx context.Context, client client.Client, cluster *v1beta1.Cluster, hostServerIP string) (*clientcmdapi.Config, error) {
bootstrapData, err := bootstrap.LoadFromSecret(ctx, client, cluster)
if err != nil {
return nil, err
}
serverCACert := []byte(bootstrapData.ServerCA.Content)
adminCert, adminKey, err := certs.CreateClientCertKey(
k.CN,
k.ORG,
&k.AltNames,
[]x509.ExtKeyUsage{x509.ExtKeyUsageClientAuth},
k.ExpiryDate,
bootstrapData.ClientCA.Content,
bootstrapData.ClientCAKey.Content,
)
if err != nil {
return nil, err
}
serverURL, err := getURLFromService(ctx, client, cluster, hostServerIP)
if err != nil {
return nil, err
}
config := NewConfig(serverURL.String(), serverCACert, adminCert, adminKey)
return config, nil
}
func NewConfig(url string, serverCA, clientCert, clientKey []byte) *clientcmdapi.Config {
config := clientcmdapi.NewConfig()
cluster := clientcmdapi.NewCluster()
cluster.CertificateAuthorityData = serverCA
cluster.Server = url
authInfo := clientcmdapi.NewAuthInfo()
authInfo.ClientCertificateData = clientCert
authInfo.ClientKeyData = clientKey
context := clientcmdapi.NewContext()
context.AuthInfo = "default"
context.Cluster = "default"
config.Clusters["default"] = cluster
config.AuthInfos["default"] = authInfo
config.Contexts["default"] = context
config.CurrentContext = "default"
return config
}
// getURLFromService generates the API server URL for the kubeconfig based on the service configuration.
//
// It handles internal vs external access patterns:
// - Internal access (hostServerIP == service.ClusterIP): uses the ClusterIP for direct pod-to-pod communication
// - External access (hostServerIP != service.ClusterIP): uses the appropriate external endpoint based on the service type
//
// Service type handling:
// - ClusterIP: uses service.Spec.ClusterIP (internal-only)
// - NodePort: uses hostServerIP:NodePort for external access, ClusterIP:Port for internal access
// - LoadBalancer: uses the LoadBalancer ingress IP, falling back to its hostname
// - Ingress (if configured): takes precedence over the service-based URL
//
// The hostServerIP parameter determines the access pattern:
// - Controller reconciliation: passes service.Spec.ClusterIP → internal access
// - CLI kubeconfig export: passes the external host → external access
func getURLFromService(ctx context.Context, c client.Client, cluster *v1beta1.Cluster, hostServerIP string) (*url.URL, error) {
log := ctrl.LoggerFrom(ctx)
key := types.NamespacedName{
Name: server.ServiceName(cluster.Name),
Namespace: cluster.Namespace,
}
// Check if ingress is configured
if cluster.Spec.Expose != nil && cluster.Spec.Expose.Ingress != nil {
key := types.NamespacedName{
Name: server.IngressName(cluster.Name),
Namespace: cluster.Namespace,
}
var k3kIngress networkingv1.Ingress
if err := c.Get(ctx, key, &k3kIngress); err != nil {
return nil, err
}
if len(k3kIngress.Spec.Rules) > 0 && k3kIngress.Spec.Rules[0].Host != "" {
return url.Parse(fmt.Sprintf("https://%s", k3kIngress.Spec.Rules[0].Host))
}
log.V(1).Info("Ingress has no rule with a host set, falling back to the service URL.")
}
// Fall back to Service-based URL
var k3kService corev1.Service
if err := c.Get(ctx, key, &k3kService); err != nil {
return nil, err
}
// init to hostServerIP and 443 port
ip := hostServerIP
port := int32(443)
// Use service port as default if available
if len(k3kService.Spec.Ports) > 0 {
port = k3kService.Spec.Ports[0].Port
}
// Handle each service type separately
switch k3kService.Spec.Type {
case corev1.ServiceTypeClusterIP:
ip = k3kService.Spec.ClusterIP
case corev1.ServiceTypeNodePort:
// Only use NodePort if hostServerIP is NOT the ClusterIP
// If hostServerIP == ClusterIP, this is an internal connection, use ClusterIP
if hostServerIP != k3kService.Spec.ClusterIP {
if len(k3kService.Spec.Ports) > 0 {
port = k3kService.Spec.Ports[0].NodePort
}
} else {
// Internal connection: use ClusterIP
ip = k3kService.Spec.ClusterIP
}
case corev1.ServiceTypeLoadBalancer:
if len(k3kService.Status.LoadBalancer.Ingress) > 0 {
ingress := k3kService.Status.LoadBalancer.Ingress[0]
switch {
case ingress.IP != "":
ip = ingress.IP
case ingress.Hostname != "":
ip = ingress.Hostname
default:
log.V(1).Info("No usable ingress address found in LoadBalancer service.")
}
}
}
if !slices.Contains(cluster.Status.TLSSANs, ip) {
log.V(1).Info(fmt.Sprintf("IP %s not in tlsSANs.", ip))
if len(cluster.Spec.TLSSANs) > 0 {
log.V(1).Info("Using the first TLS SAN in the spec as a fallback: " + cluster.Spec.TLSSANs[0])
ip = cluster.Spec.TLSSANs[0]
} else if len(cluster.Status.TLSSANs) > 0 {
log.V(1).Info("No explicit tlsSANs specified. Trying to use the first TLS SAN in the status: " + cluster.Status.TLSSANs[0])
ip = cluster.Status.TLSSANs[0]
} else {
log.V(1).Info("IP not found in tlsSANs. This could cause issue with the certificate validation.")
}
}
// Build URL with port only if not the default HTTPS port
var rawURL string
if port != int32(443) {
rawURL = fmt.Sprintf("https://%s", net.JoinHostPort(ip, strconv.Itoa(int(port))))
} else {
rawURL = fmt.Sprintf("https://%s", ip)
}
return url.Parse(rawURL)
}