Skip to content
Open
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
2 changes: 1 addition & 1 deletion docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -131,7 +131,7 @@ spec:
| `ca.certificatesExpireAfter` | The expiration duration of the server certificate (default: 8760h) |
| `extraArgs` | Map of key-values (strings) for any extra arguments to pass down to Kubernetes API server process. `extraArgs` are recommended over `rawArgs` if the use case allows it. Any behavior triggered by these parameters is outside k0s support. (default: empty) |
| `rawArgs` | Slice of strings for any raw arguments to pass down to the kube-apiserver process. These are appended after `extraArgs`. If possible, it's recommended to use `extraArgs` over `rawArgs`. Any behavior triggered by these parameters is outside k0s support. (default: empty) |
| `port`¹ | Custom port for the Kubernetes API server to listen on (default: 6443) |
| `port`¹ | Custom port for the Kubernetes API server to listen on (default: 6443). When set to a privileged port (< 1024), k0s automatically grants the `CAP_NET_BIND_SERVICE` capability to the kube-apiserver process to allow binding to the port. |
| `k0sApiPort`¹ | Custom port for k0s API server to listen on (default: 9443) |

¹ If `port` and `k0sApiPort` are used with the `externalAddress` element, the load balancer serving at `externalAddress` must listen on the same ports.
Expand Down
1 change: 1 addition & 0 deletions inttest/Makefile.variables
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,7 @@ smoketests := \
check-noderole \
check-noderole-no-taints \
check-noderole-single \
check-privileged-port \
check-psp \
check-reset \
check-singlenode \
Expand Down
132 changes: 132 additions & 0 deletions inttest/privileged-port/privileged_port_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
//go:build linux

// SPDX-FileCopyrightText: 2026 k0s authors
// SPDX-License-Identifier: Apache-2.0

package privilegedport

import (
"bytes"
"context"
"errors"
"fmt"
"html/template"
"strconv"
"strings"
"testing"

"github.com/k0sproject/k0s/inttest/common"
"github.com/stretchr/testify/suite"
"golang.org/x/sys/unix"
)

type PrivilegedPortSuite struct {
common.BootlooseSuite
}

const configWithPrivilegedPort = `
apiVersion: k0s.k0sproject.io/v1beta1
kind: ClusterConfig
metadata:
name: k0s
spec:
api:
port: {{ .Port }}
`

const privilegedPort = 443

func TestPrivilegedPortSuite(t *testing.T) {
s := PrivilegedPortSuite{
common.BootlooseSuite{
ControllerCount: 1,
WorkerCount: 0,
KubeAPIExternalPort: privilegedPort,
},
}
suite.Run(t, &s)
}

func (s *PrivilegedPortSuite) getControllerConfig() string {
data := struct {
Port int
}{
Port: privilegedPort,
}
content := bytes.NewBuffer([]byte{})
s.Require().NoError(template.Must(template.New("k0s.yaml").Parse(configWithPrivilegedPort)).Execute(content, data), "can't execute k0s.yaml template")
return content.String()
}

func (s *PrivilegedPortSuite) TestCapNetBindServiceIsSet() {
ctx := s.Context()

// Setup k0s with privileged port configuration
config := s.getControllerConfig()
s.PutFile(s.ControllerNode(0), "/tmp/k0s.yaml", config)

s.Require().NoError(s.InitController(0, "--config=/tmp/k0s.yaml"))

kc, err := s.KubeClient(s.ControllerNode(0))
s.Require().NoError(err)

s.AssertSomeKubeSystemPods(kc)

// Now verify that kube-apiserver has CAP_NET_BIND_SERVICE
s.Run("kube-apiserver has CAP_NET_BIND_SERVICE", func() {
s.Require().NoError(s.verifyCapability(ctx, s.ControllerNode(0)))
})
}

