Skip to content

Commit 5598d94

Browse files
authored
Add unified /api/certificates inventory endpoint (#782)
* Add /api/certificates: unified cert-manager + TLS-secret inventory A cluster's serving certs come from two sources: cert-manager.io Certificate CRs and raw kubernetes.io/tls secrets (cloud-LB, webhook, gateway, or hand-rolled). Expose a single endpoint that unifies them so consumers don't have to know which a cluster uses. pkg/certs.Aggregate owns the merge — dedup of a TLS secret against the cert-manager Certificate that owns it (by secretName), health rating, domain formatting, and most-urgent-first sort. The handler does only k8s I/O: lists cert-manager CRs (absent CRD is fine) and TLS secrets, skipping sealed-secrets controller keypairs and emitting only public cert metadata (issuer/domains/expiry), never tls.key. Read with the SA (inventory pattern) so cert hygiene doesn't vanish for cloud:viewer. * certs: namespace-scope inventory, surface read errors, add tests Review follow-ups on handleCertificates: - Post-filter both legs to the caller's RBAC-allowed namespaces (parseNamespacesForUser + MatchesNamespace), matching handleSecretCertExpiry / handleListPackages. Previously returned every namespace's cert metadata regardless of the caller's scope. - Stop swallowing read errors uniformly: ignore only ErrUnknownDynamicKind (cert-manager absent); log other cert-manager list errors and secret list errors; 503 when both legs fail so the fleet view marks the cluster errored instead of rendering a misleading empty 'no certs'. - Extract secretToCertInput so the skip-chain is unit-testable. - Tests: projectCertManagerCert (issued + not-yet-issued), secretToCertInput skip-chain, namespace-scoped dedup, and exact 7/30-day health boundaries. * certs: floor day count so a just-expired cert reads as expired int(Hours()/24) truncates toward zero, so a cert that expired an hour ago got daysLeft=0 ('expires today') instead of -1. Use math.Floor to match topology.ParsePEMCertificates' semantics; add a test pinning it. * certs: don't return empty 200 when a cert read failed handleCertificates only 503'd when BOTH legs failed. On a cluster without cert-manager the CR leg is benignly absent (cmFailed stays false), so a failed TLS-secret read fell through to an empty 200 — indistinguishable from a healthy cluster with no certs, despite secrets being the only source there. Surface 503 when the result is empty and either leg errored; a partial success (one leg returned certs) still passes through.
1 parent a86f94b commit 5598d94

5 files changed

Lines changed: 702 additions & 0 deletions

File tree

internal/server/certificate.go

Lines changed: 170 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,24 @@
11
package server
22

33
import (
4+
"errors"
45
"log"
56
"net/http"
7+
"time"
8+
9+
corev1 "k8s.io/api/core/v1"
610

711
"github.com/skyhook-io/radar/internal/k8s"
12+
"github.com/skyhook-io/radar/pkg/certs"
813
"github.com/skyhook-io/radar/pkg/topology"
914
)
1015

16+
// sealedSecretsKeyLabel marks a sealed-secrets controller keypair. Those are
17+
// type=kubernetes.io/tls and parse as real (long-lived, self-signed) x509, but
18+
// they're an internal encryption key, not a serving certificate anyone renews —
19+
// so the certificate inventory skips them to stay signal, not noise.
20+
const sealedSecretsKeyLabel = "sealedsecrets.bitnami.com/sealed-secrets-key"
21+
1122
const (
1223
certExpiryWarningDays = 30
1324
certExpiryCriticalDays = 7
@@ -18,6 +29,165 @@ type CertificateInfo = topology.CertificateInfo
1829
type SecretCertificateInfo = topology.SecretCertificateInfo
1930
type CertExpiry = topology.CertExpiry
2031

32+
// handleCertificates returns the cluster's unified certificate inventory:
33+
// cert-manager.io Certificate CRs + raw kubernetes.io/tls secrets, deduped and
34+
// health-rated by pkg/certs. Backs Radar Hub's fleet Certs view; the merge
35+
// lives in pkg/certs so the hub stays a thin pivot.
36+
//
37+
// Read pattern mirrors handleListPackages: the ServiceAccount-backed cache
38+
// performs the read (so cert hygiene survives cloud:viewer, whose K8s `view`
39+
// role excludes secrets), but the response is post-filtered to the caller's
40+
// RBAC-allowed namespaces. Only public certificate metadata (issuer / domains
41+
// / expiry) is emitted — never tls.key. A cluster without cert-manager simply
42+
// contributes no CR rows; that's not an error.
43+
func (s *Server) handleCertificates(w http.ResponseWriter, r *http.Request) {
44+
if !s.requireConnected(w) {
45+
return
46+
}
47+
cache := k8s.GetResourceCache()
48+
if cache == nil {
49+
s.writeError(w, http.StatusServiceUnavailable, "Resource cache not available")
50+
return
51+
}
52+
53+
namespaces := s.parseNamespacesForUser(r)
54+
if noNamespaceAccess(namespaces) {
55+
s.writeJSON(w, []certs.Cert{})
56+
return
57+
}
58+
59+
src := certs.Sources{Now: time.Now().UTC()}
60+
61+
// cert-manager Certificate CRs. ?group disambiguates from other ecosystems'
62+
// "Certificate" kinds. An absent CRD (ErrUnknownDynamicKind) just means the
63+
// cluster doesn't run cert-manager — not an error; the TLS-secret leg still
64+
// answers. Any OTHER error (RBAC denial, discovery not ready, transient)
65+
// would silently drop real cert-manager certs, so log it and remember the
66+
// failure rather than rendering a misleadingly-empty inventory.
67+
cmFailed := false
68+
items, err := cache.ListDynamicWithGroup(r.Context(), "certificates", "", "cert-manager.io")
69+
switch {
70+
case err == nil:
71+
for _, it := range items {
72+
if in := projectCertManagerCert(it.Object); topology.MatchesNamespace(namespaces, in.Namespace) {
73+
src.CertManager = append(src.CertManager, in)
74+
}
75+
}
76+
case errors.Is(err, k8s.ErrUnknownDynamicKind):
77+
// cert-manager not installed — expected.
78+
default:
79+
log.Printf("[certificate] cert-manager Certificate list failed: %v", err)
80+
cmFailed = true
81+
}
82+
83+
// kubernetes.io/tls secrets. We only read tls.crt (the public chain) and
84+
// emit metadata; tls.key never leaves the cluster. A nil error with no
85+
// secrets is a benign deferred-cache state; a non-nil error means we
86+
// genuinely couldn't read secrets.
87+
secFailed := false
88+
provider := k8s.NewTopologyResourceProvider(cache)
89+
if secrets, err := provider.Secrets(); err == nil {
90+
for _, sec := range secrets {
91+
if !topology.MatchesNamespace(namespaces, sec.Namespace) {
92+
continue
93+
}
94+
if in, ok := secretToCertInput(sec); ok {
95+
src.TLSSecrets = append(src.TLSSecrets, in)
96+
}
97+
}
98+
} else {
99+
log.Printf("[certificate] TLS secret list failed: %v", err)
100+
secFailed = true
101+
}
102+
103+
// An empty result caused by a read error reads as a healthy "no certs"
104+
// cluster. Surface it so the fleet view marks the cluster errored rather
105+
// than silently green. This matters most on a cluster without cert-manager,
106+
// where TLS secrets are the only source: there cmFailed stays false (an
107+
// absent CRD is benign), so gating on both legs would miss a failed secret
108+
// read. Gate on "no data AND something errored" instead — which still lets
109+
// a partial success (one leg returned certs) through.
110+
result := certs.Aggregate(src)
111+
if len(result) == 0 && (cmFailed || secFailed) {
112+
s.writeError(w, http.StatusServiceUnavailable, "certificate inventory temporarily unavailable")
113+
return
114+
}
115+
116+
s.writeJSON(w, result)
117+
}
118+
119+
// secretToCertInput projects a kubernetes.io/tls secret into a certs.Input.
120+
// ok=false for secrets that aren't serving certs: non-TLS types, sealed-secrets
121+
// controller keypairs (encryption keys, not renew-able certs), and secrets
122+
// whose tls.crt is missing or unparseable. Reads only tls.crt — never tls.key.
123+
func secretToCertInput(sec *corev1.Secret) (certs.Input, bool) {
124+
if sec.Type != corev1.SecretTypeTLS {
125+
return certs.Input{}, false
126+
}
127+
if _, sealed := sec.Labels[sealedSecretsKeyLabel]; sealed {
128+
return certs.Input{}, false
129+
}
130+
pem, ok := sec.Data["tls.crt"]
131+
if !ok || len(pem) == 0 {
132+
return certs.Input{}, false
133+
}
134+
leaf := topology.ParsePEMCertificates(pem)
135+
if len(leaf) == 0 {
136+
return certs.Input{}, false
137+
}
138+
in := certs.Input{
139+
Name: sec.Name,
140+
Namespace: sec.Namespace,
141+
Issuer: leaf[0].Issuer,
142+
Domains: leaf[0].SANs,
143+
Source: certs.SourceTLSSecret,
144+
}
145+
if t, err := time.Parse(time.RFC3339, leaf[0].NotAfter); err == nil {
146+
in.NotAfter = &t
147+
}
148+
return in, true
149+
}
150+
151+
// projectCertManagerCert maps a cert-manager.io Certificate CR (unstructured)
152+
// to a certs.Input. status.notAfter is absent when the cert has no issued
153+
// secret yet (and during some renewal-failure states), in which case NotAfter
154+
// stays nil (pkg/certs renders it as unknown expiry).
155+
func projectCertManagerCert(obj map[string]any) certs.Input {
156+
md, _ := obj["metadata"].(map[string]any)
157+
spec, _ := obj["spec"].(map[string]any)
158+
status, _ := obj["status"].(map[string]any)
159+
in := certs.Input{
160+
Name: mapString(md, "name"),
161+
Namespace: mapString(md, "namespace"),
162+
SecretName: mapString(spec, "secretName"),
163+
Source: certs.SourceCertManager,
164+
}
165+
if ir, ok := spec["issuerRef"].(map[string]any); ok {
166+
in.Issuer = mapString(ir, "name")
167+
}
168+
if dns, ok := spec["dnsNames"].([]any); ok {
169+
for _, d := range dns {
170+
if ds, ok := d.(string); ok {
171+
in.Domains = append(in.Domains, ds)
172+
}
173+
}
174+
}
175+
if na := mapString(status, "notAfter"); na != "" {
176+
if t, err := time.Parse(time.RFC3339, na); err == nil {
177+
in.NotAfter = &t
178+
}
179+
}
180+
return in
181+
}
182+
183+
func mapString(m map[string]any, key string) string {
184+
if m == nil {
185+
return ""
186+
}
187+
s, _ := m[key].(string)
188+
return s
189+
}
190+
21191
// handleSecretCertExpiry returns certificate expiry for all TLS secrets.
22192
// Used by the frontend secrets list to show an "Expires" column without
23193
// parsing certificates client-side.
Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,151 @@
1+
package server
2+
3+
import (
4+
"crypto/ecdsa"
5+
"crypto/elliptic"
6+
"crypto/rand"
7+
"crypto/x509"
8+
"crypto/x509/pkix"
9+
"encoding/pem"
10+
"math/big"
11+
"testing"
12+
"time"
13+
14+
corev1 "k8s.io/api/core/v1"
15+
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
16+
17+
"github.com/skyhook-io/radar/pkg/certs"
18+
)
19+
20+
// selfSignedPEM returns a PEM-encoded self-signed cert for use as a secret's
21+
// tls.crt, so secretToCertInput's parse path has a real leaf to read.
22+
func selfSignedPEM(t *testing.T, cn string, dns []string, notAfter time.Time) []byte {
23+
t.Helper()
24+
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
25+
if err != nil {
26+
t.Fatalf("gen key: %v", err)
27+
}
28+
tmpl := &x509.Certificate{
29+
SerialNumber: big.NewInt(1),
30+
Subject: pkix.Name{CommonName: cn},
31+
NotBefore: time.Now().Add(-time.Hour),
32+
NotAfter: notAfter,
33+
DNSNames: dns,
34+
}
35+
der, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &key.PublicKey, key)
36+
if err != nil {
37+
t.Fatalf("create cert: %v", err)
38+
}
39+
return pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der})
40+
}
41+
42+
func TestProjectCertManagerCert_Issued(t *testing.T) {
43+
notAfter := time.Now().Add(45 * 24 * time.Hour).UTC().Format(time.RFC3339)
44+
obj := map[string]any{
45+
"metadata": map[string]any{"name": "api-prod", "namespace": "default"},
46+
"spec": map[string]any{
47+
"secretName": "api-prod-tls",
48+
"issuerRef": map[string]any{"name": "letsencrypt"},
49+
"dnsNames": []any{"api.example.com", "www.example.com"},
50+
},
51+
"status": map[string]any{"notAfter": notAfter},
52+
}
53+
in := projectCertManagerCert(obj)
54+
if in.Name != "api-prod" || in.Namespace != "default" {
55+
t.Errorf("name/namespace = %q/%q, want api-prod/default", in.Name, in.Namespace)
56+
}
57+
if in.SecretName != "api-prod-tls" {
58+
t.Errorf("secretName = %q, want api-prod-tls", in.SecretName)
59+
}
60+
if in.Issuer != "letsencrypt" {
61+
t.Errorf("issuer = %q, want letsencrypt", in.Issuer)
62+
}
63+
if len(in.Domains) != 2 || in.Domains[0] != "api.example.com" {
64+
t.Errorf("domains = %v, want [api.example.com www.example.com]", in.Domains)
65+
}
66+
if in.Source != certs.SourceCertManager {
67+
t.Errorf("source = %q, want cert-manager", in.Source)
68+
}
69+
if in.NotAfter == nil {
70+
t.Error("NotAfter = nil, want parsed time")
71+
}
72+
}
73+
74+
func TestProjectCertManagerCert_NotYetIssued(t *testing.T) {
75+
// No status block at all — a Certificate that hasn't been issued.
76+
obj := map[string]any{
77+
"metadata": map[string]any{"name": "pending", "namespace": "default"},
78+
"spec": map[string]any{"secretName": "pending-tls", "issuerRef": map[string]any{"name": "letsencrypt"}},
79+
}
80+
in := projectCertManagerCert(obj)
81+
if in.Name != "pending" {
82+
t.Fatalf("name = %q, want pending", in.Name)
83+
}
84+
if in.NotAfter != nil {
85+
t.Errorf("NotAfter = %v, want nil (no status.notAfter)", in.NotAfter)
86+
}
87+
}
88+
89+
func TestSecretToCertInput_SkipsNonServingSecrets(t *testing.T) {
90+
valid := selfSignedPEM(t, "svc.example", []string{"svc.example"}, time.Now().Add(40*24*time.Hour))
91+
cases := []struct {
92+
name string
93+
secret *corev1.Secret
94+
wantOK bool
95+
}{
96+
{
97+
name: "non-TLS type skipped",
98+
secret: &corev1.Secret{Type: corev1.SecretTypeOpaque, Data: map[string][]byte{"tls.crt": valid}},
99+
wantOK: false,
100+
},
101+
{
102+
name: "sealed-secrets keypair skipped",
103+
secret: &corev1.Secret{
104+
Type: corev1.SecretTypeTLS,
105+
Data: map[string][]byte{"tls.crt": valid},
106+
},
107+
wantOK: false, // label set below
108+
},
109+
{
110+
name: "missing tls.crt skipped",
111+
secret: &corev1.Secret{Type: corev1.SecretTypeTLS, Data: map[string][]byte{}},
112+
wantOK: false,
113+
},
114+
{
115+
name: "unparseable tls.crt skipped",
116+
secret: &corev1.Secret{Type: corev1.SecretTypeTLS, Data: map[string][]byte{"tls.crt": []byte("not a pem")}},
117+
wantOK: false,
118+
},
119+
{
120+
name: "valid TLS serving cert accepted",
121+
secret: &corev1.Secret{
122+
ObjectMeta: metav1.ObjectMeta{Name: "web-tls", Namespace: "prod"},
123+
Type: corev1.SecretTypeTLS,
124+
Data: map[string][]byte{"tls.crt": valid},
125+
},
126+
wantOK: true,
127+
},
128+
}
129+
// Attach the sealed-secrets label to the dedicated case.
130+
cases[1].secret.Labels = map[string]string{sealedSecretsKeyLabel: "active"}
131+
132+
for _, tc := range cases {
133+
t.Run(tc.name, func(t *testing.T) {
134+
in, ok := secretToCertInput(tc.secret)
135+
if ok != tc.wantOK {
136+
t.Fatalf("ok = %v, want %v", ok, tc.wantOK)
137+
}
138+
if tc.wantOK {
139+
if in.Name != "web-tls" || in.Namespace != "prod" {
140+
t.Errorf("name/namespace = %q/%q, want web-tls/prod", in.Name, in.Namespace)
141+
}
142+
if in.Source != certs.SourceTLSSecret {
143+
t.Errorf("source = %q, want tls-secret", in.Source)
144+
}
145+
if in.NotAfter == nil {
146+
t.Error("NotAfter = nil, want parsed expiry from the leaf cert")
147+
}
148+
}
149+
})
150+
}
151+
}

internal/server/server.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -291,6 +291,7 @@ func (s *Server) setupRoutes() {
291291
r.Get("/resources/{kind}/{namespace}/{name}/cascade-preview", s.handleCascadeDeletePreview)
292292
r.Delete("/resources/{kind}/{namespace}/{name}", s.handleDeleteResource)
293293
r.Get("/secrets/certificate-expiry", s.handleSecretCertExpiry)
294+
r.Get("/certificates", s.handleCertificates)
294295

295296
// Cluster audit
296297
r.Get("/audit", s.handleAudit)

0 commit comments

Comments
 (0)