From 386edd772f8f93854b884da3a9777d2808fb01ee Mon Sep 17 00:00:00 2001 From: stevensbkang Date: Fri, 8 May 2026 12:23:29 +1200 Subject: [PATCH 1/3] feat(networking): add --disable-ipv6 flag for IPv4-only operation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduces --disable-ipv6 (KUBESOLO_DISABLE_IPV6) to disable IPv6 across all layers when running on IPv4-only or constrained environments: - OS: writes net.ipv6.conf.{all,default,lo}/disable_ipv6=1 via /proc/sys at bootstrap; idempotent — skips paths already set to 1 - CoreDNS: omits ip6.arpa reverse zone from the Corefile - Kubelet: conditionally passes --node-ip to force IPv4-only node registration only when the flag is set, avoiding breakage on IPv6-only hosts --- cmd/kubesolo/main.go | 13 +++++++++- internal/config/flags/flags.go | 1 + internal/runtime/network/ipv6.go | 29 ++++++++++++++++++++++ pkg/components/coredns/configuration.go | 32 +++++++++++++++++++++---- pkg/components/coredns/coredns.go | 4 ++-- pkg/kubernetes/kubelet/args.go | 8 +++++-- pkg/kubernetes/kubelet/service.go | 2 ++ types/types.go | 3 +++ 8 files changed, 83 insertions(+), 9 deletions(-) create mode 100644 internal/runtime/network/ipv6.go 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..71db99f2 --- /dev/null +++ b/internal/runtime/network/ipv6.go @@ -0,0 +1,29 @@ +package network + +import ( + "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", +} + +func DisableIPv6Sysctls() error { + for _, path := range ipv6SysctlPaths { + current, err := os.ReadFile(path) + if err == nil && 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 { + return fmt.Errorf("failed to disable ipv6 at %s: %w", path, err) + } + log.Info().Str("component", "network").Msgf("disabled ipv6: %s", path) + } + return nil +} diff --git a/pkg/components/coredns/configuration.go b/pkg/components/coredns/configuration.go index 99bb1143..92e6ac78 100644 --- a/pkg/components/coredns/configuration.go +++ b/pkg/components/coredns/configuration.go @@ -9,8 +9,25 @@ import ( "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,18 +45,25 @@ const CoreDNSConfig = `.:53 { ready :8181 }` +func coreDNSConfig(disableIPv6 bool) string { + if disableIPv6 { + return coreDNSConfigIPv4Only + } + return coreDNSConfigDualStack +} + // 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 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), }, } 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..58d0a747 100644 --- a/pkg/kubernetes/kubelet/args.go +++ b/pkg/kubernetes/kubelet/args.go @@ -5,10 +5,14 @@ import ( ) 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 { + args = append(args, "--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 From 5b41d7efc5b0fc38caffe916270a5967589e1768 Mon Sep 17 00:00:00 2001 From: stevensbkang Date: Fri, 8 May 2026 16:28:02 +1200 Subject: [PATCH 2/3] fix(networking): address review feedback on disable-ipv6 implementation - Add doc comment to DisableIPv6Sysctls - Aggregate sysctl write errors so all three paths are always attempted rather than halting on the first failure, avoiding partial configuration - Replace ConfigMap Update with a merge patch so existing metadata (labels, annotations) and unrelated data keys are preserved --- internal/runtime/network/ipv6.go | 10 +++++++-- pkg/components/coredns/configuration.go | 29 +++++++++++++++---------- 2 files changed, 26 insertions(+), 13 deletions(-) diff --git a/internal/runtime/network/ipv6.go b/internal/runtime/network/ipv6.go index 71db99f2..86d34f8b 100644 --- a/internal/runtime/network/ipv6.go +++ b/internal/runtime/network/ipv6.go @@ -1,6 +1,7 @@ package network import ( + "errors" "fmt" "os" @@ -13,7 +14,11 @@ var ipv6SysctlPaths = []string{ "/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 && len(current) > 0 && current[0] == '1' { @@ -21,9 +26,10 @@ func DisableIPv6Sysctls() error { continue } if err := os.WriteFile(path, []byte("1"), 0644); err != nil { - return fmt.Errorf("failed to disable ipv6 at %s: %w", path, err) + errs = append(errs, fmt.Errorf("%s: %w", path, err)) + continue } log.Info().Str("component", "network").Msgf("disabled ipv6: %s", path) } - return nil + return errors.Join(errs...) } diff --git a/pkg/components/coredns/configuration.go b/pkg/components/coredns/configuration.go index 92e6ac78..b680b2b8 100644 --- a/pkg/components/coredns/configuration.go +++ b/pkg/components/coredns/configuration.go @@ -2,10 +2,12 @@ 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" ) @@ -52,10 +54,9 @@ func coreDNSConfig(disableIPv6 bool) string { return coreDNSConfigDualStack } -// 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 +// 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{ @@ -68,15 +69,21 @@ func createConfigMap(ctx context.Context, clientset *kubernetes.Clientset, disab } _, 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 } From 06a4fd3885c49f2e4e0cf9732daaa69a9ed8277c Mon Sep 17 00:00:00 2001 From: stevensbkang Date: Mon, 11 May 2026 11:34:40 +1200 Subject: [PATCH 3/3] feedback --- internal/runtime/network/ipv6.go | 7 ++++++- pkg/kubernetes/kubelet/args.go | 10 +++++++++- 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/internal/runtime/network/ipv6.go b/internal/runtime/network/ipv6.go index 86d34f8b..6ddc0346 100644 --- a/internal/runtime/network/ipv6.go +++ b/internal/runtime/network/ipv6.go @@ -21,7 +21,12 @@ func DisableIPv6Sysctls() error { var errs []error for _, path := range ipv6SysctlPaths { current, err := os.ReadFile(path) - if err == nil && len(current) > 0 && current[0] == '1' { + 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 } diff --git a/pkg/kubernetes/kubelet/args.go b/pkg/kubernetes/kubelet/args.go index 58d0a747..0f70c674 100644 --- a/pkg/kubernetes/kubelet/args.go +++ b/pkg/kubernetes/kubelet/args.go @@ -1,6 +1,9 @@ package kubelet import ( + "net" + + "github.com/rs/zerolog/log" "github.com/spf13/cobra" ) @@ -12,7 +15,12 @@ func (s *service) configureKubeletArgs(command *cobra.Command) { "--kubeconfig", s.kubeletKubeConfigFile, } if s.disableIPv6 { - args = append(args, "--node-ip", s.nodeIP) + 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) }