From 8605b70d17e532b3c15a3f8106a03f4c046ddc96 Mon Sep 17 00:00:00 2001 From: EdmondDantes21 Date: Fri, 4 Sep 2026 16:06:28 +0200 Subject: [PATCH 1/3] fix: renew authn's own client certificate and clamp the login JWT to it Co-Authored-By: Claude Opus 5 (1M context) --- README.md | 42 ++ go.mod | 6 +- internal/certrenewal/renewal.go | 384 ++++++++++++++++++ internal/certrenewal/renewal_test.go | 150 +++++++ internal/env/env.go | 13 + internal/helpers/encode/success.go | 39 +- internal/helpers/encode/success_test.go | 95 +++++ internal/helpers/kube/certinfo.go | 63 +++ internal/helpers/kube/certinfo_test.go | 101 +++++ internal/helpers/kube/config/build.go | 35 +- internal/helpers/kube/config/gen.go | 50 ++- .../kube/config/storage/annotations_test.go | 60 +++ .../helpers/kube/config/storage/storage.go | 26 ++ internal/routes/auth/basic/login.go | 9 +- internal/routes/auth/ldap/login.go | 9 +- internal/routes/auth/oauth/login.go | 9 +- internal/routes/auth/oidc/login.go | 9 +- main.go | 63 ++- scripts/verify-cert-renewal.sh | 221 ++++++++++ 19 files changed, 1325 insertions(+), 59 deletions(-) create mode 100644 internal/certrenewal/renewal.go create mode 100644 internal/certrenewal/renewal_test.go create mode 100644 internal/helpers/encode/success_test.go create mode 100644 internal/helpers/kube/certinfo.go create mode 100644 internal/helpers/kube/certinfo_test.go create mode 100644 internal/helpers/kube/config/storage/annotations_test.go create mode 100755 scripts/verify-cert-renewal.sh diff --git a/README.md b/README.md index 4d495f1..d44fe3d 100644 --- a/README.md +++ b/README.md @@ -267,6 +267,48 @@ See [oidc-azure-pagination](./testdata/oidc-azure-pagination.yaml), [oidc-azure] If the RESTAction does not accept a token parameter, then it will temporarily set the token in the respective endpoint. +## Client certificate renewal +authn mints x509 client certificates through the Kubernetes CSR API (signer `kubernetes.io/kube-apiserver-client`) and stores them in `-clientconfig` Secrets. There are two kinds of identity and they are kept alive differently. + +### The requested duration is not the granted duration +A signer grants `min(requested expirationSeconds, cluster-signing-duration, signer CA remaining life)`. Vanilla Kubernetes defaults `--cluster-signing-duration` to `8760h`, so the year authn asks for usually survives, which is why this stays invisible until you deploy elsewhere. OpenShift pins the signing duration to 720h and rotates the CSR signer CA every 30 days, so a certificate requested for a year comes back valid for at most 30 days, and for a single day when a rotation is imminent. + +authn therefore reads `NotAfter` off the issued certificate instead of assuming the request held, and annotates every stored Secret with the window that was actually granted: +```sh +$ kubectl -n krateo-system get secret authn-clientconfig -o jsonpath='{.metadata.annotations}' +{"authn.krateo.io/certificate-not-before":"2026-09-04T10:00:00Z","authn.krateo.io/certificate-not-after":"2026-10-04T10:00:00Z"} +``` +A `warn` is logged at issue time whenever the granted window is under 90% of the requested one, which is the only signal that a signer is capping you. Nothing reads these annotations back: the renewal loop decides off the stored certificate itself, and they exist so that an operator can answer "when does this credential really die" across a whole namespace without decoding anything, e.g. `kubectl get secret -o custom-columns=NAME:.metadata.name,EXPIRES:'.metadata.annotations.authn\.krateo\.io/certificate-not-after'`. + +That clamp is what makes `NotAfter` a sufficient signal rather than a hopeful one, and it is worth knowing it is real: Kubernetes' signer sets the issued `NotAfter` to `min(now + ttl, signer CA NotAfter)` (`pkg/controller/certificates/authority/policies.go`, `if !tmpl.NotAfter.Before(signerNotAfter) { tmpl.NotAfter = signerNotAfter }`) and refuses to sign at all once the CA itself has expired, so a certificate can never outlive the CA that signed it. The signer hot-reloads its CA from disk, so signings after a rotation use the new CA. OpenShift's rotation is additive on the trust side: library-go's `manageCABundleConfigMap` prepends the new signer to the CA bundle and prunes only certificates that have genuinely expired, so a certificate issued by a since-rotated CA stays trusted right up to its own `NotAfter`. Renewing at a fraction of the granted window is therefore always in time. Note that a CA being *rotated* in five days is not the same as it *expiring* in five days: if the old CA cert is still valid for another 25 days you are granted up to those 25 days, and that is correct, because the rotated-out CA remains in the bundle until it expires. + +The one case this cannot see is a signer CA regenerated out of band with its trust bundle rebuilt from scratch (disaster recovery, a manual signer wipe). Previously issued certificates then stop being accepted while their `NotAfter` is still in the future, which surfaces as `x509: certificate signed by unknown authority` rather than as an expiry. authn cannot detect it, because snowplow rather than authn is what presents the certificate; recovery is a pod restart or `kubectl delete secret authn-clientconfig`, which the loop re-issues on its next check. + +### `authn-clientconfig` is renewed in the background +This is authn's own identity, the one snowplow presents when it runs RESTActions on authn's behalf. Nothing else ever re-issues it: unlike a user certificate there is no login to rewrite it, so it used to be created once at startup and then rot in place, and every RESTAction call failed with `x509: certificate has expired or is not yet valid` until the pod was restarted. + +A background loop now issues it at startup and re-issues it once it passes `AUTHN_CRT_RENEWAL_THRESHOLD` of its granted lifetime. Because the threshold is a fraction of what was granted rather than of what was asked for, one setting covers both a full year (re-issued after about 8 months) and a certificate a rotating signer CA cut to an hour (re-issued after about 40 minutes). The certificate is read exactly once per issuance: the signing call returns it, and the clamp above makes its `NotAfter` deterministic, so there is nothing to re-read until renewal falls due. The loop then simply sleeps that long — a year-long certificate is one sleep of about eight months, not a poll. The knobs, both settable under `env` in the chart values: +- `AUTHN_SERVICE_CRT_EXPIRES_IN` (`--service-cert-expires`, default `8760h`): duration requested for authn's own certificate. +- `AUTHN_CRT_RENEWAL_THRESHOLD` (`--cert-renewal-threshold`, default `0.66`): fraction of the granted lifetime after which it is re-issued. This is the whole renewal policy; it must be between 0 and 1 exclusive, and an out-of-range value falls back to the default. +- `AUTHN_CRT_RENEWAL_ENABLED` (`--cert-renewal`, default `true`): set `false` to restore the old behaviour, where the certificate expires in place until the pod restarts. + +Failures retry after a minute, and a failed read never triggers a re-issue: an apiserver hiccup is not evidence the credential is gone. Because nothing polls, a Secret deleted or replaced out of band is not noticed until the next scheduled re-issue; deleting it is still the way to force a fresh certificate, but it takes a pod restart to act on it immediately. No new RBAC is needed: the validity annotations are written with the `get` and `update` on `secrets` authn already holds. The loop does assume a single writer, since the CSR object is named after the username and two replicas would race on the CSR named `authn`. + +### `-clientconfig` is clamped, not renewed +Per-user certificates are deliberately not on the renewal loop. Instead the login JWT is now issued for `min(AUTHN_KUBECONFIG_CRT_EXPIRES_IN, time until the certificate's real NotAfter)`. + +That closes the actual bug. The JWT lifetime used to come from the requested duration, so a truncated certificate left the user logged in while every user-scoped call failed with an x509 error, and with no refresh endpoint the only recovery was waiting out the JWT. With the clamp the certificate outlives every token issued against it by construction, and a user certificate is re-minted on every login, so there is no window left for a background loop to rescue. + +Renewing them would also be actively worse: it would rewrite the Secret but not the kubeconfig copy the user is holding, and it would keep minting valid cluster credentials for identities long since deprovisioned upstream, which authn cannot check because it learns who someone is at login and holds no session state. + +### Verifying it +Certificate handling differs per distribution, so it has to be measured rather than reasoned about. [`scripts/verify-cert-renewal.sh`](./scripts/verify-cert-renewal.sh) reports the granted `NotAfter`, checks that renewal fires before expiry, and proves the renewed certificate still authenticates against the apiserver. Run it on each flavour you support (kind, minikube, k3s/k3d, EKS, AKS, GKE, OpenShift) and record the row it prints. +```sh +$ scripts/verify-cert-renewal.sh -n krateo-system # report only +$ scripts/verify-cert-renewal.sh -n krateo-system --wait # force a renewal now +$ scripts/verify-cert-renewal.sh -n krateo-system --short 15m # watch a full cycle in ~10 minutes +``` + ## Graphics Configuration The OAuth2 and OIDC authentication methods also support a `graphics` object that allows to configure how the button for the redirect to the authentication provider portal is visualized in the frontend login screen. ```yaml diff --git a/go.mod b/go.mod index 7f24e44..7cb7bb9 100644 --- a/go.mod +++ b/go.mod @@ -21,7 +21,10 @@ require ( sigs.k8s.io/e2e-framework v0.6.0 ) -require github.com/krateoplatformops/snowplow v0.0.0-20250508092448-4cff77fa45a5 +require ( + github.com/golang-jwt/jwt/v5 v5.2.2 + github.com/krateoplatformops/snowplow v0.0.0-20250508092448-4cff77fa45a5 +) require ( github.com/Azure/go-ntlmssp v0.0.0-20221128193559-754e69321358 // indirect @@ -39,7 +42,6 @@ require ( github.com/go-openapi/swag v0.23.0 // indirect github.com/gobuffalo/flect v1.0.3 // indirect github.com/gogo/protobuf v1.3.2 // indirect - github.com/golang-jwt/jwt/v5 v5.2.2 // indirect github.com/google/gnostic-models v0.6.9 // indirect github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect diff --git a/internal/certrenewal/renewal.go b/internal/certrenewal/renewal.go new file mode 100644 index 0000000..da4749c --- /dev/null +++ b/internal/certrenewal/renewal.go @@ -0,0 +1,384 @@ +// Package certrenewal keeps authn's own client certificate alive. +// +// authn authenticates as a first-class cluster identity: it mints an x509 +// client certificate for itself through the Kubernetes CSR API and stores it in +// the `-clientconfig` Secret, which snowplow then presents when +// running RESTActions on authn's behalf. Nothing else ever re-issues it. A +// user's certificate is rewritten on every login, but authn's own identity used +// to be created exactly once, at startup, and then left to rot: a pod that +// outlived its certificate kept presenting an expired one, and every RESTAction +// call failed with `x509: certificate has expired or is not yet valid` until +// somebody restarted it. +// +// The gap is not theoretical, because a signer grants +// min(requested, --cluster-signing-duration, signer CA remaining life). Vanilla +// Kubernetes defaults that middle term to 8760h, so the year authn asks for +// usually survives. OpenShift pins it to 720h and rotates the CSR signer CA on +// a 30-day cycle, so the certificate comes back valid for at most 30 days — and +// for as little as a day when the rotation is imminent. +// +// The renewer closes it: it issues the certificate at startup, reads the +// granted NotAfter off the issued certificate rather than assuming the request +// held, and re-issues once the certificate passes a fraction of its REAL +// lifetime. +// +// It deliberately covers only authn's own identity. Per-user +// `-clientconfig` Secrets are session-scoped: they are re-minted on every +// login, and the login JWT is now clamped to the certificate's real NotAfter +// (internal/helpers/encode), so a session can no longer outlive the credential +// it was issued with and there is nothing left for a background loop to save. +// Renewing them anyway would keep minting live cluster credentials for +// identities that may since have been deprovisioned upstream, which authn has +// no way to check. See the "Client certificate renewal" section of the README. +package certrenewal + +import ( + "context" + "crypto/x509" + "fmt" + "time" + + "github.com/krateoplatformops/authn/apis/core" + "github.com/krateoplatformops/authn/internal/helpers/kube" + "github.com/krateoplatformops/authn/internal/helpers/kube/config/storage" + "github.com/krateoplatformops/authn/internal/helpers/kube/secrets" + "github.com/krateoplatformops/authn/internal/helpers/kube/util" + "github.com/krateoplatformops/plumbing/kubeutil" + "github.com/krateoplatformops/plumbing/signup" + "github.com/rs/zerolog" + corev1 "k8s.io/api/core/v1" + apierrors "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/client-go/rest" +) + +const ( + // DefaultThreshold renews once two thirds of the granted lifetime have + // elapsed, leaving a third of it to recover from a failing apiserver. + DefaultThreshold = 0.66 + // DefaultDuration is the lifetime requested for authn's own certificate. + DefaultDuration = time.Hour * 8760 // 1 year + + // minSleep floors the wait. It is a spin guard, not a schedule: a signer + // whose CA has seconds left can only grant a window of seconds, and without + // a floor the loop would hammer the CSR API re-issuing it. + minSleep = time.Minute + // retryInterval is how soon a failed read or issue is retried. + retryInterval = time.Minute +) + +type Options struct { + RestConfig *rest.Config + // Namespace holding the clientconfig Secret; defaults to the operator + // namespace so reads and writes cannot drift apart. + Namespace string + // CAData and ServerURL are written into the stored config as-is; empty + // CAData makes signup resolve the cluster CA itself. + CAData string + ServerURL string + Username string + Groups []string + // Duration requested from the signer. What is granted may be far less. + Duration time.Duration + Threshold float64 + Log zerolog.Logger +} + +// Renewer keeps one identity's clientconfig Secret holding a valid certificate. +type Renewer struct { + rc *rest.Config + namespace string + secret string + caData string + serverURL string + username string + groups []string + requested time.Duration + threshold float64 + log zerolog.Logger +} + +func New(o Options) (*Renewer, error) { + if o.RestConfig == nil { + return nil, fmt.Errorf("a kubernetes rest config is required") + } + if o.Username == "" { + return nil, fmt.Errorf("a username is required") + } + + ns := o.Namespace + if ns == "" { + resolved, err := util.GetOperatorNamespace() + if err != nil { + return nil, fmt.Errorf("unable to resolve the namespace holding the clientconfig secret: %w", err) + } + ns = resolved + } + + if o.Duration <= 0 { + o.Duration = DefaultDuration + } + // A threshold outside (0,1) would renew either never or on every tick. + if o.Threshold <= 0 || o.Threshold >= 1 { + o.Threshold = DefaultThreshold + } + return &Renewer{ + rc: o.RestConfig, + namespace: ns, + secret: SecretName(o.Username), + caData: o.CAData, + serverURL: o.ServerURL, + username: o.Username, + groups: o.Groups, + requested: o.Duration, + threshold: o.Threshold, + log: o.Log, + }, nil +} + +// SecretName is the clientconfig Secret an identity's credential is stored in — +// the same name the login path and plumbing's signup derive. +func SecretName(username string) string { + return fmt.Sprintf("%s-clientconfig", kubeutil.MakeDNS1123Compatible(username)) +} + +// SecretName is the clientconfig Secret this renewer keeps current. +func (r *Renewer) SecretName() string { return r.secret } + +// RenewAt is the instant a certificate falls due for renewal: the point at +// which threshold of its GRANTED lifetime has elapsed. +// +// Scaling with the granted lifetime, rather than applying a fixed +// remaining-time floor, is what lets one setting serve both a year-long +// certificate (renewed after ~8 months) and one a rotating signer CA cut to an +// hour (renewed after ~40 minutes). +func RenewAt(notBefore, notAfter time.Time, threshold float64) time.Time { + lifetime := notAfter.Sub(notBefore) + if lifetime <= 0 { + return notAfter + } + + return notBefore.Add(time.Duration(float64(lifetime) * threshold)) +} + +// Run sleeps out wait, then keeps the certificate current until ctx is +// cancelled. +// +// wait comes from the caller's own first Ensure, so the certificate is read +// exactly once per issuance: the granted NotAfter is returned by the signing +// call itself and the signer clamps it deterministically, so there is nothing +// to re-read until renewal actually falls due. A year-long certificate is one +// year-long sleep. +func (r *Renewer) Run(ctx context.Context, wait time.Duration) { + r.log.Info(). + Str("secret", r.secret). + Str("namespace", r.namespace). + Dur("requested", r.requested). + Float64("threshold", r.threshold). + Dur("nextCheck", wait). + Msg("client certificate renewer started") + + for { + timer := time.NewTimer(wait) + select { + case <-ctx.Done(): + timer.Stop() + r.log.Info().Str("secret", r.secret).Msg("client certificate renewer stopped") + return + case <-timer.C: + } + + var err error + wait, err = r.Ensure(ctx) + if err != nil { + r.log.Err(err).Str("secret", r.secret).Msg("keeping the authn client certificate current") + } + } +} + +// Ensure issues the certificate when it is missing, unreadable or past its +// renewal point, and reports how long to wait before checking again. +func (r *Renewer) Ensure(ctx context.Context) (time.Duration, error) { + crt, sec, err := r.current(ctx) + if err != nil { + // A failed read is not evidence the credential is gone: retry the read + // rather than churning a fresh CSR on every apiserver hiccup. + return retryInterval, fmt.Errorf("reading the stored certificate from %q: %w", r.secret, err) + } + + if crt == nil { + return r.issue(ctx, "no usable certificate stored") + } + + due := RenewAt(crt.NotBefore, crt.NotAfter, r.threshold) + if wait := time.Until(due); wait > 0 { + // The annotations are written when a certificate is issued, so one + // minted by an authn that predates them — or one whose metadata write + // was lost — carries none. Backfill here instead of leaving the granted + // window invisible until the next renewal, which can be months away. + r.syncAnnotations(ctx, sec) + r.log.Debug(). + Str("secret", r.secret). + Time("notAfter", crt.NotAfter). + Time("renewAt", due). + Msg("client certificate is current") + return sleepFor(wait), nil + } + + return r.issue(ctx, "certificate passed its renewal point") +} + +// issue mints a fresh certificate and rewrites the Secret. +func (r *Renewer) issue(ctx context.Context, reason string) (time.Duration, error) { + r.log.Info(). + Str("secret", r.secret). + Str("reason", reason). + Dur("requested", r.requested). + Msg("issuing authn client certificate") + + ep, err := signup.Do(ctx, signup.Options{ + RestConfig: r.rc, + Namespace: r.namespace, + CAData: r.caData, + ServerURL: r.serverURL, + CertDuration: r.requested, + Username: r.username, + UserGroups: r.groups, + }) + if err != nil { + return retryInterval, fmt.Errorf("issuing a client certificate for %q: %w", r.username, err) + } + + r.annotate(ctx) + + crt, err := kube.ParseCertificateBase64(ep.ClientCertificateData) + if err != nil { + // The credential is stored and usable; only its expiry is unknown, so + // schedule the next check off the duration that was requested. + r.log.Err(err).Str("secret", r.secret).Msg("parsing the issued certificate") + return sleepFor(time.Duration(float64(r.requested) * r.threshold)), nil + } + + granted := kube.Lifetime(crt) + due := RenewAt(crt.NotBefore, crt.NotAfter, r.threshold) + + // A capped signer is otherwise invisible: the request succeeds, only + // shorter. Say so, so the cause is in the log before anything expires. + evt := r.log.Info() + if kube.MateriallyShorter(granted, r.requested) { + evt = r.log.Warn() + } + evt. + Str("secret", r.secret). + Dur("requested", r.requested). + Dur("granted", granted). + Time("notAfter", crt.NotAfter). + Time("renewAt", due). + Msg("authn client certificate issued") + + return sleepFor(time.Until(due)), nil +} + +// current reads the stored certificate, returning it with the Secret that holds +// it so a caller can reconcile the Secret's metadata without reading twice. A +// missing Secret, a missing key and an unreadable certificate are all reported +// as "nothing usable stored" (nil certificate) — they call for the same response. +func (r *Renewer) current(ctx context.Context) (*x509.Certificate, *corev1.Secret, error) { + sec, err := secrets.Get(ctx, r.rc, &core.SecretKeySelector{ + Namespace: r.namespace, + Name: r.secret, + }) + if err != nil { + if apierrors.IsNotFound(err) { + return nil, nil, nil + } + return nil, nil, err + } + + data, ok := sec.Data[storage.ClientCertLabel] + if !ok || len(data) == 0 { + return nil, nil, nil + } + + crt, err := kube.ParseCertificateBase64(string(data)) + if err != nil { + r.log.Warn().Err(err).Str("secret", r.secret).Msg("stored certificate is unreadable") + return nil, nil, nil + } + + return crt, sec, nil +} + +// annotate records the GRANTED validity window on the Secret it was just +// written to. signup writes the Secret with a full PUT that carries no metadata, +// so the annotations have to be re-applied after every issue. +func (r *Renewer) annotate(ctx context.Context) { + sec, err := secrets.Get(ctx, r.rc, &core.SecretKeySelector{ + Namespace: r.namespace, + Name: r.secret, + }) + if err != nil { + r.log.Err(err).Str("secret", r.secret).Msg("reading back the stored credential to annotate it") + return + } + + r.syncAnnotations(ctx, sec) +} + +// syncAnnotations brings sec's validity annotations in line with the certificate +// it carries, writing only when they actually differ — so the steady state costs +// no API call at all. +// +// It is a read-modify-write rather than a patch so that it needs only the `get` +// and `update` authn already holds, keeping the annotations from costing a +// `patch` verb in every deployment's RBAC. sec comes from a read and so carries +// its resourceVersion, making the write conditional on it: a concurrent writer +// loses to a conflict rather than being silently clobbered. +// +// Nothing here is worth failing a renewal over — the annotations are +// observability, not part of the credential — so every error is logged and +// swallowed. +func (r *Renewer) syncAnnotations(ctx context.Context, sec *corev1.Secret) { + if sec == nil { + return + } + + want := storage.CertValidityAnnotations(string(sec.Data[storage.ClientCertLabel])) + if len(want) == 0 { + return + } + + have := sec.GetAnnotations() + stale := false + for k, v := range want { + if have[k] != v { + stale = true + break + } + } + if !stale { + return + } + + merged := map[string]string{} + for k, v := range have { + merged[k] = v + } + for k, v := range want { + merged[k] = v + } + sec.SetAnnotations(merged) + + if err := secrets.Update(ctx, r.rc, sec); err != nil { + r.log.Err(err).Str("secret", r.secret).Msg("recording the certificate validity window") + } +} + +// sleepFor is the wait until the next check: the time left until renewal falls +// due, floored by the minSleep spin guard. +func sleepFor(d time.Duration) time.Duration { + if d < minSleep { + return minSleep + } + + return d +} diff --git a/internal/certrenewal/renewal_test.go b/internal/certrenewal/renewal_test.go new file mode 100644 index 0000000..7a1543d --- /dev/null +++ b/internal/certrenewal/renewal_test.go @@ -0,0 +1,150 @@ +package certrenewal + +import ( + "testing" + "time" + + "github.com/rs/zerolog" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "k8s.io/client-go/rest" +) + +func TestRenewAt(t *testing.T) { + notBefore := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC) + + tests := []struct { + name string + lifetime time.Duration + threshold float64 + want time.Duration // elapsed since notBefore + }{ + { + // The year authn asks for, granted in full: renewed with four + // months to spare. + name: "one year at two thirds", + lifetime: time.Hour * 8760, + threshold: 0.66, + want: time.Duration(float64(time.Hour*8760) * 0.66), + }, + { + // OpenShift's cap. The same threshold now renews after ~20 days + // instead of ~8 months, with no reconfiguration. + name: "openshift 720h cap", + lifetime: time.Hour * 720, + threshold: 0.66, + want: time.Duration(float64(time.Hour*720) * 0.66), + }, + { + // Signer CA about to rotate: the whole window is an hour, and the + // renewal point scales down with it. + name: "one hour granted", + lifetime: time.Hour, + threshold: 0.66, + want: time.Minute*39 + time.Second*36, + }, + { + name: "half of a day", + lifetime: time.Hour * 24, + threshold: 0.5, + want: time.Hour * 12, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got := RenewAt(notBefore, notBefore.Add(tc.lifetime), tc.threshold) + assert.Equal(t, notBefore.Add(tc.want), got) + assert.True(t, got.Before(notBefore.Add(tc.lifetime)), + "renewal must fall before expiry") + }) + } +} + +// An already-expired or zero-length window has no renewal point to compute; +// RenewAt must still return something in the past so the caller re-issues +// rather than dividing its way into a future date. +func TestRenewAtDegenerateWindow(t *testing.T) { + at := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC) + + assert.Equal(t, at, RenewAt(at, at, 0.66)) + assert.Equal(t, at, RenewAt(at.Add(time.Hour), at, 0.66)) +} + +func TestNewNormalizesOptions(t *testing.T) { + tests := []struct { + name string + opts Options + wantDuration time.Duration + wantThreshold float64 + }{ + { + name: "zero values fall back to the defaults", + opts: Options{}, + wantDuration: DefaultDuration, + wantThreshold: DefaultThreshold, + }, + { + // A threshold of 1 would renew only at expiry, 0 immediately and a + // negative one before the certificate exists. + name: "out of range threshold falls back", + opts: Options{Threshold: 1}, + wantDuration: DefaultDuration, + wantThreshold: DefaultThreshold, + }, + { + name: "negative threshold falls back", + opts: Options{Threshold: -0.5}, + wantDuration: DefaultDuration, + wantThreshold: DefaultThreshold, + }, + { + name: "explicit values are kept", + opts: Options{Duration: time.Hour * 720, Threshold: 0.5}, + wantDuration: time.Hour * 720, + wantThreshold: 0.5, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + tc.opts.RestConfig = &rest.Config{} + tc.opts.Username = "authn" + tc.opts.Namespace = "krateo-system" + tc.opts.Log = zerolog.Nop() + + r, err := New(tc.opts) + require.NoError(t, err) + + assert.Equal(t, tc.wantDuration, r.requested) + assert.Equal(t, tc.wantThreshold, r.threshold) + }) + } +} + +func TestNewRequiresIdentity(t *testing.T) { + _, err := New(Options{Username: "authn", Namespace: "krateo-system"}) + assert.Error(t, err, "a nil rest config must be rejected") + + _, err = New(Options{RestConfig: &rest.Config{}, Namespace: "krateo-system"}) + assert.Error(t, err, "an empty username must be rejected") +} + +func TestSecretName(t *testing.T) { + assert.Equal(t, "authn-clientconfig", SecretName("authn")) + // The name has to survive being used as a DNS-1123 object name, the same + // way plumbing's signup derives it. + assert.Equal(t, "pinocpalloemailcom-clientconfig", SecretName("pinoc.pallo@email.com")) +} + +// The wait is the real time until renewal, however long: a year-long +// certificate parks the loop for months rather than being re-checked. Only the +// spin guard clips it, and only under a minute. +func TestSleepFor(t *testing.T) { + eightMonths := time.Duration(float64(time.Hour*8760) * DefaultThreshold) + + assert.Equal(t, eightMonths, sleepFor(eightMonths), "a multi-month wait is slept on in full") + assert.Equal(t, time.Minute*30, sleepFor(time.Minute*30), "so is a short one") + assert.Equal(t, minSleep, sleepFor(time.Second), "sub-minute waits hit the spin guard") + assert.Equal(t, minSleep, sleepFor(-time.Hour), "a past due date is floored, not negative") +} diff --git a/internal/env/env.go b/internal/env/env.go index 39cb28c..d44b22f 100644 --- a/internal/env/env.go +++ b/internal/env/env.go @@ -33,6 +33,19 @@ func Int(key string, defaultValue int) int { return res } +func Float64(key string, defaultValue float64) float64 { + val, ok := os.LookupEnv(key) + if !ok { + return defaultValue + } + + res, err := strconv.ParseFloat(strings.TrimSpace(val), 64) + if err != nil { + return defaultValue + } + return res +} + func Bool(key string, defaultValue bool) bool { val, ok := os.LookupEnv(key) if !ok { diff --git a/internal/helpers/encode/success.go b/internal/helpers/encode/success.go index fac83c1..82bed68 100644 --- a/internal/helpers/encode/success.go +++ b/internal/helpers/encode/success.go @@ -9,10 +9,43 @@ import ( "github.com/krateoplatformops/plumbing/jwtutil" ) +// DefaultJwtDuration is the token lifetime used when a caller supplies none. +const DefaultJwtDuration = time.Hour * 8 + type Extras struct { UserInfo userinfo.Info JwtDuration time.Duration JwtSingKey string + // CertNotAfter is the real expiry of the client certificate carried in the + // same response. The token is clamped to it. Zero leaves it unclamped. + CertNotAfter time.Time +} + +// tokenDuration resolves the lifetime of the issued token: the requested +// duration, but never past the certificate shipped alongside it. +// +// The two are NOT the same knob even though one value configures both. The +// requested certificate duration is a wish; the signer grants +// min(requested, cluster-signing-duration, signer CA remaining life). Issuing a +// token for the requested duration would let a session outlive the credential +// it authenticates with — the user stays logged in while every user-scoped call +// fails with `x509: certificate has expired`, and there is no refresh endpoint +// to recover from it. Clamping makes the session end with the credential, so +// the user is sent back through login instead. +func tokenDuration(requested time.Duration, notAfter time.Time) time.Duration { + if requested <= 0 { + requested = DefaultJwtDuration + } + + if notAfter.IsZero() { + return requested + } + + if remaining := time.Until(notAfter); remaining < requested { + return remaining + } + + return requested } func Success(w http.ResponseWriter, dat []byte, extras *Extras) (err error) { @@ -30,14 +63,10 @@ func Success(w http.ResponseWriter, dat []byte, extras *Extras) (err error) { out.Groups = nfo.GetGroups() if extras.JwtSingKey != "" { - if extras.JwtDuration <= 0 { - extras.JwtDuration = time.Hour * 8 - } - out.AccessToken, err = jwtutil.CreateToken(jwtutil.CreateTokenOptions{ Username: nfo.GetUserName(), Groups: nfo.GetGroups(), - Duration: extras.JwtDuration, + Duration: tokenDuration(extras.JwtDuration, extras.CertNotAfter), SigningKey: extras.JwtSingKey, }) if err != nil { diff --git a/internal/helpers/encode/success_test.go b/internal/helpers/encode/success_test.go new file mode 100644 index 0000000..43bfc6d --- /dev/null +++ b/internal/helpers/encode/success_test.go @@ -0,0 +1,95 @@ +package encode + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/golang-jwt/jwt/v5" + "github.com/krateoplatformops/authn/internal/helpers/userinfo" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestTokenDuration(t *testing.T) { + now := time.Now() + + tests := []struct { + name string + requested time.Duration + notAfter time.Time + want time.Duration + }{ + { + name: "no certificate leaves the request untouched", + requested: time.Hour * 24, + want: time.Hour * 24, + }, + { + name: "no request falls back to the default", + requested: 0, + want: DefaultJwtDuration, + }, + { + // The signer honoured the request: nothing to clamp. + name: "certificate outlives the request", + requested: time.Hour * 24, + notAfter: now.Add(time.Hour * 720), + want: time.Hour * 24, + }, + { + // The case that motivates the clamp: the signer's CA had two hours + // left, so the 24h session would have outlived its credential. + name: "truncated certificate bounds the session", + requested: time.Hour * 24, + notAfter: now.Add(time.Hour * 2), + want: time.Hour * 2, + }, + { + name: "an unset request is clamped too", + requested: 0, + notAfter: now.Add(time.Minute * 30), + want: time.Minute * 30, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got := tokenDuration(tc.requested, tc.notAfter) + // time.Until moves between the table and the call. + assert.InDelta(t, tc.want, got, float64(time.Second)) + }) + } +} + +// A session must never outlive the credential it was issued with — assert it on +// the token that actually goes over the wire, not just on the helper. +func TestSuccessClampsTokenToCertificate(t *testing.T) { + notAfter := time.Now().Add(time.Hour * 2) + + rec := httptest.NewRecorder() + require.NoError(t, Success(rec, []byte(`{"kind":"Config"}`), &Extras{ + UserInfo: userinfo.NewDefaultUser("alice", "1", []string{"devs"}, userinfo.Extensions{}), + JwtDuration: time.Hour * 24, + JwtSingKey: "AbbraCadabbra", + CertNotAfter: notAfter, + })) + require.Equal(t, http.StatusOK, rec.Code) + + var out struct { + AccessToken string `json:"accessToken"` + } + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &out)) + require.NotEmpty(t, out.AccessToken) + + claims := jwt.MapClaims{} + _, _, err := jwt.NewParser().ParseUnverified(out.AccessToken, claims) + require.NoError(t, err) + + exp, err := claims.GetExpirationTime() + require.NoError(t, err) + assert.WithinDuration(t, notAfter, exp.Time, time.Second*2, + "the token must expire with the certificate, not 24h later") +} diff --git a/internal/helpers/kube/certinfo.go b/internal/helpers/kube/certinfo.go new file mode 100644 index 0000000..c72448c --- /dev/null +++ b/internal/helpers/kube/certinfo.go @@ -0,0 +1,63 @@ +package kube + +import ( + "crypto/x509" + "encoding/base64" + "encoding/pem" + "fmt" + "strings" + "time" +) + +// A signer is free to grant less than the requested `spec.expirationSeconds`: +// the granted lifetime is min(requested, --cluster-signing-duration, signer CA +// remaining life). OpenShift pins the signing duration to 720h and rotates the +// CSR signer CA every 30 days, so a certificate requested for a year can come +// back valid for a day. Nothing downstream may assume the request held — read +// the window off the issued certificate instead. + +// ParseCertificatePEM returns the leaf certificate of a PEM-encoded chain +// (the first CERTIFICATE block, which is what a CSR's status.certificate +// carries first). +func ParseCertificatePEM(data []byte) (*x509.Certificate, error) { + for block, rest := pem.Decode(data); block != nil; block, rest = pem.Decode(rest) { + if block.Type != "CERTIFICATE" { + continue + } + return x509.ParseCertificate(block.Bytes) + } + + return nil, fmt.Errorf("no CERTIFICATE block in PEM data") +} + +// ParseCertificateBase64 returns the leaf certificate of a base64-encoded PEM +// chain — the `client-certificate-data` encoding used in kubeconfigs and in the +// `-clientconfig` Secrets. +func ParseCertificateBase64(in string) (*x509.Certificate, error) { + raw, err := base64.StdEncoding.DecodeString(strings.TrimSpace(in)) + if err != nil { + return nil, fmt.Errorf("decoding base64 certificate: %w", err) + } + + return ParseCertificatePEM(raw) +} + +// Lifetime is the granted validity window of a certificate. +func Lifetime(crt *x509.Certificate) time.Duration { + return crt.NotAfter.Sub(crt.NotBefore) +} + +// shortfallRatio is the fraction of the requested duration below which a +// granted lifetime counts as materially short. +const shortfallRatio = 0.9 + +// MateriallyShorter reports whether the signer granted noticeably less than was +// requested — the symptom of a capped signing duration or of a signer CA whose +// own remaining life is now the binding constraint. +func MateriallyShorter(granted, requested time.Duration) bool { + if requested <= 0 { + return false + } + + return granted < time.Duration(float64(requested)*shortfallRatio) +} diff --git a/internal/helpers/kube/certinfo_test.go b/internal/helpers/kube/certinfo_test.go new file mode 100644 index 0000000..93fc2b2 --- /dev/null +++ b/internal/helpers/kube/certinfo_test.go @@ -0,0 +1,101 @@ +package kube + +import ( + "crypto/rand" + "crypto/rsa" + "crypto/x509" + "crypto/x509/pkix" + "encoding/base64" + "encoding/pem" + "math/big" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// issue mints a self-signed certificate with the given validity window, PEM +// encoded the way a CSR's status.certificate carries it. +func issue(t *testing.T, notBefore, notAfter time.Time) []byte { + t.Helper() + + key, err := rsa.GenerateKey(rand.Reader, 2048) + require.NoError(t, err) + + tpl := &x509.Certificate{ + SerialNumber: big.NewInt(1), + Subject: pkix.Name{CommonName: "authn", Organization: []string{"authn"}}, + NotBefore: notBefore, + NotAfter: notAfter, + KeyUsage: x509.KeyUsageDigitalSignature, + ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageClientAuth}, + } + + der, err := x509.CreateCertificate(rand.Reader, tpl, tpl, &key.PublicKey, key) + require.NoError(t, err) + + return pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der}) +} + +func TestParseCertificate(t *testing.T) { + notBefore := time.Now().Add(-time.Hour).UTC().Truncate(time.Second) + notAfter := notBefore.Add(time.Hour * 720) + crtPEM := issue(t, notBefore, notAfter) + + t.Run("from PEM", func(t *testing.T) { + crt, err := ParseCertificatePEM(crtPEM) + require.NoError(t, err) + assert.Equal(t, notAfter, crt.NotAfter) + assert.Equal(t, "authn", crt.Subject.CommonName) + }) + + t.Run("from base64 PEM", func(t *testing.T) { + crt, err := ParseCertificateBase64(base64.StdEncoding.EncodeToString(crtPEM)) + require.NoError(t, err) + assert.Equal(t, notAfter, crt.NotAfter) + }) + + // status.certificate may carry the signer's chain after the leaf; the leaf + // is the one whose expiry bounds the session. + t.Run("leaf of a chain", func(t *testing.T) { + intermediate := issue(t, notBefore, notBefore.Add(time.Hour*24*3650)) + crt, err := ParseCertificatePEM(append(append([]byte{}, crtPEM...), intermediate...)) + require.NoError(t, err) + assert.Equal(t, notAfter, crt.NotAfter) + }) + + t.Run("lifetime", func(t *testing.T) { + crt, err := ParseCertificatePEM(crtPEM) + require.NoError(t, err) + assert.Equal(t, time.Hour*720, Lifetime(crt)) + }) +} + +func TestParseCertificateRejectsGarbage(t *testing.T) { + _, err := ParseCertificateBase64("") + assert.Error(t, err, "an empty value is not a certificate") + + _, err = ParseCertificateBase64("not base64 at all!!") + assert.Error(t, err) + + _, err = ParseCertificatePEM([]byte("-----BEGIN CERTIFICATE REQUEST-----\nAAAA\n-----END CERTIFICATE REQUEST-----\n")) + assert.Error(t, err, "a CSR is not a certificate") +} + +func TestMateriallyShorter(t *testing.T) { + year := time.Hour * 8760 + + // OpenShift's 720h cap against a one-year request: the whole point of the + // warning. + assert.True(t, MateriallyShorter(time.Hour*720, year)) + // A signer CA with a day left. + assert.True(t, MateriallyShorter(time.Hour*24, year)) + // Granted in full, and the few seconds a signer shaves off it. + assert.False(t, MateriallyShorter(year, year)) + assert.False(t, MateriallyShorter(year-time.Minute, year)) + // The 24h login default, which sits far below every cap. + assert.False(t, MateriallyShorter(time.Hour*24, time.Hour*24)) + // No request to compare against. + assert.False(t, MateriallyShorter(time.Hour, 0)) +} diff --git a/internal/helpers/kube/config/build.go b/internal/helpers/kube/config/build.go index 3128a7e..d7f8173 100644 --- a/internal/helpers/kube/config/build.go +++ b/internal/helpers/kube/config/build.go @@ -17,7 +17,13 @@ import ( ) type Generator interface { - Generate(user userinfo.Info) ([]byte, error) + // Generate mints a client certificate for user, stores it as a + // `-clientconfig` Secret and returns the kubeconfig document + // together with the certificate's REAL expiry. The signer may grant less + // than the requested duration, so callers must bound anything derived from + // the credential — the login JWT above all — by the returned notAfter and + // never by the duration they asked for. + Generate(user userinfo.Info) (dat []byte, notAfter time.Time, err error) } type GeneratorOption func(*kubeconfigGenerator) @@ -93,23 +99,25 @@ type kubeconfigGenerator struct { log zerolog.Logger } -func (g *kubeconfigGenerator) Generate(userInfo userinfo.Info) ([]byte, error) { +func (g *kubeconfigGenerator) Generate(userInfo userinfo.Info) ([]byte, time.Time, error) { + var notAfter time.Time + if len(g.caData) == 0 { caCrt, err := configmaps.CACrt(context.Background(), g.restconfig) if err != nil { - return nil, err + return nil, notAfter, err } g.caData = caCrt } - certInfo, clusterInfo, err := g.generateCertAndClusterInfo(userInfo) + certInfo, clusterInfo, notAfter, err := g.generateCertAndClusterInfo(userInfo) if err != nil { - return nil, err + return nil, notAfter, err } err = g.storeCertAndClusterInfo(userInfo.GetUserName(), certInfo, clusterInfo) if err != nil { - return nil, err + return nil, notAfter, err } c := KubeConfig{ @@ -141,10 +149,10 @@ func (g *kubeconfigGenerator) Generate(userInfo userinfo.Info) ([]byte, error) { out, err := json.Marshal(c) if err != nil { - return nil, fmt.Errorf("converting generated config to json: %w", err) + return nil, notAfter, fmt.Errorf("converting generated config to json: %w", err) } - return out, nil + return out, notAfter, nil } func (g *kubeconfigGenerator) storeCertAndClusterInfo(name string, certInfo CertInfo, clusterInfo ClusterInfo) error { @@ -159,29 +167,30 @@ func (g *kubeconfigGenerator) storeCertAndClusterInfo(name string, certInfo Cert return g.store.Put(name, &nfo) } -func (g *kubeconfigGenerator) generateCertAndClusterInfo(userInfo userinfo.Info) (certInfo CertInfo, clusterInfo ClusterInfo, err error) { +func (g *kubeconfigGenerator) generateCertAndClusterInfo(userInfo userinfo.Info) (certInfo CertInfo, clusterInfo ClusterInfo, notAfter time.Time, err error) { if len(g.kubernetesURL) == 0 { host, port := os.Getenv("KUBERNETES_SERVICE_HOST"), os.Getenv("KUBERNETES_SERVICE_PORT") if len(host) == 0 || len(port) == 0 { - return certInfo, clusterInfo, rest.ErrNotInCluster + return certInfo, clusterInfo, notAfter, rest.ErrNotInCluster } g.kubernetesURL = "https://" + net.JoinHostPort(host, port) } cli, err := kubernetes.NewForConfig(g.restconfig) if err != nil { - return certInfo, clusterInfo, err + return certInfo, clusterInfo, notAfter, err } - cert, key, err := generateClientCertAndKey(cli, g.log, generateClientCertAndKeyOpts{ + cert, key, leaf, err := generateClientCertAndKey(cli, g.log, generateClientCertAndKeyOpts{ userID: userInfo.GetID(), username: userInfo.GetUserName(), groups: userInfo.GetGroups(), duration: g.certDuration, }) if err != nil { - return certInfo, clusterInfo, err + return certInfo, clusterInfo, notAfter, err } + notAfter = leaf.NotAfter clusterInfo.CertificateAuthorityData = g.caData //clusterInfo.ProxyURL = g.proxyURL diff --git a/internal/helpers/kube/config/gen.go b/internal/helpers/kube/config/gen.go index 546a184..8eef9ec 100644 --- a/internal/helpers/kube/config/gen.go +++ b/internal/helpers/kube/config/gen.go @@ -21,15 +21,19 @@ type generateClientCertAndKeyOpts struct { groups []string } -func generateClientCertAndKey(client kubernetes.Interface, l zerolog.Logger, o generateClientCertAndKeyOpts) (string, string, error) { +// generateClientCertAndKey mints a client certificate through the Kubernetes CSR +// API and returns it base64-PEM encoded together with its key and the PARSED +// leaf, whose NotAfter is the only trustworthy expiry: the signer may grant less +// than o.duration (see internal/helpers/kube/certinfo.go). +func generateClientCertAndKey(client kubernetes.Interface, l zerolog.Logger, o generateClientCertAndKeyOpts) (string, string, *x509.Certificate, error) { key, err := kube.NewPrivateKey() if err != nil { - return "", "", err + return "", "", nil, err } req, err := kube.NewCertificateRequest(key, o.username, o.groups) if err != nil { - return "", "", err + return "", "", nil, err } // csr object from csr bytes @@ -39,18 +43,18 @@ func generateClientCertAndKey(client kubernetes.Interface, l zerolog.Logger, o g err = kube.CreateCertificateSigningRequests(client, csr) if err != nil { if !errors.IsAlreadyExists(err) { - return "", "", fmt.Errorf("creating CSR kubernetes object: %w", err) + return "", "", nil, fmt.Errorf("creating CSR kubernetes object: %w", err) } l.Debug().Str("crs.name", csr.Name).Msg("certificate signing request already exists") if err := kube.DeleteCertificateSigningRequest(client, csr.Name); err != nil { - return "", "", fmt.Errorf("deleting existing CSR kubernetes object: %w", err) + return "", "", nil, fmt.Errorf("deleting existing CSR kubernetes object: %w", err) } l.Debug().Str("crs.name", csr.Name).Msg("existing certificate signing request deleted") if err := kube.CreateCertificateSigningRequests(client, csr); err != nil { - return "", "", fmt.Errorf("creating CSR kubernetes object: %w", err) + return "", "", nil, fmt.Errorf("creating CSR kubernetes object: %w", err) } } @@ -59,7 +63,7 @@ func generateClientCertAndKey(client kubernetes.Interface, l zerolog.Logger, o g // approve the csr err = kube.ApproveCertificateSigningRequest(client, csr) if err != nil { - return "", "", err + return "", "", nil, err } l.Debug().Str("crs.name", csr.Name).Msg("approved certificate signing request") @@ -67,19 +71,43 @@ func generateClientCertAndKey(client kubernetes.Interface, l zerolog.Logger, o g l.Debug().Str("crs.name", csr.Name).Msg("waiting for certificate...") err = kube.WaitForCertificate(client, csr.Name) if err != nil { - return "", "", err + return "", "", nil, err } crt, err := kube.Certificate(client, csr.Name) if err != nil { - return "", "", err + return "", "", nil, err + } + + leaf, err := kube.ParseCertificatePEM(crt) + if err != nil { + return "", "", nil, fmt.Errorf("parsing issued certificate: %w", err) + } + + granted := kube.Lifetime(leaf) + l.Debug(). + Str("crs.name", csr.Name). + Dur("requested", o.duration). + Dur("granted", granted). + Time("notAfter", leaf.NotAfter). + Msg("certificate acquired") + + // A capped signer is invisible unless it is called out: the request + // succeeds, only shorter. Warn at issue time when the shortfall is material, + // so the cause is in the log the first time a session dies early. + if kube.MateriallyShorter(granted, o.duration) { + l.Warn(). + Str("crs.name", csr.Name). + Dur("requested", o.duration). + Dur("granted", granted). + Time("notAfter", leaf.NotAfter). + Msg("signer granted a materially shorter certificate than requested") } - l.Debug().Str("crs.name", csr.Name).Msg("certificate acquired") crtStr := base64.StdEncoding.EncodeToString(crt) keyStr := base64.StdEncoding.EncodeToString(pem.EncodeToMemory(&pem.Block{ Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(key), })) - return crtStr, keyStr, nil + return crtStr, keyStr, leaf, nil } diff --git a/internal/helpers/kube/config/storage/annotations_test.go b/internal/helpers/kube/config/storage/annotations_test.go new file mode 100644 index 0000000..60e9e57 --- /dev/null +++ b/internal/helpers/kube/config/storage/annotations_test.go @@ -0,0 +1,60 @@ +package storage + +import ( + "crypto/rand" + "crypto/rsa" + "crypto/x509" + "crypto/x509/pkix" + "encoding/base64" + "encoding/pem" + "math/big" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestCertValidityAnnotations(t *testing.T) { + notBefore := time.Date(2026, 9, 4, 10, 0, 0, 0, time.UTC) + // A one-year request that the signer capped at OpenShift's 720h: the + // annotations must report what was granted, never what was asked for. + notAfter := notBefore.Add(time.Hour * 720) + + got := CertValidityAnnotations(certificate(t, notBefore, notAfter)) + + assert.Equal(t, map[string]string{ + NotBeforeAnnotation: "2026-09-04T10:00:00Z", + NotAfterAnnotation: "2026-10-04T10:00:00Z", + }, got) +} + +// An unreadable certificate must not block storing the credential: the +// annotations are observability, and Put stamps whatever comes back. +func TestCertValidityAnnotationsTolerateGarbage(t *testing.T) { + assert.Nil(t, CertValidityAnnotations("")) + assert.Nil(t, CertValidityAnnotations("not base64 at all!!")) + assert.Nil(t, CertValidityAnnotations(base64.StdEncoding.EncodeToString([]byte("not a pem block")))) +} + +// certificate mints a self-signed certificate with the given validity window, +// encoded the way a clientconfig Secret stores it (base64 of PEM). +func certificate(t *testing.T, notBefore, notAfter time.Time) string { + t.Helper() + + key, err := rsa.GenerateKey(rand.Reader, 2048) + require.NoError(t, err) + + tpl := &x509.Certificate{ + SerialNumber: big.NewInt(1), + Subject: pkix.Name{CommonName: "authn"}, + NotBefore: notBefore, + NotAfter: notAfter, + } + + der, err := x509.CreateCertificate(rand.Reader, tpl, tpl, &key.PublicKey, key) + require.NoError(t, err) + + return base64.StdEncoding.EncodeToString( + pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der})) +} diff --git a/internal/helpers/kube/config/storage/storage.go b/internal/helpers/kube/config/storage/storage.go index b090e65..813b266 100644 --- a/internal/helpers/kube/config/storage/storage.go +++ b/internal/helpers/kube/config/storage/storage.go @@ -3,8 +3,10 @@ package storage import ( "context" "fmt" + "time" "github.com/krateoplatformops/authn/apis/core" + "github.com/krateoplatformops/authn/internal/helpers/kube" "github.com/krateoplatformops/authn/internal/helpers/kube/secrets" "github.com/krateoplatformops/authn/internal/helpers/kube/util" "github.com/krateoplatformops/plumbing/kubeutil" @@ -19,8 +21,31 @@ const ( CALabel = "certificate-authority-data" ProxyUrlLabel = "proxy-url" ServerUrlLabel = "server-url" + + // NotBeforeAnnotation and NotAfterAnnotation record the validity window the + // signer actually GRANTED, which can be far shorter than the one requested + // (see internal/helpers/kube/certinfo.go). They are the only place the real + // expiry of a stored credential is observable without decoding the Secret. + NotBeforeAnnotation = "authn.krateo.io/certificate-not-before" + NotAfterAnnotation = "authn.krateo.io/certificate-not-after" ) +// CertValidityAnnotations reports the granted validity window of a base64-PEM +// client certificate, ready to be stamped onto the Secret that stores it. An +// unparseable certificate yields no annotations rather than an error: the +// annotations are observability, never a precondition for storing a credential. +func CertValidityAnnotations(certData string) map[string]string { + crt, err := kube.ParseCertificateBase64(certData) + if err != nil { + return nil + } + + return map[string]string{ + NotBeforeAnnotation: crt.NotBefore.UTC().Format(time.RFC3339), + NotAfterAnnotation: crt.NotAfter.UTC().Format(time.RFC3339), + } +} + type AuthInfo struct { Server string `json:"server"` ProxyURL string `json:"proxy-url,omitempty"` @@ -53,6 +78,7 @@ func (st *secretStore) Put(name string, nfo *AuthInfo) error { sec := corev1.Secret{} sec.SetName(fmt.Sprintf("%s-clientconfig", kubeutil.MakeDNS1123Compatible(name))) sec.SetNamespace(ns) + sec.SetAnnotations(CertValidityAnnotations(nfo.CertData)) sec.StringData = map[string]string{ CALabel: nfo.CAData, ClientCertLabel: nfo.CertData, diff --git a/internal/routes/auth/basic/login.go b/internal/routes/auth/basic/login.go index a48b5c7..ccbe6b3 100644 --- a/internal/routes/auth/basic/login.go +++ b/internal/routes/auth/basic/login.go @@ -84,7 +84,7 @@ func (r *loginRoute) Handler() http.HandlerFunc { Str("groups", strings.Join(user.GetGroups(), ",")). Msg("basic auth succeded") - dat, err := r.gen.Generate(user) + dat, notAfter, err := r.gen.Generate(user) if err != nil { log.Err(err).Msg("kubeconfig creation failure") encode.InternalError(wri, err) @@ -97,9 +97,10 @@ func (r *loginRoute) Handler() http.HandlerFunc { } encode.Success(wri, dat, &encode.Extras{ - UserInfo: user, - JwtDuration: r.jwtDuration, - JwtSingKey: r.jwtSignKey, + UserInfo: user, + JwtDuration: r.jwtDuration, + JwtSingKey: r.jwtSignKey, + CertNotAfter: notAfter, }) } } diff --git a/internal/routes/auth/ldap/login.go b/internal/routes/auth/ldap/login.go index 72f915c..5a88043 100644 --- a/internal/routes/auth/ldap/login.go +++ b/internal/routes/auth/ldap/login.go @@ -109,7 +109,7 @@ func (r *loginRoute) Handler() http.HandlerFunc { return } - dat, err := r.gen.Generate(nfo) + dat, notAfter, err := r.gen.Generate(nfo) if err != nil { log.Err(err).Msg("kubeconfig creation failure") encode.InternalError(wri, err) @@ -117,9 +117,10 @@ func (r *loginRoute) Handler() http.HandlerFunc { } encode.Success(wri, dat, &encode.Extras{ - UserInfo: nfo, - JwtDuration: r.jwtDuration, - JwtSingKey: r.jwtSignKey, + UserInfo: nfo, + JwtDuration: r.jwtDuration, + JwtSingKey: r.jwtSignKey, + CertNotAfter: notAfter, }) } } diff --git a/internal/routes/auth/oauth/login.go b/internal/routes/auth/oauth/login.go index 642928c..074b00e 100644 --- a/internal/routes/auth/oauth/login.go +++ b/internal/routes/auth/oauth/login.go @@ -140,7 +140,7 @@ func (r *loginRoute) Handler() http.HandlerFunc { Strs("groups", user.GetGroups()). Msg("user info successfully fetched") - dat, err := r.gen.Generate(user) + dat, notAfter, err := r.gen.Generate(user) if err != nil { log.Err(err).Msg("kubeconfig creation failure") encode.InternalError(wri, err) @@ -148,9 +148,10 @@ func (r *loginRoute) Handler() http.HandlerFunc { } encode.Success(wri, dat, &encode.Extras{ - UserInfo: user, - JwtDuration: r.jwtDuration, - JwtSingKey: r.jwtSignKey, + UserInfo: user, + JwtDuration: r.jwtDuration, + JwtSingKey: r.jwtSignKey, + CertNotAfter: notAfter, }) } } diff --git a/internal/routes/auth/oidc/login.go b/internal/routes/auth/oidc/login.go index 61f28e2..6c9d91e 100644 --- a/internal/routes/auth/oidc/login.go +++ b/internal/routes/auth/oidc/login.go @@ -129,7 +129,7 @@ func (r *loginRoute) Handler() http.HandlerFunc { } log.Debug().Str("name", name).Msg("generating secret from oidc idtoken") - dat, err := r.gen.Generate(nfo) + dat, notAfter, err := r.gen.Generate(nfo) if err != nil { log.Err(err).Msg("kubeconfig creation failure") encode.InternalError(wri, err) @@ -137,9 +137,10 @@ func (r *loginRoute) Handler() http.HandlerFunc { } encode.Success(wri, dat, &encode.Extras{ - UserInfo: nfo, - JwtDuration: r.jwtDuration, - JwtSingKey: r.jwtSignKey, + UserInfo: nfo, + JwtDuration: r.jwtDuration, + JwtSingKey: r.jwtSignKey, + CertNotAfter: notAfter, }) } } diff --git a/main.go b/main.go index d9005a1..52fdd93 100644 --- a/main.go +++ b/main.go @@ -11,6 +11,7 @@ import ( "syscall" "time" + "github.com/krateoplatformops/authn/internal/certrenewal" "github.com/krateoplatformops/authn/internal/env" kubeconfig "github.com/krateoplatformops/authn/internal/helpers/kube/config" "github.com/krateoplatformops/authn/internal/helpers/kube/util" @@ -26,7 +27,6 @@ import ( "github.com/krateoplatformops/authn/internal/routes/health" xcontext "github.com/krateoplatformops/plumbing/context" "github.com/krateoplatformops/plumbing/jwtutil" - "github.com/krateoplatformops/plumbing/signup" "github.com/rs/zerolog" "k8s.io/client-go/rest" "k8s.io/client-go/tools/clientcmd" @@ -49,7 +49,16 @@ func main() { corsOn := flag.Bool("cors", env.Bool("AUTHN_CORS", true), "enable or disable CORS") servicePort := flag.Int("port", env.Int("AUTHN_PORT", 8082), "port to listen on") certExpiresIn := flag.Duration("cert-expires", - env.Duration("AUTHN_KUBECONFIG_CRT_EXPIRES_IN", time.Hour*24), "generated certificate duration (default: 24h)") + env.Duration("AUTHN_KUBECONFIG_CRT_EXPIRES_IN", time.Hour*24), "requested duration of a login certificate (default: 24h)") + serviceCertExpiresIn := flag.Duration("service-cert-expires", + env.Duration("AUTHN_SERVICE_CRT_EXPIRES_IN", certrenewal.DefaultDuration), + "requested duration of authn's own service certificate (default: 8760h)") + certRenewalOn := flag.Bool("cert-renewal", + env.Bool("AUTHN_CRT_RENEWAL_ENABLED", true), + "keep authn's own service certificate renewed in the background") + certRenewalThreshold := flag.Float64("cert-renewal-threshold", + env.Float64("AUTHN_CRT_RENEWAL_THRESHOLD", certrenewal.DefaultThreshold), + "fraction of the GRANTED certificate lifetime after which it is re-issued (default: 0.66)") clusterName := flag.String("kubeconfig-cluster-name", env.String("AUTHN_KUBECONFIG_CLUSTER_NAME", "krateo"), "cluster name for generated kubeconfig") @@ -105,7 +114,9 @@ func main() { Str("port", fmt.Sprintf("%d", *servicePort)). Str("clusterName", *clusterName). Str("kubernetesURL", *kubernetesURL). - Dur("certExpire", *certExpiresIn) + Dur("certExpire", *certExpiresIn). + Dur("serviceCertExpire", *serviceCertExpiresIn). + Bool("certRenewal", *certRenewalOn) if *dumpEnv { evt = evt.Strs("env-vars", os.Environ()) @@ -227,16 +238,44 @@ func main() { }...) defer stop() - // Create authn clientconfig to call snowplow's RESTActions - _, _ = signup.Do(context.TODO(), signup.Options{ - RestConfig: cfg, - Namespace: *storageNamespace, - CAData: string(cfg.CAData), - ServerURL: *kubernetesURL, - CertDuration: time.Hour * 8760, // 1 year - Username: *authnUsername, - UserGroups: []string{"authn"}, + // authn's own clientconfig — the identity snowplow presents when it runs + // RESTActions on authn's behalf. Issued synchronously here, as it always + // was, and then kept alive: the signer may grant far less than the year we + // ask for, and nothing but a pod restart used to re-issue it. + renewer, err := certrenewal.New(certrenewal.Options{ + RestConfig: cfg, + Namespace: *storageNamespace, + CAData: string(cfg.CAData), + ServerURL: *kubernetesURL, + Username: *authnUsername, + Groups: []string{"authn"}, + Duration: *serviceCertExpiresIn, + Threshold: *certRenewalThreshold, + Log: log, }) + switch { + case err != nil: + // A clientconfig authn cannot create has never been a reason to refuse + // logins — only snowplow-backed identity enrichment degrades — so this + // stays as non-fatal as the fire-and-forget signup it replaces. + log.Err(err).Msg("client certificate renewal unavailable") + + default: + // This read is the only one until renewal falls due: the wait it + // returns is handed straight to the loop. + wait, err := renewer.Ensure(context.Background()) + if err != nil { + log.Err(err).Msg("issuing authn clientconfig") + } + + if *certRenewalOn { + go renewer.Run(ctx, wait) + } else { + log.Warn(). + Str("secret", renewer.SecretName()). + Msg("client certificate renewal is disabled; the stored certificate will expire without a restart") + } + } go func() { atomic.StoreInt32(&healthy, 1) diff --git a/scripts/verify-cert-renewal.sh b/scripts/verify-cert-renewal.sh new file mode 100755 index 0000000..ca0a270 --- /dev/null +++ b/scripts/verify-cert-renewal.sh @@ -0,0 +1,221 @@ +#!/usr/bin/env bash +# Verify client certificate renewal on the cluster kubectl currently points at. +# +# Certificate handling differs per Kubernetes distribution: whether +# spec.expirationSeconds is honoured at all, what --cluster-signing-duration +# caps it to, and how/when the signer CA rotates. None of that can be reasoned +# about from the code, so this script measures it. Run it once per flavour +# (kind, minikube, k3s/k3d, EKS, AKS, GKE, OpenShift) and record the output. +# +# It answers three questions: +# 1. What NotAfter did this signer actually grant? +# 2. Does renewal fire before expiry? +# 3. Is the renewed certificate accepted by the apiserver? +# +# Question 2 is the slow one: by default it only reports when renewal is due. +# Pass --wait to force it now by rewinding the stored annotations, or +# --short to reinstall authn with a lifetime short enough to watch a +# real renewal end to end (recommended: 15m, so the whole cycle takes ~10m). +# +# Usage: +# scripts/verify-cert-renewal.sh [-n NAMESPACE] [-d DEPLOYMENT] [--wait] [--short DURATION] + +set -euo pipefail + +NAMESPACE="${NAMESPACE:-krateo-system}" +DEPLOYMENT="${DEPLOYMENT:-authn}" +SECRET="authn-clientconfig" +FORCE=0 +SHORT="" + +while [[ $# -gt 0 ]]; do + case "$1" in + -n|--namespace) NAMESPACE="$2"; shift 2 ;; + -d|--deployment) DEPLOYMENT="$2"; shift 2 ;; + --wait) FORCE=1; shift ;; + --short) SHORT="$2"; shift 2 ;; + -h|--help) sed -n '2,22p' "$0"; exit 0 ;; + *) echo "unknown argument: $1" >&2; exit 2 ;; + esac +done + +for bin in kubectl openssl; do + command -v "$bin" >/dev/null || { echo "missing required tool: $bin" >&2; exit 1; } +done + +say() { printf '\n\033[1m== %s\033[0m\n' "$*"; } +fail() { printf '\033[31mFAIL\033[0m %s\n' "$*"; FAILED=1; } +pass() { printf '\033[32mok\033[0m %s\n' "$*"; } +FAILED=0 + +# ---------------------------------------------------------------- distribution +say "cluster" +kubectl version -o json 2>/dev/null | sed -n 's/.*"gitVersion": *"\([^"]*\)".*/server: \1/p' | head -1 +FLAVOUR="unknown" +if kubectl api-resources --api-group=config.openshift.io 2>/dev/null | grep -q clusterversions; then + FLAVOUR="openshift" +elif kubectl get nodes -o jsonpath='{.items[0].metadata.labels}' 2>/dev/null | grep -q 'k3s'; then + FLAVOUR="k3s" +elif kubectl config current-context 2>/dev/null | grep -q '^kind-'; then + FLAVOUR="kind" +elif kubectl config current-context 2>/dev/null | grep -q 'minikube'; then + FLAVOUR="minikube" +elif kubectl get nodes -o jsonpath='{.items[0].spec.providerID}' 2>/dev/null | grep -q '^aws'; then + FLAVOUR="eks" +elif kubectl get nodes -o jsonpath='{.items[0].spec.providerID}' 2>/dev/null | grep -q '^azure'; then + FLAVOUR="aks" +elif kubectl get nodes -o jsonpath='{.items[0].spec.providerID}' 2>/dev/null | grep -q '^gce'; then + FLAVOUR="gke" +fi +echo "flavour: $FLAVOUR" + +# setting reads the effective value of an authn env var: the Deployment's own +# container env wins over a ConfigMap (that is how Kubernetes resolves it), and +# an unset var falls back to the binary's default. +setting() { + local key="$1" default="$2" val + val=$(kubectl -n "$NAMESPACE" get deploy "$DEPLOYMENT" \ + -o jsonpath="{.spec.template.spec.containers[0].env[?(@.name=='$key')].value}" 2>/dev/null) + [[ -n "$val" ]] || val=$(kubectl -n "$NAMESPACE" get configmap authn \ + -o jsonpath="{.data.$key}" 2>/dev/null) + echo "${val:-$default}" +} + +# --------------------------------------------------------------- optional: short lifetime +if [[ -n "$SHORT" ]]; then + say "reconfiguring authn for a $SHORT service certificate" + # Patch the Deployment's container env rather than a ConfigMap: an explicit + # env entry wins over envFrom, so this works whether authn was deployed from + # manifests/ or from a chart. + kubectl -n "$NAMESPACE" set env "deploy/$DEPLOYMENT" \ + AUTHN_SERVICE_CRT_EXPIRES_IN="$SHORT" + kubectl -n "$NAMESPACE" delete secret "$SECRET" --ignore-not-found + kubectl -n "$NAMESPACE" rollout status "deploy/$DEPLOYMENT" --timeout=180s +fi + +# ---------------------------------------------------------------- 1. granted window +say "1. granted validity window" +kubectl -n "$NAMESPACE" get secret "$SECRET" >/dev/null || { + echo "secret $SECRET not found in $NAMESPACE" >&2; exit 1; } + +CERT="$(mktemp)"; trap 'rm -f "$CERT" "${CERT}.key" "${CERT}.ca" "${CERT}.kubeconfig"' EXIT +kubectl -n "$NAMESPACE" get secret "$SECRET" \ + -o jsonpath='{.data.client-certificate-data}' | base64 -d | base64 -d > "$CERT" + +openssl x509 -in "$CERT" -noout -subject -dates + +NOT_BEFORE_EPOCH=$(date -u -j -f "%b %e %T %Y" "$(openssl x509 -in "$CERT" -noout -startdate | cut -d= -f2 | sed 's/ GMT//')" +%s 2>/dev/null \ + || date -u -d "$(openssl x509 -in "$CERT" -noout -startdate | cut -d= -f2)" +%s) +NOT_AFTER_EPOCH=$(date -u -j -f "%b %e %T %Y" "$(openssl x509 -in "$CERT" -noout -enddate | cut -d= -f2 | sed 's/ GMT//')" +%s 2>/dev/null \ + || date -u -d "$(openssl x509 -in "$CERT" -noout -enddate | cut -d= -f2)" +%s) +NOW=$(date -u +%s) + +GRANTED_H=$(( (NOT_AFTER_EPOCH - NOT_BEFORE_EPOCH) / 3600 )) +REQUESTED=$(setting AUTHN_SERVICE_CRT_EXPIRES_IN 8760h) +echo "requested: $REQUESTED granted: ${GRANTED_H}h" + +# The annotation is what makes the granted window observable without openssl; +# it is the headline deliverable of the change. +ANN=$(kubectl -n "$NAMESPACE" get secret "$SECRET" \ + -o jsonpath='{.metadata.annotations.authn\.krateo\.io/certificate-not-after}') +if [[ -n "$ANN" ]]; then + pass "not-after annotation present: $ANN" +else + fail "not-after annotation missing (authn needs get+update on secrets; see manifests/deploy.local.yaml)" +fi + +if (( NOT_AFTER_EPOCH <= NOW )); then + fail "the stored certificate is ALREADY EXPIRED" +else + pass "valid for $(( (NOT_AFTER_EPOCH - NOW) / 3600 ))h more" +fi + +# --------------------------------------------------------------- 2. renewal fires +say "2. renewal fires before expiry" +THRESHOLD=$(setting AUTHN_CRT_RENEWAL_THRESHOLD 0.66) +RENEW_AT=$(awk -v nb="$NOT_BEFORE_EPOCH" -v na="$NOT_AFTER_EPOCH" -v t="$THRESHOLD" 'BEGIN{printf "%d", nb + (na-nb)*t}') +echo "threshold: $THRESHOLD renewal due: $(date -u -r "$RENEW_AT" 2>/dev/null || date -u -d "@$RENEW_AT")" + +SERIAL_BEFORE=$(openssl x509 -in "$CERT" -noout -serial | cut -d= -f2) + +if (( FORCE )); then + # Drive the renewal point into the past by collapsing the threshold, then + # restart so the loop re-derives it. This exercises the real "passed its + # renewal point" branch. + # + # Rewinding the not-after ANNOTATION would do nothing: the loop decides off the + # stored certificate, never the annotation, so a rewound annotation is ignored + # and the certificate is judged current. + # + # While the override is in place a re-issue happens every minSleep (a minute), + # since each fresh certificate is immediately due again — so it is restored as + # soon as a new serial appears. + echo "forcing: collapsing the renewal threshold and restarting authn" + kubectl -n "$NAMESPACE" set env "deploy/$DEPLOYMENT" AUTHN_CRT_RENEWAL_THRESHOLD=0.0001 + kubectl -n "$NAMESPACE" rollout status "deploy/$DEPLOYMENT" --timeout=180s + # Restore on any exit path, not just the happy one. + trap 'kubectl -n "$NAMESPACE" set env "deploy/$DEPLOYMENT" AUTHN_CRT_RENEWAL_THRESHOLD- >/dev/null 2>&1 || true; rm -f "$CERT" "${CERT}.key" "${CERT}.ca" "${CERT}.kubeconfig"' EXIT +fi + +if (( FORCE )) || (( NOW >= RENEW_AT )); then + echo "waiting up to 5m for a new serial..." + for _ in $(seq 1 60); do + sleep 5 + kubectl -n "$NAMESPACE" get secret "$SECRET" \ + -o jsonpath='{.data.client-certificate-data}' | base64 -d | base64 -d > "$CERT" + SERIAL_NOW=$(openssl x509 -in "$CERT" -noout -serial | cut -d= -f2) + [[ "$SERIAL_NOW" != "$SERIAL_BEFORE" ]] && break + done + if [[ "${SERIAL_NOW:-$SERIAL_BEFORE}" != "$SERIAL_BEFORE" ]]; then + pass "certificate re-issued (serial $SERIAL_BEFORE -> $SERIAL_NOW)" + else + fail "renewal did not fire; check: kubectl -n $NAMESPACE logs deploy/$DEPLOYMENT | grep certificate" + fi +else + echo "not due yet — re-run with --wait to force, or --short 15m to watch a real cycle" +fi + +# ------------------------------------------------- 3. the apiserver accepts it +say "3. the renewed certificate authenticates" +# Both cert and key are stored base64-of-base64: the Secret encodes the value, +# and the value is itself the base64-PEM a kubeconfig carries. The CA is stored +# the same way (plumbing's kubeutil.CACrt base64-encodes the PEM before storing). +kubectl -n "$NAMESPACE" get secret "$SECRET" -o jsonpath='{.data.client-key-data}' | base64 -d | base64 -d > "${CERT}.key" +kubectl -n "$NAMESPACE" get secret "$SECRET" -o jsonpath='{.data.certificate-authority-data}' | base64 -d | base64 -d > "${CERT}.ca" +SERVER=$(kubectl -n "$NAMESPACE" get secret "$SECRET" -o jsonpath='{.data.server-url}' | base64 -d) + +# Build a standalone kubeconfig from the Secret rather than overriding flags on +# the ambient one: with the admin context still in play, an ambient token could +# authenticate the call and mask a certificate the apiserver actually rejected. +KUBECONFIG_OUT="${CERT}.kubeconfig" +kubectl config --kubeconfig="$KUBECONFIG_OUT" set-cluster verify \ + --server="$SERVER" --certificate-authority="${CERT}.ca" --embed-certs=true >/dev/null +kubectl config --kubeconfig="$KUBECONFIG_OUT" set-credentials verify \ + --client-certificate="$CERT" --client-key="${CERT}.key" --embed-certs=true >/dev/null +kubectl config --kubeconfig="$KUBECONFIG_OUT" set-context verify \ + --cluster=verify --user=verify >/dev/null +kubectl config --kubeconfig="$KUBECONFIG_OUT" use-context verify >/dev/null + +# A SelfSubjectReview echoes back the identity the apiserver derived from the +# presented certificate: proof both that the certificate is accepted and that +# CN/O still map to the expected user and groups. +if OUT=$(kubectl --kubeconfig="$KUBECONFIG_OUT" auth whoami -o json 2>&1); then + pass "accepted by the apiserver" + echo "$OUT" | grep -E '"username"|"groups"' || true +elif kubectl --kubeconfig="$KUBECONFIG_OUT" auth can-i get secrets -n "$NAMESPACE" >/dev/null 2>&1; then + # `auth whoami` needs SelfSubjectReview (k8s >= 1.27); any authenticated call + # proves the same point. can-i answering "no" still means authentication + # succeeded — only a transport/x509 error is a failure here. + pass "accepted by the apiserver (via SelfSubjectAccessReview)" +else + fail "the apiserver rejected the certificate: $OUT" +fi + +# ------------------------------------------------------------------- verdict +say "record this row" +printf '| %-10s | requested %-6s | granted %-6s | renewal %-8s | apiserver %-8s |\n' \ + "$FLAVOUR" "$REQUESTED" "${GRANTED_H}h" \ + "$( ((FAILED)) && echo see-above || echo ok )" \ + "$( ((FAILED)) && echo see-above || echo ok )" + +exit "$FAILED" From b5eea71430553811538104b068aa8b7fe226715a Mon Sep 17 00:00:00 2001 From: EdmondDantes21 Date: Mon, 7 Sep 2026 16:24:55 +0200 Subject: [PATCH 2/3] ci: replace shut-down BuildJet runners with GitHub-hosted ones Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/release-pullrequest.yaml | 6 +++--- .github/workflows/release-tag.yaml | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/.github/workflows/release-pullrequest.yaml b/.github/workflows/release-pullrequest.yaml index 2961d59..40f8508 100644 --- a/.github/workflows/release-pullrequest.yaml +++ b/.github/workflows/release-pullrequest.yaml @@ -13,11 +13,11 @@ jobs: name: Build and Push strategy: matrix: - runner: [buildjet-2vcpu-ubuntu-2204-arm, buildjet-2vcpu-ubuntu-2204] + runner: [ubuntu-24.04-arm, ubuntu-latest] include: - - runner: buildjet-2vcpu-ubuntu-2204-arm + - runner: ubuntu-24.04-arm platform: linux/arm64 - - runner: buildjet-2vcpu-ubuntu-2204 + - runner: ubuntu-latest platform: linux/amd64 runs-on: ${{ matrix.runner }} diff --git a/.github/workflows/release-tag.yaml b/.github/workflows/release-tag.yaml index 1fa027d..23355f2 100644 --- a/.github/workflows/release-tag.yaml +++ b/.github/workflows/release-tag.yaml @@ -13,11 +13,11 @@ jobs: strategy: fail-fast: false matrix: - runner: [buildjet-2vcpu-ubuntu-2204-arm, buildjet-2vcpu-ubuntu-2204] + runner: [ubuntu-24.04-arm, ubuntu-latest] include: - - runner: buildjet-2vcpu-ubuntu-2204-arm + - runner: ubuntu-24.04-arm platform: linux/arm64 - - runner: buildjet-2vcpu-ubuntu-2204 + - runner: ubuntu-latest platform: linux/amd64 runs-on: ${{ matrix.runner }} From d622aa43d4888ee9bfe695b1f9b739c45e23f8c4 Mon Sep 17 00:00:00 2001 From: EdmondDantes21 Date: Mon, 7 Sep 2026 16:45:47 +0200 Subject: [PATCH 3/3] build: base the builder on Debian 12 (bookworm) Debian 11 LTS ended 2026-08-31; its security pool no longer serves the .deb files its index advertises, so apt-get install fails in CI. Co-Authored-By: Claude Opus 5 (1M context) --- Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Dockerfile b/Dockerfile index 9b738bb..adc49b3 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,6 +1,6 @@ # Build environment # ----------------- -FROM golang:1.24.2-bullseye as builder +FROM golang:1.24.2-bookworm as builder LABEL stage=builder ARG DEBIAN_FRONTEND=noninteractive