Skip to content
Merged
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
9 changes: 5 additions & 4 deletions cmd/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -525,11 +525,12 @@ func setupKubeletServer(mgr ctrl.Manager, addr, clientCA string, servingTLSBoots
// 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.
// Keep the CSR name in step with vnode.ServingCSRName, and the users with the providers that
// can register — that list is the one part still keyed to a runtime-chosen name. 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=certificatesigningrequests,resourceNames=nebula-kubelet-serving,verbs=delete;get
// +kubebuilder:rbac:groups=certificates.k8s.io,resources=certificatesigningrequests/approval,resourceNames=nebula-kubelet-serving,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
Expand Down
3 changes: 2 additions & 1 deletion config/manager/manager.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -98,7 +98,8 @@ spec:
fieldPath: status.podIP
# 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).
# is fixed and carries no Pod identity (see vnode.ServingCSRName), which is
# why these annotations are the only way to tell.
- name: POD_NAME
valueFrom:
fieldRef:
Expand Down
8 changes: 2 additions & 6 deletions config/rbac/role.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -88,9 +88,7 @@ rules:
- apiGroups:
- certificates.k8s.io
resourceNames:
- nebula-kubelet-serving-nebula-aws
- nebula-kubelet-serving-nebula-fake
- nebula-kubelet-serving-nebula-modal
- nebula-kubelet-serving
resources:
- certificatesigningrequests
verbs:
Expand All @@ -99,9 +97,7 @@ rules:
- apiGroups:
- certificates.k8s.io
resourceNames:
- nebula-kubelet-serving-nebula-aws
- nebula-kubelet-serving-nebula-fake
- nebula-kubelet-serving-nebula-modal
- nebula-kubelet-serving
resources:
- certificatesigningrequests/approval
verbs:
Expand Down
23 changes: 13 additions & 10 deletions docs/deploy.md
Original file line number Diff line number Diff line change
Expand Up @@ -152,16 +152,19 @@ kubectl -n nebula-system logs deploy/nebula-controller-manager \
```

Two identities are involved, and the split is not cosmetic. The CSR is **created** while
impersonating `system:node:nebula-<provider>`, 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-<node>`, 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.
impersonating `system:node:nebula-<provider>`, because EKS silently declines to sign a request from
the manager's own ServiceAccount — upstream Kubernetes does not check the requester at all, so this
is EKS-specific and measured rather than inferred (see
[kubelet-api.md](kubelet-api.md#the-serving-certificate)); 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 for the whole cluster, because every virtual
node advertises this Pod's IP and the API server verifies the address it dialed. A fixed name is
what lets `config/rbac/role.yaml` scope delete, get and approval to it 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.

---

Expand Down
32 changes: 22 additions & 10 deletions docs/kubelet-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,20 +46,32 @@ 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-<provider>`, 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.
- **EKS checks the requester; upstream does not.** The CSR is created while impersonating
`system:node:nebula-<provider>`, with that same name as its CN. Send the identical request as the
manager's own ServiceAccount and EKS approves it and then never signs it — no certificate, and
**no condition** to notice, so `Approved,Issued` is the only healthy state. Both ways were
measured on EKS 1.35 with identical CSR bytes, differing only in the creating identity; check that
again before believing any claim that the impersonation is removable. None of this is upstream
behavior:
`ValidateKubeletServingCSR` never sees the requester and checks only the CN prefix and a
`system:nodes` organization, and a validation failure there writes `CertificateFailed` rather
than going quiet.
- **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-<node>`.** 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.
- **One certificate covers every virtual node, so there is one CSR.** 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 a single request, submitted under the first registered provider's node
identity, serves the whole set. Its name is the fixed `nebula-kubelet-serving`, which is what
lets `config/rbac/role.yaml` scope delete, get and approval to that one name by
`resourceNames`; only `create` is cluster-wide. The node identity still varies, and the
`users` impersonate grant has to list every provider that can register.
- **That name is global, so the object under it is checked.** A fetch by name can return a CSR
someone else recreated, and approving it would sign a key and SANs the manager does not control —
the name is the only thing its approval grant is scoped by. So each poll compares the object's UID
with the one it created and restarts the attempt on a mismatch. Two Nebula installations in one
cluster will therefore log a replaced CSR at each other indefinitely rather than converge.
- **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
Expand Down
70 changes: 42 additions & 28 deletions pkg/vnode/kubelet_certificate.go
Original file line number Diff line number Diff line change
Expand Up @@ -47,20 +47,20 @@ 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.
// NodeIdentity is the CN a serving certificate request must carry, and the username the client
// impersonates to submit it — one function because the two must agree.
//
// 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.
// EKS signs only for a system:node: creator, and refuses in silence: the same request from the
// manager's ServiceAccount is approved and then never signed, with no condition to notice. That
// is EKS-specific, not upstream behavior.
func NodeIdentity(nodeName string) string { return "system:node:" + nodeName }

// ServingCSRName is the CSR one virtual node reuses for the life of the cluster.
// ServingCSRName is the single CSR the kubelet endpoint 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 }
// One name because one certificate serves every virtual node: they all advertise this Pod's
// address, and the API server verifies the address it dialed. Provider-sharded replicas, each
// with its own Pod IP, would need a name per node again.
const ServingCSRName = "nebula-kubelet-serving"

type KubeletServingCertificateBootstrapper struct {
// nodeClient impersonates the virtual node and CREATES the request; ownClient is the
Expand All @@ -72,7 +72,6 @@ type KubeletServingCertificateBootstrapper struct {
nodeName string
podName string
podNamespace string
csrName string
pollInterval time.Duration
retryInterval time.Duration
}
Expand All @@ -82,13 +81,12 @@ 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.
// - nodeClient impersonates NodeIdentity(nodeName) and creates the request; see there.
// - 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"`.
//
// Requester and approver differing is the ordinary arrangement: the signer checks who ASKED.
// Only the CREATE's identity matters, so approving as the manager is not a workaround.
func NewKubeletServingCertificateBootstrapper(
nodeClient, ownClient kubernetes.Interface,
server *KubeletServer,
Expand Down Expand Up @@ -118,7 +116,6 @@ func NewKubeletServingCertificateBootstrapper(
nodeName: nodeName,
podName: podName,
podNamespace: podNamespace,
csrName: ServingCSRName(nodeName),
pollInterval: kubeletServingPollInterval,
retryInterval: kubeletServingRetryInterval,
}, nil
Expand Down Expand Up @@ -163,14 +160,14 @@ func (b *KubeletServingCertificateBootstrapper) requestAndWait(ctx context.Conte

// 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)
if err := b.ownClient.Delete(ctx, ServingCSRName, metav1.DeleteOptions{}); err != nil && !apierrors.IsNotFound(err) {
return time.Time{}, fmt.Errorf("delete stale CSR %s: %w", ServingCSRName, err)
}
expirationSeconds := int32(kubeletServingCertificateLifetime / time.Second)
// The one call whose IDENTITY matters (see the constructor).
csr, err := b.nodeClient.Create(ctx, &certificatesv1.CertificateSigningRequest{
ObjectMeta: metav1.ObjectMeta{
Name: b.csrName,
Name: ServingCSRName,
Labels: map[string]string{
"app.kubernetes.io/name": "nebula",
"app.kubernetes.io/component": "kubelet-serving-certificate",
Expand All @@ -184,14 +181,23 @@ func (b *KubeletServingCertificateBootstrapper) requestAndWait(ctx context.Conte
Request: requestPEM,
SignerName: certificatesv1.KubeletServingSignerName,
ExpirationSeconds: &expirationSeconds,
// Two, not three: keyEncipherment is for an RSA key and this one is ECDSA. The signer
// accepts either set, so adding it would still sign — and still be wrong.
Usages: []certificatesv1.KeyUsage{
certificatesv1.UsageDigitalSignature,
certificatesv1.UsageServerAuth,
},
},
}, metav1.CreateOptions{})
if err != nil {
return time.Time{}, fmt.Errorf("create CSR %s: %w", b.csrName, err)
// Forbidden here is almost always the impersonate grant: the identity changes with the
// provider, the RBAC list naming it does not.
if apierrors.IsForbidden(err) {
return time.Time{}, fmt.Errorf("create CSR %s as %s: %w; add that name to the users "+
"impersonate grant in cmd/main.go and run `make manifests`",
ServingCSRName, NodeIdentity(b.nodeName), err)
}
return time.Time{}, fmt.Errorf("create CSR %s: %w", ServingCSRName, err)
}

log := logf.FromContext(ctx).WithName("kubelet-serving-certificate")
Expand All @@ -201,19 +207,27 @@ func (b *KubeletServingCertificateBootstrapper) requestAndWait(ctx context.Conte
ticker := time.NewTicker(b.pollInterval)
defer ticker.Stop()
for {
current, err := b.ownClient.Get(ctx, b.csrName, metav1.GetOptions{})
current, err := b.ownClient.Get(ctx, ServingCSRName, metav1.GetOptions{})
if err != nil {
return time.Time{}, fmt.Errorf("get CSR %s: %w", b.csrName, err)
return time.Time{}, fmt.Errorf("get CSR %s: %w", ServingCSRName, err)
}
// The name is cluster-global, so a get can return an object we did not create. Approving
// that would sign a key and SANs we do not control, and our approval grant is scoped by
// this name alone.
if current.UID != csr.UID {
return time.Time{}, fmt.Errorf("CSR %s was replaced (uid %s, created %s); "+
"another Nebula installation sharing the cluster would do this",
ServingCSRName, current.UID, csr.UID)
}
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)
return time.Time{}, fmt.Errorf("CSR %s ended with %s: %s", ServingCSRName, 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)
return time.Time{}, fmt.Errorf("load certificate from CSR %s: %w", ServingCSRName, err)
}
b.server.SetServingCertificate(cert)
return notAfter, nil
Expand All @@ -226,7 +240,7 @@ func (b *KubeletServingCertificateBootstrapper) requestAndWait(ctx context.Conte
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)
"csr", ServingCSRName, "approveCommand", "kubectl certificate approve "+ServingCSRName)
}
}

Expand Down Expand Up @@ -263,7 +277,7 @@ func (b *KubeletServingCertificateBootstrapper) approve(
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{})
_, err := b.ownClient.UpdateApproval(ctx, ServingCSRName, csr, metav1.UpdateOptions{})
return err
}

Expand Down
Loading
Loading