Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 5 additions & 5 deletions INSTALL.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ curl -sfL https://raw.githubusercontent.com/portainer/kubesolo/develop/install.s

# With options
curl -sfL https://get.kubesolo.io | sudo sh -s -- \
--version=v1.1.3 \
--version=v1.1.4 \
--path=/opt/kubesolo \
--run-mode=service

Expand Down Expand Up @@ -58,7 +58,7 @@ This script provides basic installation with minimal dependencies for the most c
wget -O - https://raw.githubusercontent.com/portainer/kubesolo/develop/install-minimal.sh | sh

# Or with environment variables
KUBESOLO_VERSION=v1.1.3 KUBESOLO_PATH=/opt/kubesolo sh install-minimal.sh
KUBESOLO_VERSION=v1.1.4 KUBESOLO_PATH=/opt/kubesolo sh install-minimal.sh
```

**Features:**
Expand Down Expand Up @@ -93,7 +93,7 @@ chmod +x kubesolo-service.sh
All installers support these environment variables:

```bash
export KUBESOLO_VERSION="v1.1.3" # Version to install
export KUBESOLO_VERSION="v1.1.4" # Version to install
export KUBESOLO_PATH="/var/lib/kubesolo" # Installation path
export KUBESOLO_PORTAINER_EDGE_ID="your-id" # Portainer Edge ID
export KUBESOLO_PORTAINER_EDGE_KEY="your-key" # Portainer Edge Key
Expand Down Expand Up @@ -138,7 +138,7 @@ For air-gapped installations:

```bash
# Pre-download the binary
wget https://github.com/portainer/kubesolo/releases/download/v1.1.3/kubesolo-v1.1.3-linux-arm64.tar.gz
wget https://github.com/portainer/kubesolo/releases/download/v1.1.4/kubesolo-v1.1.4-linux-arm64.tar.gz

# Extract and install manually
tar -xzf kubesolo-*.tar.gz
Expand Down Expand Up @@ -173,7 +173,7 @@ curl -sfL https://get.kubesolo.io | sudo sh -
# Combined with other options
curl -sfL https://get.kubesolo.io | sudo sh -s -- \
--proxy=http://proxy.company.com:8080 \
--version=v1.1.3 \
--version=v1.1.4 \
--path=/opt/kubesolo
```

Expand Down
65 changes: 54 additions & 11 deletions cmd/kubesolo/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,8 @@ type kubesolo struct {
loadBalancer bool
localStorage bool
localStorageSharedPath string
fullMode bool
disableIPv6 bool
embedded types.Embedded
}

Expand All @@ -74,6 +76,8 @@ func service() (*kubesolo, error) {
loadBalancer: *flags.LoadBalancer,
localStorage: *flags.LocalStorage,
localStorageSharedPath: *flags.LocalStorageSharedPath,
fullMode: *flags.Full,
disableIPv6: *flags.DisableIPv6,
}, nil
}

Expand Down Expand Up @@ -114,10 +118,16 @@ func (s *kubesolo) run() {
cancel()
}()

profile := "edge"
if s.fullMode {
profile = "full"
}

log.Info().
Str("version", Version).
Str("build-date", BuildDate).
Str("commit", Commit).
Str("profile", profile).
Msg("starting kubesolo...")

log.Info().Str("component", "kubesolo").Msg("ensuring all embedded dependencies are available...")
Expand All @@ -131,11 +141,14 @@ func (s *kubesolo) run() {
}
log.Info().Str("component", "kubesolo").Msg("starting kubesolo services... this may take a few minutes...")

