Skip to content

Commit 68f58ea

Browse files
authored
feat(decoredirect): auto-heal failed certs and add retry-cert API route (#19)
* feat(decoredirect): auto-heal failed certs and add retry-cert API route When a Certificate enters cert-manager's exponential backoff (Issuing=False, Reason=Failed), the controller now automatically detects it and checks whether the domain DNS is correctly pointing to Deco's redirect infrastructure. If both an HTTP check (X-Redirect-By: deco) and an AAAA check (no GCP 2600:1901::/32 range) pass, the Certificate is deleted so cert-manager retries without backoff. Also adds POST /redirects/{domain}/retry-cert API route that performs the same DNS checks and forces an immediate retry for operators who don't want to wait for the next 30s reconcile cycle. Root cause addressed: domains migrating from Deno Deploy sometimes retain AAAA records in GCP range (2600:1901::/32). cert-manager's self-check uses IPv4 and passes, but Let's Encrypt validates via IPv6, hits Deno Deploy, and fails — leaving the Certificate stuck in multi-hour backoff. * fix(decoredirect): skip cert mutation while DeletionTimestamp is set * fix(lint): use _ to discard resp.Body.Close error (errcheck) * feat(decoredirect): remove retry-cert endpoint — controller auto-heals * test(decoredirect): add auto-healing scenarios and make DNSReadyFunc injectable Tests cover: - cert Failed + DNS ready → cert deleted (healed) - cert Failed + DNS wrong → cert untouched - cert Issuing=True → cert untouched (noop) - cert Ready=True → cert untouched (noop) - cert doesn't exist → no error Also skips healing when cert has DeletionTimestamp to avoid acting on a cert that is already being deleted. * feat(decoredirect): make blocked IPv6 CIDRs configurable via --redirect-blocked-ipv6 Removes the hardcoded GCP/Deno Deploy IPv6 range (2600:1901::/32) and replaces it with a configurable list of blocked CIDRs. When empty (default), no AAAA check is performed. Configure for Deco's deployment with: --redirect-blocked-ipv6=2600:1901::/32 Also accepts REDIRECT_BLOCKED_IPV6 env var. * Revert "feat(decoredirect): make blocked IPv6 CIDRs configurable via --redirect-blocked-ipv6" This reverts commit f626842. * feat(decoredirect): make blocked IPv6 CIDRs configurable via --redirect-blocked-ipv6 Removes hardcoded GCP range. Configure blocked CIDRs via: - --redirect-blocked-ipv6=2600:1901::/32 (flag) - REDIRECT_BLOCKED_IPV6=2600:1901::/32 (env) - redirect.blockedIPv6CIDRs in Helm values Default is empty (no AAAA check). * fix(helm): add blockedIPv6CIDRs arg to helm-generator
1 parent f7e178f commit 68f58ea

6 files changed

Lines changed: 297 additions & 4 deletions

File tree

chart/templates/deployment-operator-controller-manager.yaml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,9 @@ spec:
3838
- --redirect-ingress-class={{ .Values.redirect.ingressClass }}
3939
- --redirect-cluster-issuer={{ .Values.redirect.clusterIssuer.name }}
4040
{{- end }}
41+
{{- if .Values.redirect.blockedIPv6CIDRs }}
42+
- --redirect-blocked-ipv6={{ join "," .Values.redirect.blockedIPv6CIDRs }}
43+
{{- end }}
4144
{{- if .Values.redirect.namespace }}
4245
- --redirect-namespace={{ .Values.redirect.namespace }}
4346
{{- end }}

chart/values.yaml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -140,6 +140,7 @@ operatorApi:
140140
redirect:
141141
namespace: "deco-redirect-system"
142142
ingressClass: "" # set to enable DecoRedirect controller (e.g. "redirect-nginx")
143+
blockedIPv6CIDRs: [] # IPv6 CIDRs that block cert issuance when present in AAAA records (e.g. ["2600:1901::/32"])
143144
clusterIssuer:
144145
enabled: false # set true to create the Let's Encrypt ClusterIssuer
145146
name: "" # ClusterIssuer name (e.g. "letsencrypt")

cmd/main.go

Lines changed: 23 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import (
2121
"crypto/tls"
2222
"flag"
2323
"fmt"
24+
"net"
2425
"os"
2526
"path/filepath"
2627
"strings"
@@ -135,6 +136,10 @@ func main() {
135136
flag.StringVar(&redirectClusterIssuer, "redirect-cluster-issuer",
136137
getEnvOrDefault("REDIRECT_CLUSTER_ISSUER", "letsencrypt"),
137138
"cert-manager ClusterIssuer name (matches redirect.clusterIssuer.name in values).")
139+
var redirectBlockedIPv6 string
140+
flag.StringVar(&redirectBlockedIPv6, "redirect-blocked-ipv6",
141+
getEnvOrDefault("REDIRECT_BLOCKED_IPV6", ""),
142+
"Comma-separated IPv6 CIDRs that block cert issuance when present in a domain's AAAA records (e.g. 2600:1901::/32).")
138143
var controllersFlag string
139144
flag.StringVar(&controllersFlag, "controllers", "*",
140145
"Comma-separated list of controllers to enable. Use \"*\" to enable all. Valid values: "+
@@ -371,11 +376,25 @@ func main() {
371376
}
372377

373378
if enabled(controller.DecoRedirectControllerName) {
379+
var blockedIPv6CIDRs []*net.IPNet
380+
for _, cidr := range strings.Split(redirectBlockedIPv6, ",") {
381+
cidr = strings.TrimSpace(cidr)
382+
if cidr == "" {
383+
continue
384+
}
385+
_, ipNet, cidrErr := net.ParseCIDR(cidr)
386+
if cidrErr != nil {
387+
setupLog.Error(cidrErr, "invalid CIDR in --redirect-blocked-ipv6", "cidr", cidr)
388+
os.Exit(1)
389+
}
390+
blockedIPv6CIDRs = append(blockedIPv6CIDRs, ipNet)
391+
}
374392
if err = (&controller.DecoRedirectReconciler{
375-
Client: mgr.GetClient(),
376-
Scheme: mgr.GetScheme(),
377-
IngressClass: redirectIngressClass,
378-
ClusterIssuer: redirectClusterIssuer,
393+
Client: mgr.GetClient(),
394+
Scheme: mgr.GetScheme(),
395+
IngressClass: redirectIngressClass,
396+
ClusterIssuer: redirectClusterIssuer,
397+
BlockedIPv6CIDRs: blockedIPv6CIDRs,
379398
}).SetupWithManager(mgr); err != nil {
380399
setupLog.Error(err, "unable to create controller", "controller", "DecoRedirect")
381400
os.Exit(1)

hack/helm-generator/main.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -434,6 +434,9 @@ func addRedirectControllerArgs(templatesDir string) error {
434434
- --redirect-ingress-class={{ .Values.redirect.ingressClass }}
435435
- --redirect-cluster-issuer={{ .Values.redirect.clusterIssuer.name }}
436436
{{- end }}
437+
{{- if .Values.redirect.blockedIPv6CIDRs }}
438+
- --redirect-blocked-ipv6={{ join "," .Values.redirect.blockedIPv6CIDRs }}
439+
{{- end }}
437440
{{- if .Values.redirect.namespace }}
438441
- --redirect-namespace={{ .Values.redirect.namespace }}
439442
{{- end }}`

internal/controller/decoredirect_controller.go

Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@ import (
44
"context"
55
"crypto/sha256"
66
"fmt"
7+
"net"
8+
"net/http"
79
"strconv"
810
"strings"
911
"time"
@@ -30,6 +32,14 @@ type DecoRedirectReconciler struct {
3032
Scheme *runtime.Scheme
3133
IngressClass string // nginx ingress class name, e.g. "nginx"
3234
ClusterIssuer string // cert-manager ClusterIssuer name, e.g. "letsencrypt"
35+
// BlockedIPv6CIDRs is a list of IPv6 CIDR ranges that, if present in a domain's
36+
// AAAA records, indicate DNS is not ready for cert issuance. Typically legacy
37+
// infrastructure addresses that intercept Let's Encrypt validation incorrectly.
38+
// When empty, no AAAA check is performed.
39+
BlockedIPv6CIDRs []*net.IPNet
40+
// DNSReadyFunc checks if the domain DNS is correctly pointing to the redirect infrastructure.
41+
// Defaults to isDNSReady. Injectable for testing.
42+
DNSReadyFunc func(ctx context.Context, domain string) bool
3343
}
3444

3545
// dummyBackendName satisfies the k8s Ingress API requirement for a backend on every path.
@@ -51,6 +61,15 @@ func (r *DecoRedirectReconciler) Reconcile(ctx context.Context, req ctrl.Request
5161
return ctrl.Result{}, client.IgnoreNotFound(err)
5262
}
5363

64+
// Auto-heal: if Certificate is stuck in Failed backoff and DNS is now correct, delete it
65+
// so reconcileCertificate recreates it fresh and cert-manager retries without backoff.
66+
if healed, err := r.maybeHealCertificate(ctx, rd); err != nil {
67+
log.Error(err, "failed to heal Certificate")
68+
return ctrl.Result{}, err
69+
} else if healed {
70+
return ctrl.Result{RequeueAfter: 2 * time.Second}, nil
71+
}
72+
5473
if err := r.reconcileCertificate(ctx, rd); err != nil {
5574
log.Error(err, "failed to reconcile Certificate")
5675
return ctrl.Result{}, err
@@ -86,6 +105,10 @@ func (r *DecoRedirectReconciler) reconcileCertificate(ctx context.Context, rd *d
86105
}
87106

88107
_, err := controllerutil.CreateOrUpdate(ctx, r.Client, cert, func() error {
108+
// Skip mutation while the object is being deleted — the Watch will re-trigger once gone.
109+
if cert.DeletionTimestamp != nil {
110+
return nil
111+
}
89112
cert.Spec.SecretName = tlsSecretName(rd.Spec.From)
90113
cert.Spec.DNSNames = []string{rd.Spec.From}
91114
cert.Spec.IssuerRef = cmmeta.ObjectReference{
@@ -191,6 +214,93 @@ func (r *DecoRedirectReconciler) updateStatus(ctx context.Context, rd *decosites
191214
return certReady, r.Status().Patch(ctx, patch, client.MergeFrom(rd))
192215
}
193216

217+
// maybeHealCertificate deletes a Certificate that is stuck in Failed backoff when DNS
218+
// is already pointing correctly to the Deco redirect infrastructure. Returning true
219+
// means the Certificate was deleted and the caller should requeue before recreating it.
220+
func (r *DecoRedirectReconciler) maybeHealCertificate(ctx context.Context, rd *decositesv1alpha1.DecoRedirect) (bool, error) {
221+
log := logf.FromContext(ctx)
222+
223+
cert := &cmv1.Certificate{}
224+
if err := r.Get(ctx, types.NamespacedName{Name: resourceName(rd.Spec.From), Namespace: rd.Namespace}, cert); err != nil {
225+
return false, client.IgnoreNotFound(err)
226+
}
227+
228+
// Skip if already being deleted or not in the Failed backoff state.
229+
if cert.DeletionTimestamp != nil || !isCertFailed(cert) {
230+
return false, nil
231+
}
232+
233+
dnsReady := r.DNSReadyFunc
234+
if dnsReady == nil {
235+
dnsReady = r.isDNSReady
236+
}
237+
if !dnsReady(ctx, rd.Spec.From) {
238+
log.Info("certificate in Failed backoff but DNS not ready yet", "domain", rd.Spec.From)
239+
return false, nil
240+
}
241+
242+
log.Info("certificate in Failed backoff and DNS is ready — deleting to trigger retry", "domain", rd.Spec.From)
243+
if err := r.Delete(ctx, cert); err != nil {
244+
return false, client.IgnoreNotFound(err)
245+
}
246+
return true, nil
247+
}
248+
249+
// isCertFailed reports whether the Certificate is stuck in cert-manager's exponential
250+
// backoff after a failed issuance attempt (Issuing=False, Reason=Failed).
251+
func isCertFailed(cert *cmv1.Certificate) bool {
252+
for _, c := range cert.Status.Conditions {
253+
if c.Type == cmv1.CertificateConditionIssuing {
254+
return c.Status == cmmeta.ConditionFalse && c.Reason == "Failed"
255+
}
256+
}
257+
return false
258+
}
259+
260+
// isDNSReady checks that the domain is correctly pointing to the redirect infrastructure:
261+
// 1. An HTTP request returns a redirect served by the nginx (X-Redirect-By: deco header).
262+
// 2. No AAAA record falls within any BlockedIPv6CIDRs range, which would cause
263+
// Let's Encrypt's IPv6 validation to reach the wrong server and fail the challenge.
264+
func (r *DecoRedirectReconciler) isDNSReady(ctx context.Context, domain string) bool {
265+
httpClient := &http.Client{
266+
CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse },
267+
Timeout: 5 * time.Second,
268+
}
269+
req, err := http.NewRequestWithContext(ctx, http.MethodGet, "http://"+domain+"/", nil)
270+
if err != nil {
271+
return false
272+
}
273+
resp, err := httpClient.Do(req)
274+
if err != nil {
275+
return false
276+
}
277+
_ = resp.Body.Close()
278+
if resp.Header.Get("X-Redirect-By") != "deco" {
279+
return false
280+
}
281+
282+
if len(r.BlockedIPv6CIDRs) == 0 {
283+
return true
284+
}
285+
286+
addrs, err := net.DefaultResolver.LookupIPAddr(ctx, domain)
287+
if err != nil {
288+
return false
289+
}
290+
for _, a := range addrs {
291+
ip := a.IP
292+
if ip.To4() != nil {
293+
continue
294+
}
295+
for _, blocked := range r.BlockedIPv6CIDRs {
296+
if blocked.Contains(ip) {
297+
return false
298+
}
299+
}
300+
}
301+
return true
302+
}
303+
194304
// resourceName returns a deterministic k8s-safe name for a domain, capped at 253 chars.
195305
// "client.com" → "redirect-client-com"
196306
func resourceName(domain string) string {

internal/controller/decoredirect_controller_test.go

Lines changed: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -184,4 +184,161 @@ var _ = Describe("DecoRedirect Controller", func() {
184184
Expect(ing.Annotations["nginx.ingress.kubernetes.io/permanent-redirect-code"]).To(Equal("301"))
185185
})
186186
})
187+
188+
Context("Auto-healing: maybeHealCertificate", func() {
189+
const healNS = "default"
190+
ctx := context.Background()
191+
192+
newReconciler := func(dnsReady bool) *DecoRedirectReconciler {
193+
return &DecoRedirectReconciler{
194+
Client: k8sClient,
195+
Scheme: k8sClient.Scheme(),
196+
IngressClass: "nginx",
197+
ClusterIssuer: "letsencrypt",
198+
DNSReadyFunc: func(_ context.Context, _ string) bool { return dnsReady },
199+
}
200+
}
201+
202+
// Each test uses a unique name to avoid state sharing between tests.
203+
setup := func(suffix string) (nn, certNN types.NamespacedName, cleanup func()) {
204+
name := "heal-" + suffix
205+
domain := name + ".com"
206+
nn = types.NamespacedName{Name: name + "-com", Namespace: healNS}
207+
certNN = types.NamespacedName{Name: "redirect-" + name + "-com", Namespace: healNS}
208+
209+
rd := &decositesv1alpha1.DecoRedirect{
210+
ObjectMeta: metav1.ObjectMeta{Name: name + "-com", Namespace: healNS},
211+
Spec: decositesv1alpha1.DecoRedirectSpec{
212+
From: domain,
213+
To: "https://www." + domain,
214+
},
215+
}
216+
Expect(k8sClient.Create(ctx, rd)).To(Succeed())
217+
218+
cleanup = func() {
219+
r := &decositesv1alpha1.DecoRedirect{}
220+
if err := k8sClient.Get(ctx, nn, r); err == nil {
221+
_ = k8sClient.Delete(ctx, r)
222+
}
223+
c := &cmv1.Certificate{}
224+
if err := k8sClient.Get(ctx, certNN, c); err == nil {
225+
_ = k8sClient.Delete(ctx, c)
226+
}
227+
}
228+
return nn, certNN, cleanup
229+
}
230+
231+
patchCertFailed := func(certNN types.NamespacedName) {
232+
cert := &cmv1.Certificate{}
233+
Expect(k8sClient.Get(ctx, certNN, cert)).To(Succeed())
234+
patch := cert.DeepCopy()
235+
patch.Status.Conditions = []cmv1.CertificateCondition{
236+
{Type: cmv1.CertificateConditionReady, Status: "False", Reason: "DoesNotExist", Message: "secret not found", LastTransitionTime: &[]metav1.Time{metav1.Now()}[0]},
237+
{Type: cmv1.CertificateConditionIssuing, Status: "False", Reason: "Failed", Message: "cert request failed", LastTransitionTime: &[]metav1.Time{metav1.Now()}[0]},
238+
}
239+
Expect(k8sClient.Status().Patch(ctx, patch, client.MergeFrom(cert))).To(Succeed())
240+
}
241+
242+
It("should delete the Certificate when it is in Failed backoff and DNS is ready", func() {
243+
nn, certNN, cleanup := setup("delete")
244+
DeferCleanup(cleanup)
245+
246+
_, err := newReconciler(true).Reconcile(ctx, reconcile.Request{NamespacedName: nn})
247+
Expect(err).NotTo(HaveOccurred())
248+
249+
patchCertFailed(certNN)
250+
251+
rd := &decositesv1alpha1.DecoRedirect{}
252+
Expect(k8sClient.Get(ctx, nn, rd)).To(Succeed())
253+
254+
healed, err := newReconciler(true).maybeHealCertificate(ctx, rd)
255+
Expect(err).NotTo(HaveOccurred())
256+
Expect(healed).To(BeTrue())
257+
258+
cert := &cmv1.Certificate{}
259+
Expect(k8sClient.Get(ctx, certNN, cert)).To(MatchError(ContainSubstring("not found")))
260+
})
261+
262+
It("should NOT delete the Certificate when DNS is not ready", func() {
263+
nn, certNN, cleanup := setup("dns-wrong")
264+
DeferCleanup(cleanup)
265+
266+
_, err := newReconciler(false).Reconcile(ctx, reconcile.Request{NamespacedName: nn})
267+
Expect(err).NotTo(HaveOccurred())
268+
269+
patchCertFailed(certNN)
270+
271+
rd := &decositesv1alpha1.DecoRedirect{}
272+
Expect(k8sClient.Get(ctx, nn, rd)).To(Succeed())
273+
274+
healed, err := newReconciler(false).maybeHealCertificate(ctx, rd)
275+
Expect(err).NotTo(HaveOccurred())
276+
Expect(healed).To(BeFalse())
277+
278+
cert := &cmv1.Certificate{}
279+
Expect(k8sClient.Get(ctx, certNN, cert)).To(Succeed())
280+
})
281+
282+
It("should NOT delete the Certificate when it is Issuing (actively trying)", func() {
283+
nn, certNN, cleanup := setup("issuing")
284+
DeferCleanup(cleanup)
285+
286+
_, err := newReconciler(true).Reconcile(ctx, reconcile.Request{NamespacedName: nn})
287+
Expect(err).NotTo(HaveOccurred())
288+
289+
cert := &cmv1.Certificate{}
290+
Expect(k8sClient.Get(ctx, certNN, cert)).To(Succeed())
291+
patch := cert.DeepCopy()
292+
patch.Status.Conditions = []cmv1.CertificateCondition{
293+
{Type: cmv1.CertificateConditionIssuing, Status: "True", Reason: "Issuing", LastTransitionTime: &[]metav1.Time{metav1.Now()}[0]},
294+
}
295+
Expect(k8sClient.Status().Patch(ctx, patch, client.MergeFrom(cert))).To(Succeed())
296+
297+
rd := &decositesv1alpha1.DecoRedirect{}
298+
Expect(k8sClient.Get(ctx, nn, rd)).To(Succeed())
299+
300+
healed, err := newReconciler(true).maybeHealCertificate(ctx, rd)
301+
Expect(err).NotTo(HaveOccurred())
302+
Expect(healed).To(BeFalse())
303+
304+
Expect(k8sClient.Get(ctx, certNN, cert)).To(Succeed())
305+
})
306+
307+
It("should NOT delete the Certificate when it is Ready", func() {
308+
nn, certNN, cleanup := setup("ready")
309+
DeferCleanup(cleanup)
310+
311+
_, err := newReconciler(true).Reconcile(ctx, reconcile.Request{NamespacedName: nn})
312+
Expect(err).NotTo(HaveOccurred())
313+
314+
cert := &cmv1.Certificate{}
315+
Expect(k8sClient.Get(ctx, certNN, cert)).To(Succeed())
316+
patch := cert.DeepCopy()
317+
patch.Status.Conditions = []cmv1.CertificateCondition{
318+
{Type: cmv1.CertificateConditionReady, Status: "True", Reason: "Ready", LastTransitionTime: &[]metav1.Time{metav1.Now()}[0]},
319+
}
320+
Expect(k8sClient.Status().Patch(ctx, patch, client.MergeFrom(cert))).To(Succeed())
321+
322+
rd := &decositesv1alpha1.DecoRedirect{}
323+
Expect(k8sClient.Get(ctx, nn, rd)).To(Succeed())
324+
325+
healed, err := newReconciler(true).maybeHealCertificate(ctx, rd)
326+
Expect(err).NotTo(HaveOccurred())
327+
Expect(healed).To(BeFalse())
328+
329+
Expect(k8sClient.Get(ctx, certNN, cert)).To(Succeed())
330+
})
331+
332+
It("should do nothing when the Certificate does not exist yet", func() {
333+
nn, _, cleanup := setup("no-cert")
334+
DeferCleanup(cleanup)
335+
336+
rd := &decositesv1alpha1.DecoRedirect{}
337+
Expect(k8sClient.Get(ctx, nn, rd)).To(Succeed())
338+
339+
healed, err := newReconciler(true).maybeHealCertificate(ctx, rd)
340+
Expect(err).NotTo(HaveOccurred())
341+
Expect(healed).To(BeFalse())
342+
})
343+
})
187344
})

0 commit comments

Comments
 (0)