// verifyCapability checks if the kube-apiserver process has CAP_NET_BIND_SERVICE capability
func (s *PrivilegedPortSuite) verifyCapability(ctx context.Context, node string) error {
ssh, err := s.SSH(ctx, node)
if err != nil {
return fmt.Errorf("failed to SSH to node: %w", err)
}
defer ssh.Disconnect()

// Find the kube-apiserver PID
pid, err := ssh.ExecWithOutput(ctx, "pidof kube-apiserver")
if err != nil {
return fmt.Errorf("failed to find kube-apiserver process: %w", err)
}
pid = strings.TrimSpace(pid)
if pid == "" {
return errors.New("kube-apiserver process not found")
}

s.T().Logf("Found kube-apiserver with PID: %s", pid)

// Read the capability information from /proc/<pid>/status
// We need to check CapEff (effective capabilities)
capOutput, err := ssh.ExecWithOutput(ctx, fmt.Sprintf("grep CapEff /proc/%s/status", pid))
if err != nil {
return fmt.Errorf("failed to read capabilities: %w", err)
}

s.T().Logf("Capability output: %s", capOutput)

// Parse the capability hex value
// Format is "CapEff:\t0000000000000400" (or similar)
parts := strings.Fields(capOutput)
if len(parts) < 2 {
return fmt.Errorf("unexpected capability format: %s", capOutput)
}

capHex := parts[1]
capValue, err := strconv.ParseUint(capHex, 16, 64)
if err != nil {
return fmt.Errorf("failed to parse capability value %s: %w", capHex, err)
}

// Check if CAP_NET_BIND_SERVICE (bit 10) is set
// unix.CAP_NET_BIND_SERVICE is defined in golang.org/x/sys/unix
capNetBindService := uint64(unix.CAP_NET_BIND_SERVICE)
if capValue&(1<<capNetBindService) == 0 {
return fmt.Errorf("CAP_NET_BIND_SERVICE (bit %d) is not set in capabilities: 0x%x", capNetBindService, capValue)
}

s.T().Logf("CAP_NET_BIND_SERVICE is correctly set (capability value: 0x%x, bit %d is set)", capValue, capNetBindService)
return nil
}
31 changes: 25 additions & 6 deletions pkg/component/controller/apiserver.go
Original file line number Diff line number Diff line change
Expand Up @@ -87,9 +87,9 @@ func (a *APIServer) Init(_ context.Context) error {
return err
}