services := []struct {
type service struct {
name string
start func()
readyCh chan struct{}
}{
}

// infraServices must be fully ready before pod masquerade is set up.
infraServices := []service{
{
name: "containerd",
start: func() {
Expand Down Expand Up @@ -176,6 +189,10 @@ func (s *kubesolo) run() {
},
readyCh: controllerReadyCh,
},
}

// nodeServices start after masquerade is guaranteed to be in place.
nodeServices := []service{
{
name: "kubelet",
start: func() {
Expand All @@ -189,7 +206,7 @@ func (s *kubesolo) run() {
{
name: "kubeproxy",
start: func() {
kubeproxyService := kubeproxy.NewService(ctx, cancel, kubeproxyReadyCh, s.embedded.AdminKubeconfigFile)
kubeproxyService := kubeproxy.NewService(ctx, cancel, kubeproxyReadyCh, s.embedded.AdminKubeconfigFile, s.embedded.FullMode)
s.wg.Go(func() {
kubeproxyService.Run(kubeletReadyCh)
})
Expand All @@ -198,7 +215,23 @@ func (s *kubesolo) run() {
},
}

for _, svc := range services {
for _, svc := range infraServices {
log.Info().Str("component", "kubesolo").Msgf("starting %s...", svc.name)
svc.start()
if !waitForService(ctx, svc.name, svc.readyCh) {
return
}
}

// Ensure pod→external masquerade (SNAT) is in place before kubelet starts.
// kine persists cluster state across reboots, so kubelet will immediately
// reconcile existing pods — they must not start into a network with no SNAT.
log.Info().Str("component", "kubesolo").Msg("setting up pod masquerade rules...")
if err := network.EnsurePodMasquerade(types.DefaultPodCIDR); err != nil {
log.Fatal().Err(err).Msg("failed to set up pod masquerade")
}

for _, svc := range nodeServices {
log.Info().Str("component", "kubesolo").Msgf("starting %s...", svc.name)
svc.start()
if !waitForService(ctx, svc.name, svc.readyCh) {
Expand All @@ -207,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")
}

Expand Down Expand Up @@ -272,6 +305,10 @@ func cleanStaleState(basePath string) {
if name == "containerd" || name == "containerd-shim-runc-v2" || name == "crun" {
continue
}
// Preserve registry
if name == "registry" {
continue
}

target := filepath.Join(containerdDir, name)
if err := os.RemoveAll(target); err == nil {
Expand Down Expand Up @@ -397,12 +434,12 @@ func (s *kubesolo) bootstrap() {
},

// Containerd paths
ContainerdDir: filepath.Join(basePath, types.DefaultContainerdDir),
ContainerdSocketFile: filepath.Join(basePath, types.DefaultContainerdDir, types.DefaultContainerdSocket),
ContainerdBinaryFile: filepath.Join(basePath, types.DefaultContainerdDir, "containerd"),
ContainerdImagesDir: filepath.Join(basePath, types.DefaultContainerdDir, "images"),
ContainerdShimBinaryFile: filepath.Join(basePath, types.DefaultContainerdDir, "containerd-shim-runc-v2"),
ContainerdConfigFile: filepath.Join(basePath, types.DefaultContainerdDir, "config.toml"),
ContainerdDir: filepath.Join(basePath, types.DefaultContainerdDir),
ContainerdSocketFile: filepath.Join(basePath, types.DefaultContainerdDir, types.DefaultContainerdSocket),
ContainerdBinaryFile: filepath.Join(basePath, types.DefaultContainerdDir, "containerd"),
ContainerdImagesDir: filepath.Join(basePath, types.DefaultContainerdDir, "images"),
ContainerdShimBinaryFile: filepath.Join(basePath, types.DefaultContainerdDir, "containerd-shim-runc-v2"),
ContainerdConfigFile: filepath.Join(basePath, types.DefaultContainerdDir, "config.toml"),
ContainerdRootDir: filepath.Join(basePath, types.DefaultContainerdDir, "root"),
ContainerdStateDir: filepath.Join(basePath, types.DefaultContainerdDir, "state"),
ContainerdRegistryConfigDir: filepath.Join(basePath, types.DefaultContainerdDir, "registry"),
Expand Down Expand Up @@ -453,5 +490,11 @@ func (s *kubesolo) bootstrap() {

// Portainer Edge
IsPortainerEdge: s.portainerEdgeID != "" && s.portainerEdgeKey != "",

// Full mode
FullMode: s.fullMode,

// IPv6
DisableIPv6: s.disableIPv6,
}
}
2 changes: 1 addition & 1 deletion install-minimal.sh
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ case $ARCH in
esac

# Configuration
KUBESOLO_VERSION="${KUBESOLO_VERSION:-v1.1.3}"
KUBESOLO_VERSION="${KUBESOLO_VERSION:-v1.1.4}"
CONFIG_PATH="${KUBESOLO_PATH:-/var/lib/kubesolo}"
INSTALL_PATH="/usr/local/bin/kubesolo"
USE_MUSL="${USE_MUSL:-false}"
Expand Down
2 changes: 1 addition & 1 deletion install.sh
Original file line number Diff line number Diff line change
Expand Up @@ -1060,7 +1060,7 @@ install_binary() {
# ── Script entry point ────────────────────────────────────────────────────────

# Default configuration from environment variables
KUBESOLO_VERSION="${KUBESOLO_VERSION:-v1.1.3}"
KUBESOLO_VERSION="${KUBESOLO_VERSION:-v1.1.4}"
CONFIG_PATH="${KUBESOLO_PATH:-/var/lib/kubesolo}"
APISERVER_EXTRA_SANS="${KUBESOLO_APISERVER_EXTRA_SANS:-}"
PORTAINER_EDGE_ID="${KUBESOLO_PORTAINER_EDGE_ID:-}"
Expand Down
2 changes: 2 additions & 0 deletions internal/config/flags/flags.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,4 +25,6 @@ var (
LocalStorageSharedPath = Application.Flag("local-storage-shared-path", "Path to the shared file system for the local storage. Defaults to empty string.").Envar("KUBESOLO_LOCAL_STORAGE_SHARED_PATH").Default("").String()
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()
Copy link

Copilot AI Apr 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The flag help text says kubelet will only register with an explicit IPv4 node address when this flag is set, but the current kubelet args unconditionally set --node-ip. Either make kubelet’s --node-ip conditional on DisableIPv6, or adjust this description to match the actual behavior.

Copilot uses AI. Check for mistakes.
)
2 changes: 1 addition & 1 deletion internal/core/embedded/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ func generateCNIConfigFile() map[string]any {
"type": "bridge",
"bridge": "cni0",
"isGateway": true,
"ipMasq": true,
"ipMasq": false,
"hairpinMode": true,
"capabilities": map[string]any{
"portMappings": true,
Expand Down
118 changes: 118 additions & 0 deletions internal/runtime/network/masquerade.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
package network

import (
"fmt"
"os"
"os/exec"
"strings"

"github.com/portainer/kubesolo/types"
"github.com/rs/zerolog/log"
)

const masqueradeComment = "kubesolo: pod masquerade"

// EnsurePodMasquerade programs a SNAT/masquerade rule for pod egress traffic so
// pods can reach external IPs. It is idempotent and mode-aware: iptables on
// systems that have the ip_tables kernel module, nftables otherwise.
//
// This must be called before kubelet starts (so no pod ever starts without SNAT
// in place) and after flushNftablesNat (to restore the rule after the nat table
// is flushed for kube-proxy compatibility).
func EnsurePodMasquerade(podCIDR string) error {
if _, err := os.Stat("/proc/net/ip_tables_names"); err == nil {
return ensureIPTablesMasquerade(podCIDR)
}
return ensureNftablesMasquerade(podCIDR)
}

func ensureIPTablesMasquerade(podCIDR string) error {
// -w 5: wait up to 5 s for the xtables lock. This function is called during
// kube-proxy startup when other processes may also be modifying iptables;
// without -w, concurrent access causes "Resource temporarily unavailable".
args := []string{
"-w", "5",
"-t", "nat", "-C", "POSTROUTING",
"-s", podCIDR, "!", "-d", podCIDR,
"-m", "comment", "--comment", masqueradeComment,
"-j", "MASQUERADE",
}

if err := exec.Command("iptables", args...).Run(); err == nil {
log.Debug().Str("component", "network").Msg("pod masquerade rule already present (iptables)")
return nil
}

args[4] = "-A"
if out, err := exec.Command("iptables", args...).CombinedOutput(); err != nil {
return fmt.Errorf("iptables: failed to add pod masquerade rule: %v (output: %s)", err, out)
}

log.Info().Str("component", "network").
Str("cidr", podCIDR).
Msg("added pod masquerade rule (iptables)")
return nil
}

func nftCombinedOutput(args ...string) ([]byte, error) {
return exec.Command("nft", args...).CombinedOutput()
}

func nftAlreadyExists(out []byte) bool {
msg := strings.ToLower(string(out))
return strings.Contains(msg, "file exists") || strings.Contains(msg, "already exists")
}

// ensureNftObject ensures an nftables object (table or chain) exists. It tries
// listArgs first; if the object is absent it runs addArgs, tolerating a
// concurrent creation racing us to it.
func ensureNftObject(listArgs, addArgs []string) error {
if _, err := nftCombinedOutput(listArgs...); err == nil {
return nil
}
if out, err := nftCombinedOutput(addArgs...); err != nil && !nftAlreadyExists(out) {
return fmt.Errorf("nft %s: %v (output: %s)", strings.Join(addArgs, " "), err, out)
}
return nil
}

func ensureNftablesMasquerade(podCIDR string) error {
if err := ensureNftObject(
[]string{"list", "table", "ip", types.DefaultNftMasqTable},
[]string{"add", "table", "ip", types.DefaultNftMasqTable},
); err != nil {
return err
}

if err := ensureNftObject(
[]string{"list", "chain", "ip", types.DefaultNftMasqTable, "postrouting"},
[]string{"add", "chain", "ip", types.DefaultNftMasqTable, "postrouting",
"{ type nat hook postrouting priority srcnat; policy accept; }"},
); err != nil {
return err
}

out, err := nftCombinedOutput("list", "chain", "ip", types.DefaultNftMasqTable, "postrouting")
if err != nil {
return fmt.Errorf("nft list chain ip %s postrouting: %v (output: %s)", types.DefaultNftMasqTable, err, out)
}
if strings.Contains(string(out), masqueradeComment) {
log.Debug().Str("component", "network").Msg("pod masquerade rule already present (nftables)")
return nil
}

args := []string{
"add", "rule", "ip", types.DefaultNftMasqTable, "postrouting",
"ip", "saddr", podCIDR, "ip", "daddr", "!=", podCIDR,
"masquerade", "comment", `"` + masqueradeComment + `"`,
}
if out, err := nftCombinedOutput(args...); err != nil {
return fmt.Errorf("nft %s: %v (output: %s)", strings.Join(args, " "), err, out)
}

log.Info().Str("component", "network").
Str("cidr", podCIDR).
Str("table", types.DefaultNftMasqTable).
Msg("added pod masquerade rule (nftables)")
return nil
}
Loading