Skip to content

Commit afbaa07

Browse files
committed
Pass CAP_NET_BIND_SERVICE to kube-apiserver
When the Kubernetes API server is configured to listen on a privileged port (< 1024), k0s now automatically grants the CAP_NET_BIND_SERVICE Linux capabilibty to the kube-apiserver process. This allows the non-root process to bind to ports like 443 without requiring full root privileges. Signed-off-by: Vladislav Kuzmin <vladiskuz@gmail.com>
1 parent 5b7012f commit afbaa07

File tree

8 files changed

+278
-14
lines changed

8 files changed

+278
-14
lines changed

docs/configuration.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -131,7 +131,7 @@ spec:
131131
| `ca.certificatesExpireAfter` | The expiration duration of the server certificate (default: 8760h) |
132132
| `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) |
133133
| `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) |
134-
| `port`¹ | Custom port for the Kubernetes API server to listen on (default: 6443) |
134+
| `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. |
135135
| `k0sApiPort`¹ | Custom port for k0s API server to listen on (default: 9443) |
136136
137137
¹ If `port` and `k0sApiPort` are used with the `externalAddress` element, the load balancer serving at `externalAddress` must listen on the same ports.
Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
1+
//go:build linux
2+
3+
// SPDX-FileCopyrightText: 2026 k0s authors
4+
// SPDX-License-Identifier: Apache-2.0
5+
6+
package privilegedport
7+
8+
import (
9+
"bytes"
10+
"context"
11+
"fmt"
12+
"html/template"
13+
"strconv"
14+
"strings"
15+
"testing"
16+
17+
"github.com/k0sproject/k0s/inttest/common"
18+
"github.com/stretchr/testify/suite"
19+
"golang.org/x/sys/unix"
20+
)
21+
22+
type PrivilegedPortSuite struct {
23+
common.BootlooseSuite
24+
}
25+
26+
const configWithPrivilegedPort = `
27+
apiVersion: k0s.k0sproject.io/v1beta1
28+
kind: ClusterConfig
29+
metadata:
30+
name: k0s
31+
spec:
32+
api:
33+
port: {{ .Port }}
34+
`
35+
36+
const privilegedPort = 443
37+
38+
func TestPrivilegedPortSuite(t *testing.T) {
39+
s := PrivilegedPortSuite{
40+
common.BootlooseSuite{
41+
ControllerCount: 1,
42+
WorkerCount: 0,
43+
KubeAPIExternalPort: privilegedPort,
44+
},
45+
}
46+
suite.Run(t, &s)
47+
}
48+
49+
func (s *PrivilegedPortSuite) getControllerConfig() string {
50+
data := struct {
51+
Port int
52+
}{
53+
Port: privilegedPort,
54+
}
55+
content := bytes.NewBuffer([]byte{})
56+
s.Require().NoError(template.Must(template.New("k0s.yaml").Parse(configWithPrivilegedPort)).Execute(content, data), "can't execute k0s.yaml template")
57+
return content.String()
58+
}
59+
60+
func (s *PrivilegedPortSuite) TestCapNetBindServiceIsSet() {
61+
ctx := s.Context()
62+
63+
// Setup k0s with privileged port configuration
64+
config := s.getControllerConfig()
65+
s.PutFile(s.ControllerNode(0), "/tmp/k0s.yaml", config)
66+
67+
s.Require().NoError(s.InitController(0, "--config=/tmp/k0s.yaml"))
68+
69+
kc, err := s.KubeClient(s.ControllerNode(0))
70+
s.Require().NoError(err)
71+
72+
s.AssertSomeKubeSystemPods(kc)
73+
74+
// Now verify that kube-apiserver has CAP_NET_BIND_SERVICE
75+
s.Run("kube-apiserver has CAP_NET_BIND_SERVICE", func() {
76+
s.Require().NoError(s.verifyCapability(ctx, s.ControllerNode(0)))
77+
})
78+
}
79+
80+
// verifyCapability checks if the kube-apiserver process has CAP_NET_BIND_SERVICE capability
81+
func (s *PrivilegedPortSuite) verifyCapability(ctx context.Context, node string) error {
82+
ssh, err := s.SSH(ctx, node)
83+
if err != nil {
84+
return fmt.Errorf("failed to SSH to node: %w", err)
85+
}
86+
defer ssh.Disconnect()
87+
88+
// Find the kube-apiserver PID
89+
pid, err := ssh.ExecWithOutput(ctx, "pidof kube-apiserver")
90+
if err != nil {
91+
return fmt.Errorf("failed to find kube-apiserver process: %w", err)
92+
}
93+
pid = strings.TrimSpace(pid)
94+
if pid == "" {
95+
return fmt.Errorf("kube-apiserver process not found")
96+
}
97+
98+
s.T().Logf("Found kube-apiserver with PID: %s", pid)
99+
100+
// Read the capability information from /proc/<pid>/status
101+
// We need to check CapEff (effective capabilities)
102+
capOutput, err := ssh.ExecWithOutput(ctx, fmt.Sprintf("grep CapEff /proc/%s/status", pid))
103+
if err != nil {
104+
return fmt.Errorf("failed to read capabilities: %w", err)
105+
}
106+
107+
s.T().Logf("Capability output: %s", capOutput)
108+
109+
// Parse the capability hex value
110+
// Format is "CapEff:\t0000000000000400" (or similar)
111+
parts := strings.Fields(capOutput)
112+
if len(parts) < 2 {
113+
return fmt.Errorf("unexpected capability format: %s", capOutput)
114+
}
115+
116+
capHex := parts[1]
117+
capValue, err := strconv.ParseUint(capHex, 16, 64)
118+
if err != nil {
119+
return fmt.Errorf("failed to parse capability value %s: %w", capHex, err)
120+
}
121+
122+
// Check if CAP_NET_BIND_SERVICE (bit 10) is set
123+
// unix.CAP_NET_BIND_SERVICE is defined in golang.org/x/sys/unix
124+
capNetBindService := uint64(unix.CAP_NET_BIND_SERVICE)
125+
if capValue&(1<<capNetBindService) == 0 {
126+
return fmt.Errorf("CAP_NET_BIND_SERVICE (bit %d) is not set in capabilities: 0x%x", capNetBindService, capValue)
127+
}
128+
129+
s.T().Logf("CAP_NET_BIND_SERVICE is correctly set (capability value: 0x%x, bit %d is set)", capValue, capNetBindService)
130+
return nil
131+
}

pkg/component/controller/apiserver.go

Lines changed: 25 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -87,9 +87,9 @@ func (a *APIServer) Init(_ context.Context) error {
8787
return err
8888
}
8989

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

163-
a.supervisor = &supervisor.Supervisor{
163+
sup := &supervisor.Supervisor{
164164
Name: kubeAPIComponentName,
165165
BinPath: a.executablePath,
166166
RunDir: a.K0sVars.RunDir,
@@ -169,11 +169,30 @@ func (a *APIServer) Start(ctx context.Context) error {
169169
UID: a.uid,
170170
}
171171

172+
// If the API port is less than 1024, the process needs to bind to a privileged port
173+
if a.ClusterConfig.Spec.API.Port < 1024 {
174+
sup.RequiredPrivileges.BindsPrivilegedPorts = true
175+
logrus.Infof("API port %d is less than 1024, granting privilege to bind to privileged ports", a.ClusterConfig.Spec.API.Port)
176+
}
177+
172178
etcdArgs, err := getEtcdArgs(a.ClusterConfig.Spec.Storage, a.K0sVars)
179+
if err != nil {
180+
return nil, err
181+
}
182+
sup.Args = append(sup.Args, etcdArgs...)
183+
184+
return sup, nil
185+
}
186+
187+
// Run runs kube api
188+
func (a *APIServer) Start(ctx context.Context) error {
189+
logrus.Info("Starting kube-apiserver")
190+
191+
var err error
192+
a.supervisor, err = a.buildSupervisor()
173193
if err != nil {
174194
return err
175195
}
176-
a.supervisor.Args = append(a.supervisor.Args, etcdArgs...)
177196

178197
return a.supervisor.Supervise(ctx)
179198
}

pkg/component/controller/apiserver_test.go

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,3 +109,66 @@ func (a *apiServerSuite) TestGetEtcdArgs() {
109109
require.Contains(result[1], "--etcd-prefix=k0s-tenant-1")
110110
})
111111
}
112+
113+
func (a *apiServerSuite) TestCapNetBindServiceForLowPorts() {
114+
k0sVars := &config.CfgVars{
115+
BinDir: "/var/lib/k0s/bin",
116+
CertRootDir: "/var/lib/k0s/pki",
117+
DataDir: "/var/lib/k0s",
118+
RunDir: "/run/k0s",
119+
}
120+
121+
a.Run("port 443 requires CAP_NET_BIND_SERVICE", func() {
122+
clusterConfig := v1beta1.DefaultClusterConfig()
123+
clusterConfig.Spec.API.Port = 443
124+
125+
apiServer := &APIServer{
126+
ClusterConfig: clusterConfig,
127+
K0sVars: k0sVars,
128+
LogLevel: "1",
129+
executablePath: "/fake/path/kube-apiserver",
130+
}
131+
132+
supervisor, err := apiServer.buildSupervisor()
133+
require := a.Require()
134+
require.NoError(err)
135+
require.True(supervisor.RequiredPrivileges.BindsPrivilegedPorts,
136+
"Port 443 should require CAP_NET_BIND_SERVICE capability")
137+
})
138+
139+
a.Run("port 6443 does not require CAP_NET_BIND_SERVICE", func() {
140+
clusterConfig := v1beta1.DefaultClusterConfig()
141+
clusterConfig.Spec.API.Port = 6443
142+
143+
apiServer := &APIServer{
144+
ClusterConfig: clusterConfig,
145+
K0sVars: k0sVars,
146+
LogLevel: "1",
147+
executablePath: "/fake/path/kube-apiserver",
148+
}
149+
150+
supervisor, err := apiServer.buildSupervisor()
151+
require := a.Require()
152+
require.NoError(err)
153+
require.False(supervisor.RequiredPrivileges.BindsPrivilegedPorts,
154+
"Port 6443 should not require CAP_NET_BIND_SERVICE capability")
155+
})
156+
157+
a.Run("port 80 requires CAP_NET_BIND_SERVICE", func() {
158+
clusterConfig := v1beta1.DefaultClusterConfig()
159+
clusterConfig.Spec.API.Port = 80
160+
161+
apiServer := &APIServer{
162+
ClusterConfig: clusterConfig,
163+
K0sVars: k0sVars,
164+
LogLevel: "1",
165+
executablePath: "/fake/path/kube-apiserver",
166+
}
167+
168+
supervisor, err := apiServer.buildSupervisor()
169+
require := a.Require()
170+
require.NoError(err)
171+
require.True(supervisor.RequiredPrivileges.BindsPrivilegedPorts,
172+
"Port 80 should require CAP_NET_BIND_SERVICE capability")
173+
})
174+
}

pkg/supervisor/detachattr_linux.go

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
//go:build linux
2+
3+
// SPDX-FileCopyrightText: 2026 k0s authors
4+
// SPDX-License-Identifier: Apache-2.0
5+
6+
package supervisor
7+
8+
import (
9+
"os"
10+
"syscall"
11+
12+
"golang.org/x/sys/unix"
13+
)
14+
15+
// DetachAttr creates the proper syscall attributes to run the managed processes.
16+
// The RequiredPrivileges are translated into Linux-specific ambient capabilities.
17+
func DetachAttr(uid, gid int, privs RequiredPrivileges) *syscall.SysProcAttr {
18+
var creds *syscall.Credential
19+
20+
if os.Geteuid() == 0 {
21+
creds = &syscall.Credential{
22+
Uid: uint32(uid),
23+
Gid: uint32(gid),
24+
}
25+
}
26+
27+
var ambientCaps []uintptr
28+
if privs.BindsPrivilegedPorts {
29+
ambientCaps = []uintptr{unix.CAP_NET_BIND_SERVICE}
30+
}
31+
32+
return &syscall.SysProcAttr{
33+
Setpgid: true,
34+
Pgid: 0,
35+
Credential: creds,
36+
AmbientCaps: ambientCaps,
37+
}
38+
}

pkg/supervisor/detachattr_unix.go

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
//go:build unix
1+
//go:build unix && !linux
22

33
// SPDX-FileCopyrightText: 2020 k0s authors
44
// SPDX-License-Identifier: Apache-2.0
@@ -10,8 +10,10 @@ import (
1010
"syscall"
1111
)
1212

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

1719
if os.Geteuid() == 0 {

pkg/supervisor/detachattr_windows.go

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,11 @@ package supervisor
55

66
import "syscall"
77

8-
// Creates the proper syscall attributes to run the managed processes. Puts
9-
// processes into their own process group, so that Ctrl+Break events will only
8+
// DetachAttr creates the proper syscall attributes to run the managed processes.
9+
// Puts processes into their own process group, so that Ctrl+Break events will only
1010
// affect the spawned processes, but not k0s itself.
11-
func DetachAttr(int, int) *syscall.SysProcAttr {
11+
// The RequiredPrivileges parameter is ignored on Windows as it's a Linux-specific feature.
12+
func DetachAttr(uid, gid int, privs RequiredPrivileges) *syscall.SysProcAttr {
1213
return &syscall.SysProcAttr{
1314
CreationFlags: syscall.CREATE_NEW_PROCESS_GROUP,
1415
}

pkg/supervisor/supervisor.go

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,14 @@ import (
2525
"github.com/k0sproject/k0s/pkg/constant"
2626
)
2727

28+
// RequiredPrivileges encodes the intent of required privileges for a supervised process
29+
// in a platform-agnostic way. Platform-specific implementations translate these into
30+
// the appropriate mechanisms (e.g., ambient capabilities on Linux).
31+
type RequiredPrivileges struct {
32+
// BindsPrivilegedPorts indicates that the process needs to bind to ports < 1024
33+
BindsPrivilegedPorts bool
34+
}
35+
2836
// Supervisor is dead simple and stupid process supervisor, just tries to keep the process running in a while-true loop
2937
type Supervisor struct {
3038
Name string
@@ -42,6 +50,8 @@ type Supervisor struct {
4250
KeepEnvPrefix bool
4351
// A function to clean some leftovers before starting or restarting the supervised process
4452
CleanBeforeFn func() error
53+
// Required privileges for the supervised process
54+
RequiredPrivileges RequiredPrivileges
4555

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

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

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

0 commit comments

Comments
 (0)