// Run runs kube api
func (a *APIServer) Start(ctx context.Context) error {
logrus.Info("Starting kube-apiserver")
// buildSupervisor constructs and configures the supervisor for the kube-apiserver
// without starting it. This allows for testing the configuration logic independently.
func (a *APIServer) buildSupervisor() (*supervisor.Supervisor, error) {
args := stringmap.StringMap{
"advertise-address": a.ClusterConfig.Spec.API.Address,
"secure-port": strconv.Itoa(a.ClusterConfig.Spec.API.Port),
Expand Down Expand Up @@ -126,7 +126,7 @@ func (a *APIServer) Start(ctx context.Context) error {
if a.EnableKonnectivity {
err := a.writeKonnectivityConfig()
if err != nil {
return err
return nil, err
}
args["egress-selector-config-file"] = filepath.Join(a.K0sVars.DataDir, "konnectivity.conf")
apiAudiences = append(apiAudiences, "system:konnectivity-server")
Expand Down Expand Up @@ -160,7 +160,7 @@ func (a *APIServer) Start(ctx context.Context) error {
}
apiServerArgs = append(apiServerArgs, a.ClusterConfig.Spec.API.RawArgs...)

a.supervisor = &supervisor.Supervisor{
sup := &supervisor.Supervisor{
Name: kubeAPIComponentName,
BinPath: a.executablePath,
RunDir: a.K0sVars.RunDir,
Expand All @@ -169,11 +169,30 @@ func (a *APIServer) Start(ctx context.Context) error {
UID: a.uid,
}

// If the API port is less than 1024, the process needs to bind to a privileged port
if a.ClusterConfig.Spec.API.Port < 1024 {
sup.RequiredPrivileges.BindsPrivilegedPorts = true
logrus.Infof("API port %d is less than 1024, granting privilege to bind to privileged ports", a.ClusterConfig.Spec.API.Port)
}

etcdArgs, err := getEtcdArgs(a.ClusterConfig.Spec.Storage, a.K0sVars)
if err != nil {
return nil, err
}
sup.Args = append(sup.Args, etcdArgs...)

return sup, nil
}

// Run runs kube api
func (a *APIServer) Start(ctx context.Context) error {
logrus.Info("Starting kube-apiserver")

var err error
a.supervisor, err = a.buildSupervisor()
if err != nil {
return err
}
a.supervisor.Args = append(a.supervisor.Args, etcdArgs...)

return a.supervisor.Supervise(ctx)
}
Expand Down
63 changes: 63 additions & 0 deletions pkg/component/controller/apiserver_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -109,3 +109,66 @@ func (a *apiServerSuite) TestGetEtcdArgs() {
require.Contains(result[1], "--etcd-prefix=k0s-tenant-1")
})
}

func (a *apiServerSuite) TestCapNetBindServiceForLowPorts() {
k0sVars := &config.CfgVars{
BinDir: "/var/lib/k0s/bin",
CertRootDir: "/var/lib/k0s/pki",
DataDir: "/var/lib/k0s",
RunDir: "/run/k0s",
}

a.Run("port 443 requires CAP_NET_BIND_SERVICE", func() {
clusterConfig := v1beta1.DefaultClusterConfig()
clusterConfig.Spec.API.Port = 443

apiServer := &APIServer{
ClusterConfig: clusterConfig,
K0sVars: k0sVars,
LogLevel: "1",
executablePath: "/fake/path/kube-apiserver",
}

supervisor, err := apiServer.buildSupervisor()
require := a.Require()
require.NoError(err)
require.True(supervisor.RequiredPrivileges.BindsPrivilegedPorts,
"Port 443 should require CAP_NET_BIND_SERVICE capability")
})

a.Run("port 6443 does not require CAP_NET_BIND_SERVICE", func() {
clusterConfig := v1beta1.DefaultClusterConfig()
clusterConfig.Spec.API.Port = 6443

apiServer := &APIServer{
ClusterConfig: clusterConfig,
K0sVars: k0sVars,
LogLevel: "1",
executablePath: "/fake/path/kube-apiserver",
}

supervisor, err := apiServer.buildSupervisor()
require := a.Require()
require.NoError(err)
require.False(supervisor.RequiredPrivileges.BindsPrivilegedPorts,
"Port 6443 should not require CAP_NET_BIND_SERVICE capability")
})

a.Run("port 80 requires CAP_NET_BIND_SERVICE", func() {
clusterConfig := v1beta1.DefaultClusterConfig()
clusterConfig.Spec.API.Port = 80

apiServer := &APIServer{
ClusterConfig: clusterConfig,
K0sVars: k0sVars,
LogLevel: "1",
executablePath: "/fake/path/kube-apiserver",
}

supervisor, err := apiServer.buildSupervisor()
require := a.Require()
require.NoError(err)
require.True(supervisor.RequiredPrivileges.BindsPrivilegedPorts,
"Port 80 should require CAP_NET_BIND_SERVICE capability")
})
}
38 changes: 38 additions & 0 deletions pkg/supervisor/detachattr_linux.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
//go:build linux

// SPDX-FileCopyrightText: 2026 k0s authors
// SPDX-License-Identifier: Apache-2.0

package supervisor

import (
"os"
"syscall"

"golang.org/x/sys/unix"
)

// DetachAttr creates the proper syscall attributes to run the managed processes.
// The RequiredPrivileges are translated into Linux-specific ambient capabilities.
func DetachAttr(uid, gid int, privs RequiredPrivileges) *syscall.SysProcAttr {
var creds *syscall.Credential

if os.Geteuid() == 0 {
creds = &syscall.Credential{
Uid: uint32(uid),
Gid: uint32(gid),
}
}

var ambientCaps []uintptr
if privs.BindsPrivilegedPorts {
ambientCaps = []uintptr{unix.CAP_NET_BIND_SERVICE}
}

return &syscall.SysProcAttr{
Setpgid: true,
Pgid: 0,
Credential: creds,
AmbientCaps: ambientCaps,
}
}
8 changes: 5 additions & 3 deletions pkg/supervisor/detachattr_unix.go
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
//go:build unix
//go:build unix && !linux

// SPDX-FileCopyrightText: 2020 k0s authors
// SPDX-License-Identifier: Apache-2.0
Expand All @@ -10,8 +10,10 @@ import (
"syscall"
)

// DetachAttr creates the proper syscall attributes to run the managed processes
func DetachAttr(uid, gid int) *syscall.SysProcAttr {
// DetachAttr creates the proper syscall attributes to run the managed processes.
// On non-Linux Unix systems, RequiredPrivileges are not translated as ambient
// capabilities are not supported.
func DetachAttr(uid, gid int, privs RequiredPrivileges) *syscall.SysProcAttr {
var creds *syscall.Credential

if os.Geteuid() == 0 {
Expand Down
7 changes: 4 additions & 3 deletions pkg/supervisor/detachattr_windows.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,11 @@ package supervisor

import "syscall"

// Creates the proper syscall attributes to run the managed processes. Puts
// processes into their own process group, so that Ctrl+Break events will only
// DetachAttr creates the proper syscall attributes to run the managed processes.
// Puts processes into their own process group, so that Ctrl+Break events will only
// affect the spawned processes, but not k0s itself.
func DetachAttr(int, int) *syscall.SysProcAttr {
// The RequiredPrivileges parameter is ignored on Windows as it's a Linux-specific feature.
func DetachAttr(uid, gid int, privs RequiredPrivileges) *syscall.SysProcAttr {
return &syscall.SysProcAttr{
CreationFlags: syscall.CREATE_NEW_PROCESS_GROUP,
}
Expand Down
12 changes: 11 additions & 1 deletion pkg/supervisor/supervisor.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,14 @@ import (
"github.com/k0sproject/k0s/pkg/constant"
)

// RequiredPrivileges encodes the intent of required privileges for a supervised process
// in a platform-agnostic way. Platform-specific implementations translate these into
// the appropriate mechanisms (e.g., ambient capabilities on Linux).
type RequiredPrivileges struct {
// BindsPrivilegedPorts indicates that the process needs to bind to ports < 1024
BindsPrivilegedPorts bool
}

// Supervisor is dead simple and stupid process supervisor, just tries to keep the process running in a while-true loop
type Supervisor struct {
Name string
Expand All @@ -42,6 +50,8 @@ type Supervisor struct {
KeepEnvPrefix bool
// A function to clean some leftovers before starting or restarting the supervised process
CleanBeforeFn func() error
// Required privileges for the supervised process
RequiredPrivileges RequiredPrivileges

cmd *exec.Cmd
log logrus.FieldLogger
Expand Down Expand Up @@ -196,7 +206,7 @@ func (s *Supervisor) Supervise(ctx context.Context) error {

// detach from the process group so children don't
// get signals sent directly to parent.
s.cmd.SysProcAttr = DetachAttr(s.UID, s.GID)
s.cmd.SysProcAttr = DetachAttr(s.UID, s.GID, s.RequiredPrivileges)

const maxLogChunkLen = 16 * 1024
s.cmd.Stdout = log.NewWriter(s.log.WithField("stream", "stdout"), logrus.InfoLevel, maxLogChunkLen)
Expand Down
Loading