diff --git a/cmd/kubesolo/main.go b/cmd/kubesolo/main.go index 459e2b69..65ad6a66 100644 --- a/cmd/kubesolo/main.go +++ b/cmd/kubesolo/main.go @@ -49,6 +49,7 @@ type kubesolo struct { localStorage bool localStorageSharedPath string fullMode bool + disableIPv6 bool embedded types.Embedded } @@ -76,6 +77,7 @@ func service() (*kubesolo, error) { localStorage: *flags.LocalStorage, localStorageSharedPath: *flags.LocalStorageSharedPath, fullMode: *flags.Full, + disableIPv6: *flags.DisableIPv6, }, nil } @@ -238,7 +240,7 @@ func (s *kubesolo) run() { } log.Info().Str("component", "kubesolo").Msg("deploying coredns...") - if err := coredns.Deploy(s.embedded.AdminKubeconfigFile); err != nil { + if err := coredns.Deploy(s.embedded.AdminKubeconfigFile, s.embedded.DisableIPv6); err != nil { log.Fatal().Err(err).Msg("failed to deploy coredns") } @@ -351,6 +353,12 @@ func (s *kubesolo) bootstrap() { // Load required kernel modules before any networking setup system.LoadRequiredModules() + if s.disableIPv6 { + if err := network.DisableIPv6Sysctls(); err != nil { + log.Warn().Err(err).Msg("failed to disable ipv6 sysctls") + } + } + // System Node IP nodeIP, err := network.GetNodeIP() if err != nil { @@ -491,5 +499,8 @@ func (s *kubesolo) bootstrap() { // Full mode FullMode: s.fullMode, + + // IPv6 + DisableIPv6: s.disableIPv6, } } diff --git a/internal/config/flags/flags.go b/internal/config/flags/flags.go index 3656c7d7..cd029774 100644 --- a/internal/config/flags/flags.go +++ b/internal/config/flags/flags.go @@ -26,4 +26,5 @@ var ( Debug = Application.Flag("debug", "Enable debug logging. Defaults to false.").Envar("KUBESOLO_DEBUG").Default("false").Bool() PprofServer = Application.Flag("pprof-server", "Enable pprof server. Defaults to false.").Envar("KUBESOLO_PPROF_SERVER").Default("false").Bool() Full = Application.Flag("full", "Disable memory-saving overrides and use upstream Kubernetes defaults. Kubesolo still uses NodeSetter in favour of the scheduler. Recommended for CI and developer environments where memory is not constrained. Leave unset for edge deployments.").Envar("KUBESOLO_FULL").Default("false").Bool() + DisableIPv6 = Application.Flag("disable-ipv6", "Disable IPv6 support. When set, CoreDNS will not serve ip6.arpa reverse zones and kubelet will register with an explicit IPv4 node address. Defaults to false.").Envar("KUBESOLO_DISABLE_IPV6").Default("false").Bool() ) diff --git a/internal/runtime/network/ipv6.go b/internal/runtime/network/ipv6.go new file mode 100644 index 00000000..6ddc0346 --- /dev/null +++ b/internal/runtime/network/ipv6.go @@ -0,0 +1,40 @@ +package network + +import ( + "errors" + "fmt" + "os" + + "github.com/rs/zerolog/log" +) + +var ipv6SysctlPaths = []string{ + "/proc/sys/net/ipv6/conf/all/disable_ipv6", + "/proc/sys/net/ipv6/conf/default/disable_ipv6", + "/proc/sys/net/ipv6/conf/lo/disable_ipv6", +} + +// DisableIPv6Sysctls writes 1 to the kernel sysctl paths that disable IPv6 on +// all interfaces. It is idempotent — paths already set to 1 are skipped. All +// paths are attempted regardless of failure; errors are aggregated and returned. +func DisableIPv6Sysctls() error { + var errs []error + for _, path := range ipv6SysctlPaths { + current, err := os.ReadFile(path) + if err != nil { + if os.IsNotExist(err) || os.IsPermission(err) { + log.Debug().Str("component", "network").Msgf("ipv6 sysctl not available, skipping: %s", path) + continue + } + } else if len(current) > 0 && current[0] == '1' { + log.Debug().Str("component", "network").Msgf("ipv6 already disabled: %s", path) + continue + } + if err := os.WriteFile(path, []byte("1"), 0644); err != nil { + errs = append(errs, fmt.Errorf("%s: %w", path, err)) + continue + } + log.Info().Str("component", "network").Msgf("disabled ipv6: %s", path) + } + return errors.Join(errs...) +} diff --git a/pkg/components/coredns/configuration.go b/pkg/components/coredns/configuration.go index 99bb1143..b680b2b8 100644 --- a/pkg/components/coredns/configuration.go +++ b/pkg/components/coredns/configuration.go @@ -2,15 +2,34 @@ package coredns import ( "context" + "encoding/json" corev1 "k8s.io/api/core/v1" "k8s.io/apimachinery/pkg/api/errors" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + k8stypes "k8s.io/apimachinery/pkg/types" "k8s.io/client-go/kubernetes" ) -// CoreDNSConfig contains minimal CoreDNS Corefile configuration -const CoreDNSConfig = `.:53 { +const coreDNSConfigIPv4Only = `.:53 { + errors + loop + cache 30 { + disable denial cluster.local + } + kubernetes cluster.local in-addr.arpa { + pods insecure + fallthrough in-addr.arpa + ttl 30 + } + forward . /etc/resolv.conf + minimal + reload + health :8080 + ready :8181 +}` + +const coreDNSConfigDualStack = `.:53 { errors loop cache 30 { @@ -28,31 +47,43 @@ const CoreDNSConfig = `.:53 { ready :8181 }` -// createConfigMap creates a configMap with the bare minimum CoreDNS configuration -// it creates a new configmap if it does not exist -// it updates the configmap if it already exists -// it returns an error if it fails -func createConfigMap(ctx context.Context, clientset *kubernetes.Clientset) error { +func coreDNSConfig(disableIPv6 bool) string { + if disableIPv6 { + return coreDNSConfigIPv4Only + } + return coreDNSConfigDualStack +} + +// createConfigMap creates or patches the CoreDNS ConfigMap with the Corefile +// for the selected IP family mode. On update it uses a merge patch so existing +// metadata (labels, annotations) and unrelated data keys are preserved. +func createConfigMap(ctx context.Context, clientset *kubernetes.Clientset, disableIPv6 bool) error { configMap := &corev1.ConfigMap{ ObjectMeta: metav1.ObjectMeta{ Name: coreDNSConfigMapName, Namespace: coreDNSNamespace, }, Data: map[string]string{ - "Corefile": CoreDNSConfig, + "Corefile": coreDNSConfig(disableIPv6), }, } _, err := clientset.CoreV1().ConfigMaps(coreDNSNamespace).Create(ctx, configMap, metav1.CreateOptions{}) - if err != nil && !errors.IsAlreadyExists(err) { + if err == nil { + return nil + } + if !errors.IsAlreadyExists(err) { return err } - if errors.IsAlreadyExists(err) { - _, err = clientset.CoreV1().ConfigMaps(coreDNSNamespace).Update(ctx, configMap, metav1.UpdateOptions{}) - if err != nil { - return err - } + patch, err := json.Marshal(map[string]any{ + "data": map[string]string{"Corefile": coreDNSConfig(disableIPv6)}, + }) + if err != nil { + return err } - return nil + _, err = clientset.CoreV1().ConfigMaps(coreDNSNamespace).Patch( + ctx, coreDNSConfigMapName, k8stypes.MergePatchType, patch, metav1.PatchOptions{}, + ) + return err } diff --git a/pkg/components/coredns/coredns.go b/pkg/components/coredns/coredns.go index 67cb3a07..81dcae68 100644 --- a/pkg/components/coredns/coredns.go +++ b/pkg/components/coredns/coredns.go @@ -22,7 +22,7 @@ const ( ) // Deploy deploys all the necessary Kubernetes resources for CoreDNS -func Deploy(adminKubeconfig string) error { +func Deploy(adminKubeconfig string, disableIPv6 bool) error { time.Sleep(types.DefaultComponentSleep) ctx, cancel := context.WithTimeout(context.Background(), types.DefaultContextTimeout) @@ -33,7 +33,7 @@ func Deploy(adminKubeconfig string) error { return fmt.Errorf("failed to create kubernetes client: %v", err) } - if err := createConfigMap(ctx, clientset); err != nil { + if err := createConfigMap(ctx, clientset, disableIPv6); err != nil { return fmt.Errorf("failed to create CoreDNS ConfigMap: %v", err) } diff --git a/pkg/kubernetes/kubelet/args.go b/pkg/kubernetes/kubelet/args.go index 2ca21012..0f70c674 100644 --- a/pkg/kubernetes/kubelet/args.go +++ b/pkg/kubernetes/kubelet/args.go @@ -1,14 +1,26 @@ package kubelet import ( + "net" + + "github.com/rs/zerolog/log" "github.com/spf13/cobra" ) func (s *service) configureKubeletArgs(command *cobra.Command) { - command.SetArgs([]string{ + args := []string{ "--config", s.kubeletConfigFile, "--hostname-override", s.nodeName, "--root-dir", s.kubeletDir, "--kubeconfig", s.kubeletKubeConfigFile, - }) + } + if s.disableIPv6 { + ip := net.ParseIP(s.nodeIP) + if ip != nil && !ip.IsLoopback() { + args = append(args, "--node-ip", s.nodeIP) + } else { + log.Warn().Str("component", "kubelet").Msgf("--disable-ipv6 set but node IP %q is loopback or invalid; skipping --node-ip", s.nodeIP) + } + } + command.SetArgs(args) } diff --git a/pkg/kubernetes/kubelet/service.go b/pkg/kubernetes/kubelet/service.go index bb6e3125..417a601b 100644 --- a/pkg/kubernetes/kubelet/service.go +++ b/pkg/kubernetes/kubelet/service.go @@ -29,6 +29,7 @@ type service struct { kubeletCertPath string adminKubeconfig string fullMode bool + disableIPv6 bool } // NewService creates a new kubelet service @@ -51,5 +52,6 @@ func NewService(ctx context.Context, cancel context.CancelFunc, kubeletReady cha nodeName: system.GetHostname(), adminKubeconfig: embedded.AdminKubeconfigFile, fullMode: embedded.FullMode, + disableIPv6: embedded.DisableIPv6, } } diff --git a/types/types.go b/types/types.go index 31649940..eb0c327c 100644 --- a/types/types.go +++ b/types/types.go @@ -132,6 +132,9 @@ type Embedded struct { // Full mode — disables memory-saving overrides, uses upstream Kubernetes defaults FullMode bool + + // IPv6 + DisableIPv6 bool } // EdgeAgentConfig contains configuration for Portainer Edge Agent