From bec88799df3f1f765bac079d2d503696f81c170d Mon Sep 17 00:00:00 2001 From: r3loac Date: Fri, 28 Aug 2026 22:09:51 +0300 Subject: [PATCH 1/4] fix cert Signed-off-by: kerthcet --- cmd/main.go | 37 +++- config/manager/manager.yaml | 21 ++- config/rbac/role.yaml | 8 + docs/deploy.md | 28 +++ docs/kubelet-api.md | 19 +- pkg/vnode/kubelet.go | 35 +++- pkg/vnode/kubelet_certificate.go | 246 ++++++++++++++++++++++++++ pkg/vnode/kubelet_certificate_test.go | 194 ++++++++++++++++++++ 8 files changed, 566 insertions(+), 22 deletions(-) create mode 100644 pkg/vnode/kubelet_certificate.go create mode 100644 pkg/vnode/kubelet_certificate_test.go diff --git a/cmd/main.go b/cmd/main.go index 5e4b9bc..c044b34 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -95,6 +95,7 @@ func main() { var enableHTTP2 bool var kubeletAddr, kubeletClientCA string var costLabels string + var kubeletServingTLSBootstrap bool var tlsOpts []func(*tls.Config) flag.StringVar(&metricsAddr, "metrics-bind-address", "0", "The address the metrics endpoint binds to. "+ "Use :8443 for HTTPS or :8080 for HTTP, or leave as 0 to disable the metrics service.") @@ -129,6 +130,10 @@ func main() { "by candidate shape only. Changing this changes the identity of every cost series. Values "+ "come from Pod labels and are NOT capped: the manager warns once if they push the cost "+ "metric past 5000 series, but pick keys an admission policy constrains.") + flag.BoolVar(&kubeletServingTLSBootstrap, "kubelet-serving-tls-bootstrap", true, + "Request a serving certificate for the manager Pod IP through the "+ + "kubernetes.io/kubelet-serving CSR signer. The self-signed certificate remains active "+ + "until an external approver approves the CSR.") opts := zap.Options{ Development: true, } @@ -308,7 +313,7 @@ func main() { // The kubelet endpoint for `kubectl logs` — one listener shared by every provider's // node, hence built here rather than in setupVirtualNodes. Nil is supported: the // nodes then advertise no address, and logs report NotFound. - kubeletSrv := setupKubeletServer(mgr, kubeletAddr, kubeletClientCA) + kubeletSrv := setupKubeletServer(mgr, kubeletAddr, kubeletClientCA, kubeletServingTLSBootstrap) // Controller and webhook registration is deferred until the cert exists, so it // runs in a goroutine: the cert cannot be minted until the manager is STARTED @@ -465,7 +470,9 @@ func setupControllers(mgr ctrl.Manager, blocklist *failover.Blocklist, kubeletSr // what the API server dials and nothing substitutes for it: a Service would balance to // a non-leader replica, which holds no tracked Pods. Either way only logs degrade, so // it is logged loudly and the manager carries on. -func setupKubeletServer(mgr ctrl.Manager, addr, clientCA string) *vnode.KubeletServer { +// +kubebuilder:rbac:groups=certificates.k8s.io,resources=certificatesigningrequests,verbs=create;delete;get + +func setupKubeletServer(mgr ctrl.Manager, addr, clientCA string, servingTLSBootstrap bool) *vnode.KubeletServer { if addr == "" { setupLog.Info("kubelet API disabled by configuration; `kubectl logs` will not work for Nebula pods") return nil @@ -487,7 +494,31 @@ func setupKubeletServer(mgr ctrl.Manager, addr, clientCA string) *vnode.KubeletS setupLog.Error(err, "unable to add the kubelet API to the manager") return nil } - setupLog.Info("kubelet API enabled", "addr", addr, "advertisedIP", podIP, "clientCertRequired", clientCA != "") + if servingTLSBootstrap { + clientset, err := kubernetes.NewForConfig(mgr.GetConfig()) + if err != nil { + setupLog.Error(err, "failed to create Kubernetes client for kubelet serving certificate bootstrap") + } else { + bootstrapper, err := vnode.NewKubeletServingCertificateBootstrapper( + clientset, + srv, + podIP, + managerNamespace(), + os.Getenv("POD_NAME"), + os.Getenv("POD_UID"), + ) + if err != nil { + setupLog.Error(err, "failed to configure kubelet serving certificate bootstrap") + } else if err := mgr.Add(bootstrapper); err != nil { + setupLog.Error(err, "failed to add kubelet serving certificate bootstrap to the manager") + } + } + } + setupLog.Info("kubelet API enabled", + "addr", addr, + "advertisedIP", podIP, + "clientCertRequired", clientCA != "", + "servingTLSBootstrap", servingTLSBootstrap) return srv } diff --git a/config/manager/manager.yaml b/config/manager/manager.yaml index 0dbfe29..2654265 100644 --- a/config/manager/manager.yaml +++ b/config/manager/manager.yaml @@ -95,6 +95,17 @@ spec: valueFrom: fieldRef: fieldPath: status.podIP + # Identity used to give the kubelet-serving CSR a stable name for this + # exact Pod. The private key remains in memory; a recreated Pod gets a + # new UID and a separate request. + - name: POD_NAME + valueFrom: + fieldRef: + fieldPath: metadata.name + - name: POD_UID + valueFrom: + fieldRef: + fieldPath: metadata.uid envFrom: # Provider credentials live in a per-provider Secret, one secretRef per # provider — NOT a single shared secret. This matches the "creds-absent → @@ -127,11 +138,11 @@ spec: # kubelet). Declaring it is documentation and NetworkPolicy surface; the # listener binds either way. # - # It serves TLS with a self-signed cert but does NOT verify client certs by - # default, because which CA signs the API server's kubelet client cert is not - # portable — requiring it would break logs on managed control planes. So - # anything able to reach this port can read any Nebula pod's logs: restrict it - # with a NetworkPolicy, or set --kubelet-client-ca to require mTLS. + # It starts with a self-signed cert, then requests a kubelet-serving cert for + # POD_IP. Managed control planes that verify kubelet certificates use the + # signed cert after an external approver approves its CSR. Client certs are + # still not verified by default: restrict this port with a NetworkPolicy, or + # set --kubelet-client-ca to require mTLS. - name: kubelet-api containerPort: 10250 protocol: TCP diff --git a/config/rbac/role.yaml b/config/rbac/role.yaml index 6c6b589..dd0d884 100644 --- a/config/rbac/role.yaml +++ b/config/rbac/role.yaml @@ -61,6 +61,14 @@ rules: - list - update - watch +- apiGroups: + - certificates.k8s.io + resources: + - certificatesigningrequests + verbs: + - create + - delete + - get - apiGroups: - coordination.k8s.io resources: diff --git a/docs/deploy.md b/docs/deploy.md index 8d2d5ee..144c34b 100644 --- a/docs/deploy.md +++ b/docs/deploy.md @@ -130,6 +130,7 @@ Manager flags worth knowing (edit `config/manager/manager.yaml` `args`): | Flag | Default | Meaning | |---|---|---| | `--kubelet-bind-address` | `:10250` | Where the kubelet log endpoint listens — the address the API server proxies `kubectl logs` to. Set it empty to disable the endpoint, which disables logs and nothing else. | +| `--kubelet-serving-tls-bootstrap` | `true` | Request a certificate for the advertised Pod IP from the `kubernetes.io/kubelet-serving` signer. Until it is approved and issued, the endpoint retains its self-signed fallback. Disable this only when the API server does not verify kubelet serving certificates. | | `--kubelet-client-ca` | *(empty)* | PEM bundle of CAs whose client certificates are accepted on that port. **Empty means client certificates are not verified**, so anything able to reach port 10250 can read the logs of any Pod on Nebula's virtual nodes. Set it to your API server's kubelet client CA to require mTLS, or keep the port closed with a NetworkPolicy. The default is open because which CA signs that client cert is not portable — kubeadm uses the cluster CA, EKS/GKE their own — so requiring it by default would break logs on managed control planes. | The endpoint needs `POD_IP` (projected via `fieldRef` in `config/manager/manager.yaml`) @@ -137,6 +138,29 @@ because virtual nodes advertise the leader's Pod IP, not a Service. Running the off-cluster leaves it unset, and logs degrade to unsupported. See [kubelet-api.md](kubelet-api.md). +The Kubernetes signer does not approve kubelet-serving requests itself. On a cluster +without a dedicated approver, inspect and approve Nebula's request after each manager +Pod recreation and certificate renewal: + +```bash +CSR=$(kubectl get csr \ + -l app.kubernetes.io/name=nebula,app.kubernetes.io/component=kubelet-serving-certificate \ + --sort-by=.metadata.creationTimestamp -o name | tail -n1) + +# Confirm the requested IP SAN matches the manager Pod IP before approving it. +kubectl get csr "$CSR" -o jsonpath='{.spec.request}' \ + | openssl base64 -d -A | openssl req -text -noout +kubectl -n nebula-system get pod -l control-plane=controller-manager -o wide + +kubectl certificate approve "$CSR" +kubectl -n nebula-system logs deploy/nebula-controller-manager \ + | grep 'installed trusted kubelet serving certificate' +``` + +An installation with an external CSR approver should restrict it to requests that +match Nebula's ServiceAccount, `system:nodes` organization, manager Pod identity, and +current Pod IP. Nebula intentionally receives no permission to approve certificates. + --- ## Modal Environments @@ -206,6 +230,10 @@ kubectl -n nebula-system logs deploy/nebula-controller-manager | grep -i provide # Virtual nodes exist, one per registered provider. kubectl get nodes -l nebula.inftyai.com/provider +# Kubelet serving CSR is signed (required by control planes that verify kubelet TLS). +kubectl get csr \ + -l app.kubernetes.io/name=nebula,app.kubernetes.io/component=kubelet-serving-certificate + # Webhook TLS is wired: the caBundle matches the serving cert Secret. diff <(kubectl get secret nebula-webhook-server-cert -n nebula-system -o jsonpath='{.data.tls\.crt}') \ <(kubectl get mutatingwebhookconfiguration nebula-mutating-webhook-configuration \ diff --git a/docs/kubelet-api.md b/docs/kubelet-api.md index 8c00424..20b46ba 100644 --- a/docs/kubelet-api.md +++ b/docs/kubelet-api.md @@ -23,13 +23,18 @@ Pod IP and that port. Consequences worth knowing: - The endpoint is **leader-scoped and dialed by Pod IP**, not through a Service. The tracked Pods live in one process's memory, so a Service balancing across replicas would send requests to a replica that answers `NotFound`. -- It serves TLS with a self-signed, in-memory certificate — what the API server - expects of a kubelet, which does not verify it unless - `--kubelet-certificate-authority` is set. Client certificates are **not** verified - by default, because which CA signs the API server's kubelet client cert is not - portable across distributions. Anything that can reach the port can therefore read the - logs of, and **run commands in**, any Pod on these virtual nodes, with no RBAC check: - keep it closed with a NetworkPolicy, or pass `--kubelet-client-ca` to require mTLS. +- It starts with a self-signed, in-memory certificate and, by default, creates a + `kubernetes.io/kubelet-serving` CSR whose IP SAN is the advertised Pod IP. This is + required by control planes such as EKS that verify kubelet serving certificates. + The built-in signer requires an external approval decision; once the certificate is + issued, new TLS handshakes use it immediately without restarting the manager. See + [deploy.md](deploy.md#configuration) for approval and inspection commands. +- Client certificates are **not** verified by default, because which CA signs the API + server's kubelet client cert is not portable across distributions. Serving-certificate + bootstrap secures the opposite direction and does not change that. Anything that can + reach the port can therefore read logs and **run commands in** any Pod on these virtual + nodes with no RBAC check: keep it closed with a NetworkPolicy, or pass + `--kubelet-client-ca` to require mTLS. - No POD_IP (running the manager off-cluster) means no endpoint. Logs and exec degrade to unsupported; nothing else is affected. diff --git a/pkg/vnode/kubelet.go b/pkg/vnode/kubelet.go index ebec767..8c720bd 100644 --- a/pkg/vnode/kubelet.go +++ b/pkg/vnode/kubelet.go @@ -33,6 +33,7 @@ import ( "os" "strconv" "sync" + "sync/atomic" "time" "github.com/virtual-kubelet/virtual-kubelet/errdefs" @@ -81,10 +82,9 @@ const ( // is resolved by asking each registered Handler whether it tracks that Pod — at most one // can. Cheaper than a port per provider, and than reading the Pod to learn its node. // -// TLS uses a self-signed in-memory cert, which is what the API server expects: it does -// not verify a kubelet's serving cert unless --kubelet-certificate-authority is set. The -// webhook cert rotator cannot help, since it mints for a Service DNS name and this -// endpoint is dialed by Pod IP. +// TLS starts with a self-signed in-memory cert. Clusters that verify kubelet serving +// certificates can replace it at runtime with a certificate issued through the +// kubernetes.io/kubelet-serving signer; see KubeletServingCertificateBootstrapper. // // Client certs are verified only when ClientCAPath is set. Off by default because which CA // signs the API server's kubelet client cert is not portable (kubeadm uses the cluster CA, @@ -105,6 +105,10 @@ type KubeletServer struct { // others are refused at the TLS layer. Empty disables verification — see above. clientCAPath string + // servingCert is read on every TLS handshake, so an approved kubelet-serving + // certificate takes effect without restarting this listener or dropping streams. + servingCert atomic.Pointer[tls.Certificate] + mu sync.RWMutex handlers map[string]*Handler } @@ -150,6 +154,12 @@ func (s *KubeletServer) Register(nodeName string, h *Handler) { s.handlers[nodeName] = h } +// SetServingCertificate atomically replaces the certificate used for new TLS +// handshakes. Existing log and exec streams keep their current connections. +func (s *KubeletServer) SetServingCertificate(cert tls.Certificate) { + s.servingCert.Store(&cert) +} + // nodeAddress is what a node advertises so the API server can find this endpoint. // InternalIP ONLY, which is load-bearing: --kubelet-preferred-address-types tries // Hostname first, so also advertising one would have the API server try to resolve @@ -260,15 +270,26 @@ func (s *KubeletServer) runInContainer( return h.RunInContainer(ctx, namespace, podName, containerName, cmd, attach) } -// tlsConfig: a fresh self-signed keypair, plus client verification if a CA is set. +// tlsConfig installs a self-signed fallback and reads servingCert on every handshake, +// allowing TLS bootstrap to replace it without restarting the server. Client +// verification is added independently when a CA is configured. func (s *KubeletServer) tlsConfig() (*tls.Config, error) { cert, err := selfSignedCert(s.nodeIP) if err != nil { return nil, err } + if s.servingCert.Load() == nil { + s.SetServingCertificate(cert) + } cfg := &tls.Config{ - Certificates: []tls.Certificate{cert}, - MinVersion: tls.VersionTLS12, + GetCertificate: func(*tls.ClientHelloInfo) (*tls.Certificate, error) { + cert := s.servingCert.Load() + if cert == nil { + return nil, errors.New("kubelet api: no serving certificate") + } + return cert, nil + }, + MinVersion: tls.VersionTLS12, // http/1.1 only, like a real kubelet: logs need nothing HTTP/2 offers, and this is // the streaming path every kubelet client already exercises. NextProtos: []string{"http/1.1"}, diff --git a/pkg/vnode/kubelet_certificate.go b/pkg/vnode/kubelet_certificate.go new file mode 100644 index 0000000..32eed1b --- /dev/null +++ b/pkg/vnode/kubelet_certificate.go @@ -0,0 +1,246 @@ +/* +Copyright 2026 The InftyAI Team. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package vnode + +import ( + "context" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/sha256" + "crypto/tls" + "crypto/x509" + "crypto/x509/pkix" + "encoding/hex" + "encoding/pem" + "errors" + "fmt" + "net" + "time" + + certificatesv1 "k8s.io/api/certificates/v1" + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/client-go/kubernetes" + certificatesclientv1 "k8s.io/client-go/kubernetes/typed/certificates/v1" + logf "sigs.k8s.io/controller-runtime/pkg/log" + "sigs.k8s.io/controller-runtime/pkg/manager" +) + +const ( + kubeletServingCertificateLifetime = 30 * 24 * time.Hour + kubeletServingRenewBefore = 24 * time.Hour + kubeletServingRetryInterval = 30 * time.Second + kubeletServingPollInterval = 2 * time.Second +) + +type KubeletServingCertificateBootstrapper struct { + client certificatesclientv1.CertificateSigningRequestInterface + server *KubeletServer + nodeIP string + podName string + podNamespace string + csrName string + pollInterval time.Duration + retryInterval time.Duration +} + +var _ manager.Runnable = (*KubeletServingCertificateBootstrapper)(nil) + +func NewKubeletServingCertificateBootstrapper( + client kubernetes.Interface, + server *KubeletServer, + nodeIP, podNamespace, podName, podUID string, +) (*KubeletServingCertificateBootstrapper, error) { + if client == nil { + return nil, errors.New("kubelet serving certificate: Kubernetes client is required") + } + if server == nil { + return nil, errors.New("kubelet serving certificate: kubelet server is required") + } + if net.ParseIP(nodeIP) == nil { + return nil, fmt.Errorf("kubelet serving certificate: node IP %q is invalid", nodeIP) + } + if podName == "" || podNamespace == "" || podUID == "" { + return nil, errors.New("kubelet serving certificate: POD_NAME, POD_NAMESPACE and POD_UID are required") + } + + sum := sha256.Sum256([]byte(podUID)) + return &KubeletServingCertificateBootstrapper{ + client: client.CertificatesV1().CertificateSigningRequests(), + server: server, + nodeIP: nodeIP, + podName: podName, + podNamespace: podNamespace, + csrName: "nebula-kubelet-serving-" + hex.EncodeToString(sum[:12]), + pollInterval: kubeletServingPollInterval, + retryInterval: kubeletServingRetryInterval, + }, nil +} + +func (b *KubeletServingCertificateBootstrapper) Start(ctx context.Context) error { + log := logf.FromContext(ctx).WithName("kubelet-serving-certificate") + for { + notAfter, err := b.requestAndWait(ctx) + if err != nil { + if ctx.Err() != nil { + return nil + } + log.Error(err, "serving certificate bootstrap failed; retaining the current certificate", + "retryAfter", b.retryInterval) + if !waitForContext(ctx, b.retryInterval) { + return nil + } + continue + } + + renewIn := time.Until(notAfter.Add(-kubeletServingRenewBefore)) + if renewIn < time.Minute { + renewIn = time.Minute + } + log.Info("installed trusted kubelet serving certificate", "expires", notAfter, "renewIn", renewIn) + if !waitForContext(ctx, renewIn) { + return nil + } + } +} + +func (b *KubeletServingCertificateBootstrapper) requestAndWait(ctx context.Context) (time.Time, error) { + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + return time.Time{}, fmt.Errorf("generate private key: %w", err) + } + requestPEM, keyPEM, err := servingCertificateRequest(b.nodeIP, b.podName, key) + if err != nil { + return time.Time{}, err + } + + if err := b.client.Delete(ctx, b.csrName, metav1.DeleteOptions{}); err != nil && !apierrors.IsNotFound(err) { + return time.Time{}, fmt.Errorf("delete stale CSR %s: %w", b.csrName, err) + } + expirationSeconds := int32(kubeletServingCertificateLifetime / time.Second) + csr, err := b.client.Create(ctx, &certificatesv1.CertificateSigningRequest{ + ObjectMeta: metav1.ObjectMeta{ + Name: b.csrName, + Labels: map[string]string{ + "app.kubernetes.io/name": "nebula", + "app.kubernetes.io/component": "kubelet-serving-certificate", + }, + Annotations: map[string]string{ + "nebula.inftyai.com/pod-name": b.podName, + "nebula.inftyai.com/pod-namespace": b.podNamespace, + }, + }, + Spec: certificatesv1.CertificateSigningRequestSpec{ + Request: requestPEM, + SignerName: certificatesv1.KubeletServingSignerName, + ExpirationSeconds: &expirationSeconds, + Usages: []certificatesv1.KeyUsage{ + certificatesv1.UsageDigitalSignature, + certificatesv1.UsageServerAuth, + }, + }, + }, metav1.CreateOptions{}) + if err != nil { + return time.Time{}, fmt.Errorf("create CSR %s: %w", b.csrName, err) + } + + log := logf.FromContext(ctx).WithName("kubelet-serving-certificate") + log.Info("waiting for kubelet serving certificate approval", + "csr", csr.Name, + "approveCommand", "kubectl certificate approve "+csr.Name, + "podIP", b.nodeIP) + + ticker := time.NewTicker(b.pollInterval) + defer ticker.Stop() + for { + current, err := b.client.Get(ctx, b.csrName, metav1.GetOptions{}) + if err != nil { + return time.Time{}, fmt.Errorf("get CSR %s: %w", b.csrName, err) + } + for _, condition := range current.Status.Conditions { + if condition.Type == certificatesv1.CertificateDenied || condition.Type == certificatesv1.CertificateFailed { + return time.Time{}, fmt.Errorf("CSR %s ended with %s: %s", b.csrName, condition.Type, condition.Message) + } + } + if len(current.Status.Certificate) > 0 { + cert, notAfter, err := servingCertificate(current.Status.Certificate, keyPEM, b.nodeIP) + if err != nil { + return time.Time{}, fmt.Errorf("load certificate from CSR %s: %w", b.csrName, err) + } + b.server.SetServingCertificate(cert) + return notAfter, nil + } + + select { + case <-ctx.Done(): + return time.Time{}, ctx.Err() + case <-ticker.C: + } + } +} + +func servingCertificateRequest(nodeIP, podName string, key *ecdsa.PrivateKey) ([]byte, []byte, error) { + template := &x509.CertificateRequest{ + Subject: pkix.Name{ + CommonName: "system:node:" + podName, + Organization: []string{"system:nodes"}, + }, + IPAddresses: []net.IP{net.ParseIP(nodeIP)}, + } + der, err := x509.CreateCertificateRequest(rand.Reader, template, key) + if err != nil { + return nil, nil, fmt.Errorf("create serving certificate request: %w", err) + } + keyDER, err := x509.MarshalECPrivateKey(key) + if err != nil { + return nil, nil, fmt.Errorf("marshal serving certificate key: %w", err) + } + return pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE REQUEST", Bytes: der}), + pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: keyDER}), nil +} + +func servingCertificate(certPEM, keyPEM []byte, nodeIP string) (tls.Certificate, time.Time, error) { + pair, err := tls.X509KeyPair(certPEM, keyPEM) + if err != nil { + return tls.Certificate{}, time.Time{}, err + } + leaf, err := x509.ParseCertificate(pair.Certificate[0]) + if err != nil { + return tls.Certificate{}, time.Time{}, fmt.Errorf("parse leaf certificate: %w", err) + } + if err := leaf.VerifyHostname(nodeIP); err != nil { + return tls.Certificate{}, time.Time{}, fmt.Errorf("certificate does not cover advertised IP %s: %w", nodeIP, err) + } + now := time.Now() + if now.Before(leaf.NotBefore) || !now.Before(leaf.NotAfter) { + return tls.Certificate{}, time.Time{}, fmt.Errorf("certificate validity is %s to %s", leaf.NotBefore, leaf.NotAfter) + } + pair.Leaf = leaf + return pair, leaf.NotAfter, nil +} + +func waitForContext(ctx context.Context, duration time.Duration) bool { + timer := time.NewTimer(duration) + defer timer.Stop() + select { + case <-ctx.Done(): + return false + case <-timer.C: + return true + } +} diff --git a/pkg/vnode/kubelet_certificate_test.go b/pkg/vnode/kubelet_certificate_test.go new file mode 100644 index 0000000..3575439 --- /dev/null +++ b/pkg/vnode/kubelet_certificate_test.go @@ -0,0 +1,194 @@ +/* +Copyright 2026 The InftyAI Team. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package vnode + +import ( + "context" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/x509" + "crypto/x509/pkix" + "encoding/pem" + "math/big" + "net" + "testing" + "time" + + certificatesv1 "k8s.io/api/certificates/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/client-go/kubernetes/fake" +) + +func TestKubeletServingCertificateBootstrapperInstallsIssuedCertificate(t *testing.T) { + client := fake.NewSimpleClientset() + server, err := NewKubeletServer("10.20.18.154", ":10250", "") + if err != nil { + t.Fatalf("NewKubeletServer: %v", err) + } + tlsConfig, err := server.tlsConfig() + if err != nil { + t.Fatalf("tlsConfig: %v", err) + } + fallback, err := tlsConfig.GetCertificate(nil) + if err != nil { + t.Fatalf("get fallback certificate: %v", err) + } + fallbackLeaf, err := x509.ParseCertificate(fallback.Certificate[0]) + if err != nil { + t.Fatalf("parse fallback certificate: %v", err) + } + + bootstrapper, err := NewKubeletServingCertificateBootstrapper( + client, + server, + "10.20.18.154", + "nebula-system", + "nebula-controller-manager-abc", + "3d18b85e-43aa-4ed6-b5e0-38fd04d93241", + ) + if err != nil { + t.Fatalf("NewKubeletServingCertificateBootstrapper: %v", err) + } + bootstrapper.pollInterval = 5 * time.Millisecond + bootstrapper.retryInterval = 5 * time.Millisecond + + ctx, cancel := context.WithCancel(context.Background()) + errCh := make(chan error, 1) + go func() { errCh <- bootstrapper.Start(ctx) }() + t.Cleanup(func() { + cancel() + select { + case err := <-errCh: + if err != nil { + t.Errorf("bootstrapper Start: %v", err) + } + case <-time.After(time.Second): + t.Error("bootstrapper did not stop") + } + }) + + var csr *certificatesv1.CertificateSigningRequest + waitFor(t, func() bool { + csr, err = client.CertificatesV1().CertificateSigningRequests().Get( + context.Background(), bootstrapper.csrName, metav1.GetOptions{}, + ) + return err == nil + }, "kubelet-serving CSR") + + if csr.Spec.SignerName != certificatesv1.KubeletServingSignerName { + t.Fatalf("signer = %q, want %q", csr.Spec.SignerName, certificatesv1.KubeletServingSignerName) + } + request := parseCertificateRequest(t, csr.Spec.Request) + if request.Subject.CommonName != "system:node:nebula-controller-manager-abc" { + t.Fatalf("common name = %q", request.Subject.CommonName) + } + if len(request.Subject.Organization) != 1 || request.Subject.Organization[0] != "system:nodes" { + t.Fatalf("organization = %v, want [system:nodes]", request.Subject.Organization) + } + if len(request.IPAddresses) != 1 || !request.IPAddresses[0].Equal(net.ParseIP("10.20.18.154")) { + t.Fatalf("IP SANs = %v, want [10.20.18.154]", request.IPAddresses) + } + + csr.Status.Conditions = append(csr.Status.Conditions, certificatesv1.CertificateSigningRequestCondition{ + Type: certificatesv1.CertificateApproved, + Status: "True", + Reason: "TestApproved", + }) + csr.Status.Certificate = issueTestServingCertificate(t, request) + if _, err := client.CertificatesV1().CertificateSigningRequests().UpdateStatus( + context.Background(), csr, metav1.UpdateOptions{}, + ); err != nil { + t.Fatalf("issue certificate: %v", err) + } + + waitFor(t, func() bool { + current, getErr := tlsConfig.GetCertificate(nil) + return getErr == nil && current.Leaf != nil && current.Leaf.SerialNumber.Cmp(fallbackLeaf.SerialNumber) != 0 + }, "issued certificate installation") + current, err := tlsConfig.GetCertificate(nil) + if err != nil { + t.Fatalf("get installed certificate: %v", err) + } + if err := current.Leaf.VerifyHostname("10.20.18.154"); err != nil { + t.Fatalf("installed certificate does not cover advertised IP: %v", err) + } +} + +func TestServingCertificateRejectsWrongIP(t *testing.T) { + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + t.Fatalf("generate key: %v", err) + } + requestPEM, keyPEM, err := servingCertificateRequest("10.20.18.155", "manager", key) + if err != nil { + t.Fatalf("servingCertificateRequest: %v", err) + } + certificatePEM := issueTestServingCertificate(t, parseCertificateRequest(t, requestPEM)) + if _, _, err := servingCertificate(certificatePEM, keyPEM, "10.20.18.154"); err == nil { + t.Fatal("expected the certificate with the wrong IP SAN to be rejected") + } +} + +func parseCertificateRequest(t *testing.T, requestPEM []byte) *x509.CertificateRequest { + t.Helper() + block, _ := pem.Decode(requestPEM) + if block == nil { + t.Fatal("CSR is not PEM") + } + request, err := x509.ParseCertificateRequest(block.Bytes) + if err != nil { + t.Fatalf("parse CSR: %v", err) + } + if err := request.CheckSignature(); err != nil { + t.Fatalf("CSR signature: %v", err) + } + return request +} + +func issueTestServingCertificate(t *testing.T, request *x509.CertificateRequest) []byte { + t.Helper() + caKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + t.Fatalf("generate CA key: %v", err) + } + now := time.Now() + ca := &x509.Certificate{ + SerialNumber: big.NewInt(1), + Subject: pkix.Name{CommonName: "test kubelet CA"}, + NotBefore: now.Add(-time.Minute), + NotAfter: now.Add(72 * time.Hour), + KeyUsage: x509.KeyUsageCertSign, + BasicConstraintsValid: true, + IsCA: true, + } + leaf := &x509.Certificate{ + SerialNumber: big.NewInt(2), + Subject: request.Subject, + NotBefore: now.Add(-time.Minute), + NotAfter: now.Add(48 * time.Hour), + KeyUsage: x509.KeyUsageDigitalSignature, + ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, + IPAddresses: request.IPAddresses, + DNSNames: request.DNSNames, + } + der, err := x509.CreateCertificate(rand.Reader, leaf, ca, request.PublicKey, caKey) + if err != nil { + t.Fatalf("issue serving certificate: %v", err) + } + return pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der}) +} From 5d4fa9aaa0b44ec7ad8acb46eec21d9ea10c65bc Mon Sep 17 00:00:00 2001 From: kerthcet Date: Sat, 12 Sep 2026 12:47:56 +0100 Subject: [PATCH 2/4] fix cert Signed-off-by: kerthcet --- README.md | 15 +-- cmd/main.go | 90 ++++++++++--- config/manager/manager.yaml | 21 ++- config/rbac/role.yaml | 45 +++++++ docs/README.md | 8 ++ docs/deploy.md | 36 +++--- docs/kubelet-api.md | 41 +++++- pkg/vnode/kubelet.go | 4 +- pkg/vnode/kubelet_certificate.go | 115 ++++++++++++++--- pkg/vnode/kubelet_certificate_test.go | 176 +++++++++++++++++++++++++- 10 files changed, 453 insertions(+), 98 deletions(-) create mode 100644 docs/README.md diff --git a/README.md b/README.md index d937e9e..6d2d969 100644 --- a/README.md +++ b/README.md @@ -105,22 +105,9 @@ the standard `nvidia.com/gpu` resource limit, so scheduling and provisioning rea the same number. Do not set `nodeName` or a provider `nodeSelector` yourself — the placement controller owns those. -> `kubectl logs` and `kubectl exec` both work on Modal, `-f`/`--tail` and `-it` -> included: the manager serves the two kubelet routes the API server proxies. -> `--timestamps`/`--previous`/`--since` and `-c` are ignored, and a terminal resize is -> not forwarded. On providers that do not support them yet, both answer NotFound. - ## Getting started -- See [docs/deploy.md](docs/deploy.md) to install -- See [config/samples](config/samples) for example NodePools and a runnable workload. -- See [docs/add-a-provider.md](docs/add-a-provider.md) to add a provider backend. -- See [docs/architecture.md](docs/architecture.md) for design details. -- See [docs/status.md](docs/status.md) for how instance lifecycle becomes Pod and - NodeClaim status, per provider. -- See [docs/kubelet-api.md](docs/kubelet-api.md) for how `kubectl logs` and `kubectl exec` - reach a Pod with no kubelet. -- See [docs/metrics.md](docs/metrics.md) for what is instrumented and how to query it. +See [docs](docs/README.md) for an overview of Nebula. ## License diff --git a/cmd/main.go b/cmd/main.go index c044b34..48908e6 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -19,6 +19,7 @@ package main import ( "context" "crypto/tls" + "errors" "flag" "fmt" "os" @@ -43,6 +44,7 @@ import ( corev1 "k8s.io/api/core/v1" "k8s.io/client-go/kubernetes" + "k8s.io/client-go/rest" nebulav1alpha1 "github.com/InftyAI/Nebula/api/v1alpha1" "github.com/InftyAI/Nebula/internal/controller" @@ -130,10 +132,14 @@ func main() { "by candidate shape only. Changing this changes the identity of every cost series. Values "+ "come from Pod labels and are NOT capped: the manager warns once if they push the cost "+ "metric past 5000 series, but pick keys an admission policy constrains.") - flag.BoolVar(&kubeletServingTLSBootstrap, "kubelet-serving-tls-bootstrap", true, + flag.BoolVar(&kubeletServingTLSBootstrap, "kubelet-serving-tls-bootstrap", false, "Request a serving certificate for the manager Pod IP through the "+ - "kubernetes.io/kubelet-serving CSR signer. The self-signed certificate remains active "+ - "until an external approver approves the CSR.") + "kubernetes.io/kubelet-serving CSR signer, and approve it. Required wherever the "+ + "control plane verifies kubelet serving certificates (EKS sets "+ + "--kubelet-certificate-authority), since the self-signed fallback fails there with "+ + "x509: certificate signed by unknown authority. Off by default because it needs the "+ + "RBAC to impersonate one virtual node identity — the signer signs for nobody else "+ + "(see addServingCertificateBootstrap).") opts := zap.Options{ Development: true, } @@ -470,7 +476,6 @@ func setupControllers(mgr ctrl.Manager, blocklist *failover.Blocklist, kubeletSr // what the API server dials and nothing substitutes for it: a Service would balance to // a non-leader replica, which holds no tracked Pods. Either way only logs degrade, so // it is logged loudly and the manager carries on. -// +kubebuilder:rbac:groups=certificates.k8s.io,resources=certificatesigningrequests,verbs=create;delete;get func setupKubeletServer(mgr ctrl.Manager, addr, clientCA string, servingTLSBootstrap bool) *vnode.KubeletServer { if addr == "" { @@ -495,23 +500,10 @@ func setupKubeletServer(mgr ctrl.Manager, addr, clientCA string, servingTLSBoots return nil } if servingTLSBootstrap { - clientset, err := kubernetes.NewForConfig(mgr.GetConfig()) - if err != nil { - setupLog.Error(err, "failed to create Kubernetes client for kubelet serving certificate bootstrap") - } else { - bootstrapper, err := vnode.NewKubeletServingCertificateBootstrapper( - clientset, - srv, - podIP, - managerNamespace(), - os.Getenv("POD_NAME"), - os.Getenv("POD_UID"), - ) - if err != nil { - setupLog.Error(err, "failed to configure kubelet serving certificate bootstrap") - } else if err := mgr.Add(bootstrapper); err != nil { - setupLog.Error(err, "failed to add kubelet serving certificate bootstrap to the manager") - } + if err := addServingCertificateBootstrap(mgr, srv, podIP); err != nil { + setupLog.Error(err, "kubelet serving certificate bootstrap is off; "+ + "the endpoint keeps its self-signed certificate, which a control plane that sets "+ + "--kubelet-certificate-authority (EKS) rejects") } } setupLog.Info("kubelet API enabled", @@ -522,6 +514,62 @@ func setupKubeletServer(mgr ctrl.Manager, addr, clientCA string, servingTLSBoots return srv } +// Detached from the doc comment below: controller-gen ignores an rbac marker inside a +// declaration's doc. A resourceName containing a colon must be QUOTED, or the marker fails to +// parse and takes every other rbac rule in the package with it. +// +// Keep the CSR names in step with vnode.ServingCSRName, and the users with the providers that +// can register. Only `create` cannot be scoped by name. +// +kubebuilder:rbac:groups=certificates.k8s.io,resources=certificatesigningrequests,verbs=create +// +kubebuilder:rbac:groups=certificates.k8s.io,resources=certificatesigningrequests,resourceNames={nebula-kubelet-serving-nebula-aws,nebula-kubelet-serving-nebula-modal,nebula-kubelet-serving-nebula-fake},verbs=delete;get +// +kubebuilder:rbac:groups=certificates.k8s.io,resources=certificatesigningrequests/approval,resourceNames={nebula-kubelet-serving-nebula-aws,nebula-kubelet-serving-nebula-modal,nebula-kubelet-serving-nebula-fake},verbs=update +// +kubebuilder:rbac:groups=certificates.k8s.io,resources=signers,resourceNames=kubernetes.io/kubelet-serving,verbs=approve +// +kubebuilder:rbac:groups="",resources=users,resourceNames={"system:node:nebula-aws","system:node:nebula-modal","system:node:nebula-fake"},verbs=impersonate +// +kubebuilder:rbac:groups="",resources=groups,resourceNames="system:nodes",verbs=impersonate + +// addServingCertificateBootstrap requests a trusted serving certificate for the kubelet +// endpoint, IMPERSONATING a virtual node to do it — see vnode.NodeIdentity for why the signer +// requires that. +// +// WHICH node is arbitrary: they all advertise this one endpoint at this Pod's IP, and the API +// server verifies against the address it dialed. First name, for determinism. +func addServingCertificateBootstrap(mgr ctrl.Manager, srv *vnode.KubeletServer, podIP string) error { + names := provider.Names() + if len(names) == 0 { + return errors.New("no provider registered, so there is no virtual node to request a certificate as") + } + nodeName := vnode.NodeName(names[0]) + + // Impersonation is confined to this client; NewKubeletServingCertificateBootstrapper takes + // the unimpersonated one too, and says which call needs which. + cfg := rest.CopyConfig(mgr.GetConfig()) + cfg.Impersonate = rest.ImpersonationConfig{ + UserName: vnode.NodeIdentity(nodeName), + Groups: []string{"system:nodes"}, + } + nodeClient, err := kubernetes.NewForConfig(cfg) + if err != nil { + return err + } + ownClient, err := kubernetes.NewForConfig(mgr.GetConfig()) + if err != nil { + return err + } + bootstrapper, err := vnode.NewKubeletServingCertificateBootstrapper( + nodeClient, + ownClient, + srv, + podIP, + nodeName, + managerNamespace(), + os.Getenv("POD_NAME"), + ) + if err != nil { + return err + } + return mgr.Add(bootstrapper) +} + // setupVirtualNodes adds a vnode.Runner to the manager for every registered // provider. The Runner needs a typed clientset (the virtual kubelet's node/pod // controllers use client-go directly, not the controller-runtime client), built diff --git a/config/manager/manager.yaml b/config/manager/manager.yaml index 2654265..f51b2e3 100644 --- a/config/manager/manager.yaml +++ b/config/manager/manager.yaml @@ -63,6 +63,7 @@ spec: args: - --leader-elect - --health-probe-bind-address=:8081 + - --kubelet-serving-tls-bootstrap=true image: controller:latest name: manager imagePullPolicy: IfNotPresent @@ -95,17 +96,13 @@ spec: valueFrom: fieldRef: fieldPath: status.podIP - # Identity used to give the kubelet-serving CSR a stable name for this - # exact Pod. The private key remains in memory; a recreated Pod gets a - # new UID and a separate request. + # Recorded as annotations on the kubelet-serving CSR, so an operator looking + # at a stuck request can tell which manager Pod asked for it. The CSR's name + # comes from the node, not from here (see vnode.ServingCSRName). - name: POD_NAME valueFrom: fieldRef: fieldPath: metadata.name - - name: POD_UID - valueFrom: - fieldRef: - fieldPath: metadata.uid envFrom: # Provider credentials live in a per-provider Secret, one secretRef per # provider — NOT a single shared secret. This matches the "creds-absent → @@ -138,11 +135,11 @@ spec: # kubelet). Declaring it is documentation and NetworkPolicy surface; the # listener binds either way. # - # It starts with a self-signed cert, then requests a kubelet-serving cert for - # POD_IP. Managed control planes that verify kubelet certificates use the - # signed cert after an external approver approves its CSR. Client certs are - # still not verified by default: restrict this port with a NetworkPolicy, or - # set --kubelet-client-ca to require mTLS. + # It starts with a self-signed cert, then requests and self-approves a + # kubelet-serving cert for POD_IP. Managed control planes that verify kubelet + # certificates use the signed cert once it is issued. Client certs are still not + # verified by default: restrict this port with a NetworkPolicy, or set + # --kubelet-client-ca to require mTLS. - name: kubelet-api containerPort: 10250 protocol: TCP diff --git a/config/rbac/role.yaml b/config/rbac/role.yaml index dd0d884..33dc22e 100644 --- a/config/rbac/role.yaml +++ b/config/rbac/role.yaml @@ -20,6 +20,14 @@ rules: verbs: - create - patch +- apiGroups: + - "" + resourceNames: + - system:nodes + resources: + - groups + verbs: + - impersonate - apiGroups: - "" resources: @@ -52,6 +60,16 @@ rules: - list - update - watch +- apiGroups: + - "" + resourceNames: + - system:node:nebula-aws + - system:node:nebula-fake + - system:node:nebula-modal + resources: + - users + verbs: + - impersonate - apiGroups: - admissionregistration.k8s.io resources: @@ -67,8 +85,35 @@ rules: - certificatesigningrequests verbs: - create +- apiGroups: + - certificates.k8s.io + resourceNames: + - nebula-kubelet-serving-nebula-aws + - nebula-kubelet-serving-nebula-fake + - nebula-kubelet-serving-nebula-modal + resources: + - certificatesigningrequests + verbs: - delete - get +- apiGroups: + - certificates.k8s.io + resourceNames: + - nebula-kubelet-serving-nebula-aws + - nebula-kubelet-serving-nebula-fake + - nebula-kubelet-serving-nebula-modal + resources: + - certificatesigningrequests/approval + verbs: + - update +- apiGroups: + - certificates.k8s.io + resourceNames: + - kubernetes.io/kubelet-serving + resources: + - signers + verbs: + - approve - apiGroups: - coordination.k8s.io resources: diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 0000000..ce1558c --- /dev/null +++ b/docs/README.md @@ -0,0 +1,8 @@ +# Nebula docs + +- [architecture.md](architecture.md) — the design: scheduling gates, virtual nodes, NodePool and NodeClaim. +- [deploy.md](deploy.md) — installing the manager, provider credentials, manager flags. +- [status.md](status.md) — how an instance's lifecycle becomes Pod and NodeClaim status. +- [kubelet-api.md](kubelet-api.md) — `kubectl logs` and `kubectl exec` against a Pod with no kubelet. +- [metrics.md](metrics.md) — what is instrumented, and how to query it. +- [add-a-provider.md](add-a-provider.md) — adding a provider backend. diff --git a/docs/deploy.md b/docs/deploy.md index 144c34b..b785b62 100644 --- a/docs/deploy.md +++ b/docs/deploy.md @@ -130,7 +130,7 @@ Manager flags worth knowing (edit `config/manager/manager.yaml` `args`): | Flag | Default | Meaning | |---|---|---| | `--kubelet-bind-address` | `:10250` | Where the kubelet log endpoint listens — the address the API server proxies `kubectl logs` to. Set it empty to disable the endpoint, which disables logs and nothing else. | -| `--kubelet-serving-tls-bootstrap` | `true` | Request a certificate for the advertised Pod IP from the `kubernetes.io/kubelet-serving` signer. Until it is approved and issued, the endpoint retains its self-signed fallback. Disable this only when the API server does not verify kubelet serving certificates. | +| `--kubelet-serving-tls-bootstrap` | `false`, but `manager.yaml` ships `true` | Request a certificate for the advertised Pod IP from the `kubernetes.io/kubelet-serving` signer, and approve it. **Required on EKS and any control plane that sets `--kubelet-certificate-authority`**, where the self-signed fallback makes `kubectl exec` fail with `x509: certificate signed by unknown authority`. The flag's own default is off because the feature needs the `impersonate` grant on `users`/`groups` in `config/rbac/role.yaml`; the shipped manifest turns it on. | | `--kubelet-client-ca` | *(empty)* | PEM bundle of CAs whose client certificates are accepted on that port. **Empty means client certificates are not verified**, so anything able to reach port 10250 can read the logs of any Pod on Nebula's virtual nodes. Set it to your API server's kubelet client CA to require mTLS, or keep the port closed with a NetworkPolicy. The default is open because which CA signs that client cert is not portable — kubeadm uses the cluster CA, EKS/GKE their own — so requiring it by default would break logs on managed control planes. | The endpoint needs `POD_IP` (projected via `fieldRef` in `config/manager/manager.yaml`) @@ -138,28 +138,30 @@ because virtual nodes advertise the leader's Pod IP, not a Service. Running the off-cluster leaves it unset, and logs degrade to unsupported. See [kubelet-api.md](kubelet-api.md). -The Kubernetes signer does not approve kubelet-serving requests itself. On a cluster -without a dedicated approver, inspect and approve Nebula's request after each manager -Pod recreation and certificate renewal: +With `--kubelet-serving-tls-bootstrap` enabled the manager submits and approves the request +itself, once at startup and again at each renewal, so there is no manual step. To check it +landed: ```bash -CSR=$(kubectl get csr \ - -l app.kubernetes.io/name=nebula,app.kubernetes.io/component=kubelet-serving-certificate \ - --sort-by=.metadata.creationTimestamp -o name | tail -n1) +# Approved,Issued is the healthy state. "Approved" alone means the signer refused the +# request, which is what happens when the identity it was submitted under is not a node. +kubectl get csr -l app.kubernetes.io/component=kubelet-serving-certificate -# Confirm the requested IP SAN matches the manager Pod IP before approving it. -kubectl get csr "$CSR" -o jsonpath='{.spec.request}' \ - | openssl base64 -d -A | openssl req -text -noout -kubectl -n nebula-system get pod -l control-plane=controller-manager -o wide - -kubectl certificate approve "$CSR" kubectl -n nebula-system logs deploy/nebula-controller-manager \ | grep 'installed trusted kubelet serving certificate' ``` -An installation with an external CSR approver should restrict it to requests that -match Nebula's ServiceAccount, `system:nodes` organization, manager Pod identity, and -current Pod IP. Nebula intentionally receives no permission to approve certificates. +Two identities are involved, and the split is not cosmetic. The CSR is **created** while +impersonating `system:node:nebula-`, because the signer signs for nobody else; +everything else — the stale delete, the polling, the approval — goes out as the manager's +ServiceAccount, because a node identity may create and get its own CSRs and nothing more. A +single-identity version fails on the delete and never creates a CSR at all. + +The request is named `nebula-kubelet-serving-`, one per virtual node for the life of the +cluster, which is what lets `config/rbac/role.yaml` scope delete, get and approval to those +names by `resourceNames`. Only `create` is cluster-wide. An external approver, if you run one, +should match on that node identity, the `system:nodes` organization, and the current manager +Pod IP as the sole IP SAN. --- @@ -230,7 +232,7 @@ kubectl -n nebula-system logs deploy/nebula-controller-manager | grep -i provide # Virtual nodes exist, one per registered provider. kubectl get nodes -l nebula.inftyai.com/provider -# Kubelet serving CSR is signed (required by control planes that verify kubelet TLS). +# Kubelet serving CSR reached Approved,Issued — only with --kubelet-serving-tls-bootstrap. kubectl get csr \ -l app.kubernetes.io/name=nebula,app.kubernetes.io/component=kubelet-serving-certificate diff --git a/docs/kubelet-api.md b/docs/kubelet-api.md index 20b46ba..8a574aa 100644 --- a/docs/kubelet-api.md +++ b/docs/kubelet-api.md @@ -5,6 +5,7 @@ Nebula Pod. It is worth spelling out how, because none of the usual kubelet mach present. - [The transport](#the-transport) +- [The serving certificate](#the-serving-certificate) - [The provider seam](#the-provider-seam) - [What logs honour, and the one heuristic](#what-logs-honour-and-the-one-heuristic) - [Containers are not addressable](#containers-are-not-addressable) @@ -23,12 +24,8 @@ Pod IP and that port. Consequences worth knowing: - The endpoint is **leader-scoped and dialed by Pod IP**, not through a Service. The tracked Pods live in one process's memory, so a Service balancing across replicas would send requests to a replica that answers `NotFound`. -- It starts with a self-signed, in-memory certificate and, by default, creates a - `kubernetes.io/kubelet-serving` CSR whose IP SAN is the advertised Pod IP. This is - required by control planes such as EKS that verify kubelet serving certificates. - The built-in signer requires an external approval decision; once the certificate is - issued, new TLS handshakes use it immediately without restarting the manager. See - [deploy.md](deploy.md#configuration) for approval and inspection commands. +- It serves TLS on a certificate signed by the cluster CA, falling back to a self-signed + one until that is issued — see [The serving certificate](#the-serving-certificate). - Client certificates are **not** verified by default, because which CA signs the API server's kubelet client cert is not portable across distributions. Serving-certificate bootstrap secures the opposite direction and does not change that. Anything that can @@ -38,6 +35,38 @@ Pod IP and that port. Consequences worth knowing: - No POD_IP (running the manager off-cluster) means no endpoint. Logs and exec degrade to unsupported; nothing else is affected. +## The serving certificate + +A managed control plane sets `--kubelet-certificate-authority` (EKS does) and rejects a +self-signed kubelet certificate, so `kubectl exec` fails with `x509: certificate signed by +unknown authority`. `--kubelet-serving-tls-bootstrap` — off in the flag, **on in +`config/manager/manager.yaml`** — requests a real one from the `kubernetes.io/kubelet-serving` +signer, and swaps it in without a restart. Until it lands, the endpoint keeps the self-signed +fallback, so nothing depends on the request succeeding. + +The mechanics that are easy to get wrong: + +- **The requester is a node, and it is checked.** The CSR is created while impersonating + `system:node:nebula-`, with that same name as its CN. The signer signs for the node + that asks and for nobody else, and it reports a mismatch **nowhere** — the CSR sits + `Approved` with no certificate. `Approved,Issued` is the only healthy state. +- **Two identities, not one.** Only the create is impersonated. The manager's own + ServiceAccount does the delete, the polling and the approval, because a node identity may + create and get its own CSRs and nothing more. Requester and approver differing is ordinary: + the signer cares only who asked. +- **One certificate covers every virtual node.** All of them advertise the same address — this + Pod's IP — and the API server verifies against the address it dialed, not the node name. So + one request, under the first registered provider's node name, serves the whole set. +- **One CSR per node, named `nebula-kubelet-serving-`.** Stable rather than generated, so + `config/rbac/role.yaml` can scope delete, get and approval to those names by `resourceNames`; + only `create` is cluster-wide. +- **Renewal is unattended.** 30 days requested, re-requested 24h before expiry with a fresh + ECDSA key that never leaves memory. A failed attempt retains the current certificate and + retries in 30s; a failed *approval* is retried in place, so a transient API error costs a poll + interval rather than the day the CSR cleaner takes to clear an unapproved request. + +Inspection commands are in [deploy.md](deploy.md#configuration). + ## The provider seam Both are optional: a provider opts in by implementing `provider.LogStreamer` and diff --git a/pkg/vnode/kubelet.go b/pkg/vnode/kubelet.go index 8c720bd..343f483 100644 --- a/pkg/vnode/kubelet.go +++ b/pkg/vnode/kubelet.go @@ -278,9 +278,7 @@ func (s *KubeletServer) tlsConfig() (*tls.Config, error) { if err != nil { return nil, err } - if s.servingCert.Load() == nil { - s.SetServingCertificate(cert) - } + s.servingCert.CompareAndSwap(nil, &cert) cfg := &tls.Config{ GetCertificate: func(*tls.ClientHelloInfo) (*tls.Certificate, error) { cert := s.servingCert.Load() diff --git a/pkg/vnode/kubelet_certificate.go b/pkg/vnode/kubelet_certificate.go index 32eed1b..98739c2 100644 --- a/pkg/vnode/kubelet_certificate.go +++ b/pkg/vnode/kubelet_certificate.go @@ -21,11 +21,9 @@ import ( "crypto/ecdsa" "crypto/elliptic" "crypto/rand" - "crypto/sha256" "crypto/tls" "crypto/x509" "crypto/x509/pkix" - "encoding/hex" "encoding/pem" "errors" "fmt" @@ -33,6 +31,7 @@ import ( "time" certificatesv1 "k8s.io/api/certificates/v1" + corev1 "k8s.io/api/core/v1" apierrors "k8s.io/apimachinery/pkg/api/errors" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/client-go/kubernetes" @@ -48,10 +47,29 @@ const ( kubeletServingPollInterval = 2 * time.Second ) +// NodeIdentity is the username the kubernetes.io/kubelet-serving signer expects on a request +// for a node's serving certificate. +// +// One function because two places must agree: the request's CN, and the identity the client +// impersonates to submit it. The signer compares them and ignores a mismatch in silence — the +// CSR stays Approved and unsigned, with no condition to notice. +func NodeIdentity(nodeName string) string { return "system:node:" + nodeName } + +// ServingCSRName is the CSR one virtual node reuses for the life of the cluster. +// +// Derived from the node name and nothing per-process, so RBAC can scope delete, get and +// approval to exactly these names (see the markers in cmd/main.go). Changing the format means +// changing that list too, or the manager loses access to its own CSR. +func ServingCSRName(nodeName string) string { return "nebula-kubelet-serving-" + nodeName } + type KubeletServingCertificateBootstrapper struct { - client certificatesclientv1.CertificateSigningRequestInterface + // nodeClient impersonates the virtual node and CREATES the request; ownClient is the + // manager's own identity and does the rest. See the constructor. + nodeClient certificatesclientv1.CertificateSigningRequestInterface + ownClient certificatesclientv1.CertificateSigningRequestInterface server *KubeletServer nodeIP string + nodeName string podName string podNamespace string csrName string @@ -61,13 +79,23 @@ type KubeletServingCertificateBootstrapper struct { var _ manager.Runnable = (*KubeletServingCertificateBootstrapper)(nil) +// NewKubeletServingCertificateBootstrapper builds the CSR loop for one virtual node, over TWO +// clients because no single identity can do the whole job: +// +// - nodeClient impersonates NodeIdentity(nodeName) and creates the request; the signer +// refuses one submitted by anything else. +// - ownClient is the manager's ServiceAccount and does the rest. A node may create and get +// its own CSRs and nothing more — on EKS it `cannot delete resource +// "certificatesigningrequests"`, and approving is an approver's job anyway. +// +// Requester and approver differing is the ordinary arrangement: the signer checks who ASKED. func NewKubeletServingCertificateBootstrapper( - client kubernetes.Interface, + nodeClient, ownClient kubernetes.Interface, server *KubeletServer, - nodeIP, podNamespace, podName, podUID string, + nodeIP, nodeName, podNamespace, podName string, ) (*KubeletServingCertificateBootstrapper, error) { - if client == nil { - return nil, errors.New("kubelet serving certificate: Kubernetes client is required") + if nodeClient == nil || ownClient == nil { + return nil, errors.New("kubelet serving certificate: both the node and manager clients are required") } if server == nil { return nil, errors.New("kubelet serving certificate: kubelet server is required") @@ -75,18 +103,22 @@ func NewKubeletServingCertificateBootstrapper( if net.ParseIP(nodeIP) == nil { return nil, fmt.Errorf("kubelet serving certificate: node IP %q is invalid", nodeIP) } - if podName == "" || podNamespace == "" || podUID == "" { - return nil, errors.New("kubelet serving certificate: POD_NAME, POD_NAMESPACE and POD_UID are required") + if nodeName == "" { + return nil, errors.New("kubelet serving certificate: a virtual node name is required") + } + if podName == "" || podNamespace == "" { + return nil, errors.New("kubelet serving certificate: POD_NAME and POD_NAMESPACE are required") } - sum := sha256.Sum256([]byte(podUID)) return &KubeletServingCertificateBootstrapper{ - client: client.CertificatesV1().CertificateSigningRequests(), + nodeClient: nodeClient.CertificatesV1().CertificateSigningRequests(), + ownClient: ownClient.CertificatesV1().CertificateSigningRequests(), server: server, nodeIP: nodeIP, + nodeName: nodeName, podName: podName, podNamespace: podNamespace, - csrName: "nebula-kubelet-serving-" + hex.EncodeToString(sum[:12]), + csrName: ServingCSRName(nodeName), pollInterval: kubeletServingPollInterval, retryInterval: kubeletServingRetryInterval, }, nil @@ -124,16 +156,19 @@ func (b *KubeletServingCertificateBootstrapper) requestAndWait(ctx context.Conte if err != nil { return time.Time{}, fmt.Errorf("generate private key: %w", err) } - requestPEM, keyPEM, err := servingCertificateRequest(b.nodeIP, b.podName, key) + requestPEM, keyPEM, err := servingCertificateRequest(b.nodeIP, NodeIdentity(b.nodeName), key) if err != nil { return time.Time{}, err } - if err := b.client.Delete(ctx, b.csrName, metav1.DeleteOptions{}); err != nil && !apierrors.IsNotFound(err) { + // A CSR left by an earlier attempt is unusable: its certificate would be for a key we no + // longer hold. Usually a no-op — the cleaner drops an issued CSR an hour after approval. + if err := b.ownClient.Delete(ctx, b.csrName, metav1.DeleteOptions{}); err != nil && !apierrors.IsNotFound(err) { return time.Time{}, fmt.Errorf("delete stale CSR %s: %w", b.csrName, err) } expirationSeconds := int32(kubeletServingCertificateLifetime / time.Second) - csr, err := b.client.Create(ctx, &certificatesv1.CertificateSigningRequest{ + // The one call whose IDENTITY matters (see the constructor). + csr, err := b.nodeClient.Create(ctx, &certificatesv1.CertificateSigningRequest{ ObjectMeta: metav1.ObjectMeta{ Name: b.csrName, Labels: map[string]string{ @@ -161,14 +196,12 @@ func (b *KubeletServingCertificateBootstrapper) requestAndWait(ctx context.Conte log := logf.FromContext(ctx).WithName("kubelet-serving-certificate") log.Info("waiting for kubelet serving certificate approval", - "csr", csr.Name, - "approveCommand", "kubectl certificate approve "+csr.Name, - "podIP", b.nodeIP) + "csr", csr.Name, "identity", NodeIdentity(b.nodeName), "podIP", b.nodeIP) ticker := time.NewTicker(b.pollInterval) defer ticker.Stop() for { - current, err := b.client.Get(ctx, b.csrName, metav1.GetOptions{}) + current, err := b.ownClient.Get(ctx, b.csrName, metav1.GetOptions{}) if err != nil { return time.Time{}, fmt.Errorf("get CSR %s: %w", b.csrName, err) } @@ -186,6 +219,17 @@ func (b *KubeletServingCertificateBootstrapper) requestAndWait(ctx context.Conte return notAfter, nil } + // Retrying in here recovers a transient UpdateApproval in one tick, where recreating the + // CSR would cost the 24h the cleaner takes to remove it AND keep yanking it out from + // under a human approving by hand. `current` for a fresh resourceVersion. + if !isApproved(current) { + if err := b.approve(ctx, current); err != nil { + log.Error(err, "could not self-approve the serving certificate request; "+ + "approve it by hand or the endpoint keeps its self-signed certificate", + "csr", b.csrName, "approveCommand", "kubectl certificate approve "+b.csrName) + } + } + select { case <-ctx.Done(): return time.Time{}, ctx.Err() @@ -194,10 +238,39 @@ func (b *KubeletServingCertificateBootstrapper) requestAndWait(ctx context.Conte } } -func servingCertificateRequest(nodeIP, podName string, key *ecdsa.PrivateKey) ([]byte, []byte, error) { +func isApproved(csr *certificatesv1.CertificateSigningRequest) bool { + for _, condition := range csr.Status.Conditions { + if condition.Type == certificatesv1.CertificateApproved { + return true + } + } + return false +} + +// approve approves our own request, as the MANAGER — a node cannot approve its own +// certificate, and the built-in approver only handles real kubelets. Without this the endpoint +// keeps its self-signed certificate until a human runs `kubectl certificate approve`, at every +// renewal. +// +// Caller must check isApproved first: a second Approved condition is rejected by validation. +func (b *KubeletServingCertificateBootstrapper) approve( + ctx context.Context, csr *certificatesv1.CertificateSigningRequest, +) error { + csr.Status.Conditions = append(csr.Status.Conditions, certificatesv1.CertificateSigningRequestCondition{ + Type: certificatesv1.CertificateApproved, + Status: corev1.ConditionTrue, + Reason: "NebulaKubeletServing", + Message: "approved by the Nebula manager for its own kubelet serving endpoint", + LastUpdateTime: metav1.Now(), + }) + _, err := b.ownClient.UpdateApproval(ctx, b.csrName, csr, metav1.UpdateOptions{}) + return err +} + +func servingCertificateRequest(nodeIP, identity string, key *ecdsa.PrivateKey) ([]byte, []byte, error) { template := &x509.CertificateRequest{ Subject: pkix.Name{ - CommonName: "system:node:" + podName, + CommonName: identity, Organization: []string{"system:nodes"}, }, IPAddresses: []net.IP{net.ParseIP(nodeIP)}, diff --git a/pkg/vnode/kubelet_certificate_test.go b/pkg/vnode/kubelet_certificate_test.go index 3575439..3087e29 100644 --- a/pkg/vnode/kubelet_certificate_test.go +++ b/pkg/vnode/kubelet_certificate_test.go @@ -24,14 +24,25 @@ import ( "crypto/x509" "crypto/x509/pkix" "encoding/pem" + "fmt" "math/big" "net" + "os" + "path/filepath" + "slices" + "sync" "testing" "time" certificatesv1 "k8s.io/api/certificates/v1" + rbacv1 "k8s.io/api/rbac/v1" + apierrors "k8s.io/apimachinery/pkg/api/errors" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" "k8s.io/client-go/kubernetes/fake" + k8stesting "k8s.io/client-go/testing" + "sigs.k8s.io/yaml" ) func TestKubeletServingCertificateBootstrapperInstallsIssuedCertificate(t *testing.T) { @@ -54,12 +65,13 @@ func TestKubeletServingCertificateBootstrapperInstallsIssuedCertificate(t *testi } bootstrapper, err := NewKubeletServingCertificateBootstrapper( + client, client, server, "10.20.18.154", + "nebula-modal", "nebula-system", "nebula-controller-manager-abc", - "3d18b85e-43aa-4ed6-b5e0-38fd04d93241", ) if err != nil { t.Fatalf("NewKubeletServingCertificateBootstrapper: %v", err) @@ -82,19 +94,23 @@ func TestKubeletServingCertificateBootstrapperInstallsIssuedCertificate(t *testi } }) + // Waits for the approval rather than merely for the object, so the assertion below cannot + // race the UpdateApproval that follows the create. var csr *certificatesv1.CertificateSigningRequest waitFor(t, func() bool { csr, err = client.CertificatesV1().CertificateSigningRequests().Get( context.Background(), bootstrapper.csrName, metav1.GetOptions{}, ) - return err == nil - }, "kubelet-serving CSR") + return err == nil && isApproved(csr) + }, "self-approved kubelet-serving CSR") if csr.Spec.SignerName != certificatesv1.KubeletServingSignerName { t.Fatalf("signer = %q, want %q", csr.Spec.SignerName, certificatesv1.KubeletServingSignerName) } request := parseCertificateRequest(t, csr.Spec.Request) - if request.Subject.CommonName != "system:node:nebula-controller-manager-abc" { + // Must be the node identity the client impersonates, not the Pod: the signer compares the + // two and ignores a mismatch without any condition to notice (see NodeIdentity). + if request.Subject.CommonName != "system:node:nebula-modal" { t.Fatalf("common name = %q", request.Subject.CommonName) } if len(request.Subject.Organization) != 1 || request.Subject.Organization[0] != "system:nodes" { @@ -129,6 +145,158 @@ func TestKubeletServingCertificateBootstrapperInstallsIssuedCertificate(t *testi } } +// TestKubeletServingCertificateBootstrapperRoutesVerbsByIdentity pins each call to the client +// allowed to make it, by denying what a real cluster denies. The bug it encodes: everything +// went through the impersonating client, and the delete runs first — so it failed Forbidden +// before creating anything, leaving no CSR at all. +func TestKubeletServingCertificateBootstrapperRoutesVerbsByIdentity(t *testing.T) { + nodeFake := fake.NewSimpleClientset() + ownFake := fake.NewSimpleClientset() + // Two clients over ONE store, so which client issued a call is observable. Delegating to the + // tracker rather than the clientset deliberately bypasses nodeFake's denials below. + ownFake.PrependReactor("*", "certificatesigningrequests", k8stesting.ObjectReaction(nodeFake.Tracker())) + + deny := func(verb string) k8stesting.ReactionFunc { + return func(k8stesting.Action) (bool, runtime.Object, error) { + return true, nil, apierrors.NewForbidden( + schema.GroupResource{Group: "certificates.k8s.io", Resource: "certificatesigningrequests"}, + "", fmt.Errorf("%s is not granted to this identity", verb)) + } + } + nodeFake.PrependReactor("delete", "certificatesigningrequests", deny("delete")) + nodeFake.PrependReactor("update", "certificatesigningrequests", deny("update")) + ownFake.PrependReactor("create", "certificatesigningrequests", deny("create")) + + server, err := NewKubeletServer("10.20.18.154", ":10250", "") + if err != nil { + t.Fatalf("NewKubeletServer: %v", err) + } + bootstrapper, err := NewKubeletServingCertificateBootstrapper( + nodeFake, ownFake, server, + "10.20.18.154", "nebula-modal", "nebula-system", + "nebula-controller-manager-abc", + ) + if err != nil { + t.Fatalf("NewKubeletServingCertificateBootstrapper: %v", err) + } + bootstrapper.pollInterval = 5 * time.Millisecond + bootstrapper.retryInterval = 5 * time.Millisecond + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + go func() { _ = bootstrapper.Start(ctx) }() + + // Reaching Approved proves the routing: delete and approval went out as the manager, the + // create as the node. + waitFor(t, func() bool { + csr, getErr := nodeFake.CertificatesV1().CertificateSigningRequests().Get( + ctx, bootstrapper.csrName, metav1.GetOptions{}, + ) + return getErr == nil && isApproved(csr) + }, "CSR created as the node and approved as the manager") +} + +// TestKubeletServingCertificateBootstrapperRetriesApproval covers a transient UpdateApproval. +// Approving from outside the poll left nothing to try again, so the loop watched a CSR that +// could not be signed until the cleaner removed it a day later. +func TestKubeletServingCertificateBootstrapperRetriesApproval(t *testing.T) { + client := fake.NewSimpleClientset() + var mu sync.Mutex + var attempts int + client.PrependReactor("update", "certificatesigningrequests", + func(k8stesting.Action) (bool, runtime.Object, error) { + mu.Lock() + defer mu.Unlock() + if attempts++; attempts == 1 { + return true, nil, apierrors.NewServiceUnavailable("etcd leader election in progress") + } + return false, nil, nil + }) + + server, err := NewKubeletServer("10.20.18.154", ":10250", "") + if err != nil { + t.Fatalf("NewKubeletServer: %v", err) + } + bootstrapper, err := NewKubeletServingCertificateBootstrapper( + client, client, server, + "10.20.18.154", "nebula-modal", "nebula-system", + "nebula-controller-manager-abc", + ) + if err != nil { + t.Fatalf("NewKubeletServingCertificateBootstrapper: %v", err) + } + bootstrapper.pollInterval = 5 * time.Millisecond + // Long enough that a recreate-from-scratch cannot masquerade as a retry. + bootstrapper.retryInterval = time.Hour + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + go func() { _ = bootstrapper.Start(ctx) }() + + waitFor(t, func() bool { + csr, getErr := client.CertificatesV1().CertificateSigningRequests().Get( + ctx, bootstrapper.csrName, metav1.GetOptions{}, + ) + return getErr == nil && isApproved(csr) + }, "approval retried after a transient failure") + + // The loop polls for a certificate that never arrives, so without the isApproved guard it + // would keep approving an approved CSR — which the real API rejects as a duplicate + // condition. Settling for many intervals is what makes the count meaningful. + time.Sleep(20 * bootstrapper.pollInterval) + mu.Lock() + defer mu.Unlock() + if attempts != 2 { + t.Fatalf("UpdateApproval calls = %d, want 2 (one transient failure, one success)", attempts) + } +} + +// TestServingCSRNameIsScopedByRBAC guards the coupling the narrow grant rests on: the name is +// computed in Go, the resourceNames list is written by hand, and drift between them is silent +// in CI and surfaces only as a Forbidden on a real cluster. +func TestServingCSRNameIsScopedByRBAC(t *testing.T) { + raw, err := os.ReadFile(filepath.Join("..", "..", "config", "rbac", "role.yaml")) + if err != nil { + t.Fatalf("read role.yaml: %v", err) + } + var role rbacv1.ClusterRole + if err := yaml.Unmarshal(raw, &role); err != nil { + t.Fatalf("parse role.yaml: %v", err) + } + + scoped := map[string]map[string]bool{} + for _, rule := range role.Rules { + if !slices.Contains(rule.APIGroups, certificatesv1.GroupName) { + continue + } + for _, resource := range rule.Resources { + // An unscoped rule may only create: that verb cannot be scoped by name, while + // deleting or approving someone else's CSR is what the scoping exists to prevent. + if len(rule.ResourceNames) == 0 { + if !slices.Equal(rule.Verbs, []string{"create"}) { + t.Errorf("cluster-wide rule on %s grants %v, want [create] alone", resource, rule.Verbs) + } + continue + } + if scoped[resource] == nil { + scoped[resource] = map[string]bool{} + } + for _, name := range rule.ResourceNames { + scoped[resource][name] = true + } + } + } + + for _, resource := range []string{"certificatesigningrequests", "certificatesigningrequests/approval"} { + for _, provider := range []string{"aws", "modal", "fake"} { + if want := ServingCSRName(NodeName(provider)); !scoped[resource][want] { + t.Errorf("role.yaml does not scope %s to %q; run `make manifests` after changing "+ + "ServingCSRName or the markers in cmd/main.go", resource, want) + } + } + } +} + func TestServingCertificateRejectsWrongIP(t *testing.T) { key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) if err != nil { From 5142e782dc2f835e94413b4c2823536f13d61f4a Mon Sep 17 00:00:00 2001 From: kerthcet Date: Sat, 12 Sep 2026 13:00:06 +0100 Subject: [PATCH 3/4] fix comment Signed-off-by: kerthcet --- pkg/vnode/kubelet_certificate_test.go | 5 ----- 1 file changed, 5 deletions(-) diff --git a/pkg/vnode/kubelet_certificate_test.go b/pkg/vnode/kubelet_certificate_test.go index 3087e29..476dfd3 100644 --- a/pkg/vnode/kubelet_certificate_test.go +++ b/pkg/vnode/kubelet_certificate_test.go @@ -120,11 +120,6 @@ func TestKubeletServingCertificateBootstrapperInstallsIssuedCertificate(t *testi t.Fatalf("IP SANs = %v, want [10.20.18.154]", request.IPAddresses) } - csr.Status.Conditions = append(csr.Status.Conditions, certificatesv1.CertificateSigningRequestCondition{ - Type: certificatesv1.CertificateApproved, - Status: "True", - Reason: "TestApproved", - }) csr.Status.Certificate = issueTestServingCertificate(t, request) if _, err := client.CertificatesV1().CertificateSigningRequests().UpdateStatus( context.Background(), csr, metav1.UpdateOptions{}, From 8a1b320179f7e83724cd2f8789c918bb96582e3b Mon Sep 17 00:00:00 2001 From: kerthcet Date: Sat, 12 Sep 2026 13:02:38 +0100 Subject: [PATCH 4/4] disable kubeletServingTLSBootstrap by default Signed-off-by: kerthcet --- config/manager/manager.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/config/manager/manager.yaml b/config/manager/manager.yaml index f51b2e3..de892f5 100644 --- a/config/manager/manager.yaml +++ b/config/manager/manager.yaml @@ -63,7 +63,7 @@ spec: args: - --leader-elect - --health-probe-bind-address=:8081 - - --kubelet-serving-tls-bootstrap=true + # - --kubelet-serving-tls-bootstrap=true image: controller:latest name: manager imagePullPolicy: IfNotPresent