From f42e3e32b87090b813256fa0e9ea2bf070fae1e3 Mon Sep 17 00:00:00 2001 From: igoramf Date: Wed, 3 Jun 2026 15:54:02 -0300 Subject: [PATCH 1/9] feat(decoredirect): auto-heal failed certs and add retry-cert API route MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- internal/api/handlers.go | 102 ++++++++++++++++++ internal/api/server.go | 1 + .../controller/decoredirect_controller.go | 97 +++++++++++++++++ 3 files changed, 200 insertions(+) diff --git a/internal/api/handlers.go b/internal/api/handlers.go index 7864bd1..6286304 100644 --- a/internal/api/handlers.go +++ b/internal/api/handlers.go @@ -1,17 +1,28 @@ package api import ( + "context" "encoding/json" + "fmt" + "net" "net/http" "regexp" "strings" + "time" + cmv1 "github.com/cert-manager/cert-manager/pkg/apis/certmanager/v1" + cmmeta "github.com/cert-manager/cert-manager/pkg/apis/meta/v1" decositesv1alpha1 "github.com/deco-sites/decofile-operator/api/v1alpha1" apierrors "k8s.io/apimachinery/pkg/api/errors" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "sigs.k8s.io/controller-runtime/pkg/client" ) +var apiGCPIPv6Range = func() *net.IPNet { + _, n, _ := net.ParseCIDR("2600:1901::/32") + return n +}() + var domainRe = regexp.MustCompile(`^[a-zA-Z0-9]([a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])?)*$`) type Handlers struct { @@ -168,6 +179,97 @@ func (h *Handlers) list(w http.ResponseWriter, r *http.Request) { _ = json.NewEncoder(w).Encode(items) } +func (h *Handlers) retryCert(w http.ResponseWriter, r *http.Request) { + rawDomain := strings.ToLower(strings.TrimSpace(r.PathValue("domain"))) + if !domainRe.MatchString(rawDomain) { + http.Error(w, "invalid domain", http.StatusBadRequest) + return + } + domain := domainToName(rawDomain) + ns := h.nsOrDefault(r.URL.Query().Get("namespace")) + + rd := &decositesv1alpha1.DecoRedirect{} + if err := h.client.Get(r.Context(), client.ObjectKey{Name: domain, Namespace: ns}, rd); err != nil { + status := http.StatusInternalServerError + if apierrors.IsNotFound(err) { + status = http.StatusNotFound + } + http.Error(w, err.Error(), status) + return + } + + if err := checkDNSReady(r.Context(), rawDomain); err != nil { + http.Error(w, err.Error(), http.StatusUnprocessableEntity) + return + } + + cert := &cmv1.Certificate{} + certName := "redirect-" + domain + if err := h.client.Get(r.Context(), client.ObjectKey{Name: certName, Namespace: ns}, cert); err == nil { + // noop: cert is already ready or actively being issued + for _, c := range cert.Status.Conditions { + if c.Type == cmv1.CertificateConditionReady && c.Status == cmmeta.ConditionTrue { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(toResponse(rd)) + return + } + if c.Type == cmv1.CertificateConditionIssuing && c.Status == cmmeta.ConditionTrue { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(toResponse(rd)) + return + } + } + // stuck in backoff — delete so the controller recreates it fresh + if err := h.client.Delete(r.Context(), cert); err != nil && !apierrors.IsNotFound(err) { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + } else if !apierrors.IsNotFound(err) { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(toResponse(rd)) +} + +// checkDNSReady verifica se o domínio está apontando para a infraestrutura Deco: +// 1. HTTP retorna redirect servido pelo nginx deco (X-Redirect-By: deco). +// 2. Sem AAAA no range GCP (2600:1901::/32) que faria o LE validar via Deno Deploy. +func checkDNSReady(ctx context.Context, domain string) error { + httpClient := &http.Client{ + CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }, + Timeout: 5 * time.Second, + } + req, err := http.NewRequestWithContext(ctx, http.MethodGet, "http://"+domain+"/", nil) + if err != nil { + return err + } + resp, err := httpClient.Do(req) + if err != nil { + return fmt.Errorf("domain not reachable: %w", err) + } + resp.Body.Close() + if resp.Header.Get("X-Redirect-By") != "deco" { + return fmt.Errorf("domain is not pointing to Deco redirect IPs yet") + } + + addrs, err := net.DefaultResolver.LookupIPAddr(ctx, domain) + if err != nil { + return fmt.Errorf("DNS lookup failed: %w", err) + } + for _, a := range addrs { + ip := a.IP + if ip.To4() != nil { + continue + } + if apiGCPIPv6Range.Contains(ip) { + return fmt.Errorf("AAAA record %s points to Deno Deploy (GCP) — remove it first", ip) + } + } + return nil +} + // domainToName converts a domain to a valid k8s resource name (dots → dashes). func domainToName(d string) string { return strings.ReplaceAll(d, ".", "-") diff --git a/internal/api/server.go b/internal/api/server.go index 05ba842..eab4bf2 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -23,6 +23,7 @@ func NewServer(addr, user, pass string, h *Handlers) *Server { mux.HandleFunc("POST /redirects", h.create) mux.HandleFunc("GET /redirects/{domain}", h.get) mux.HandleFunc("DELETE /redirects/{domain}", h.delete) + mux.HandleFunc("POST /redirects/{domain}/retry-cert", h.retryCert) return &Server{ addr: addr, handler: basicAuth(user, pass, mux), diff --git a/internal/controller/decoredirect_controller.go b/internal/controller/decoredirect_controller.go index 0238ca0..289dc8c 100644 --- a/internal/controller/decoredirect_controller.go +++ b/internal/controller/decoredirect_controller.go @@ -4,6 +4,8 @@ import ( "context" "crypto/sha256" "fmt" + "net" + "net/http" "strconv" "strings" "time" @@ -24,6 +26,14 @@ import ( decositesv1alpha1 "github.com/deco-sites/decofile-operator/api/v1alpha1" ) +// gcpIPv6Range is Google Cloud's IPv6 range used by Deno Deploy. +// Presence of a AAAA record in this range means Let's Encrypt will validate via +// Deno Deploy's IPv6 address and fail the HTTP-01 challenge. +var gcpIPv6Range = func() *net.IPNet { + _, n, _ := net.ParseCIDR("2600:1901::/32") + return n +}() + // DecoRedirectReconciler reconciles a DecoRedirect object. type DecoRedirectReconciler struct { client.Client @@ -51,6 +61,15 @@ func (r *DecoRedirectReconciler) Reconcile(ctx context.Context, req ctrl.Request return ctrl.Result{}, client.IgnoreNotFound(err) } + // Auto-heal: if Certificate is stuck in Failed backoff and DNS is now correct, delete it + // so reconcileCertificate recreates it fresh and cert-manager retries without backoff. + if healed, err := r.maybeHealCertificate(ctx, rd); err != nil { + log.Error(err, "failed to heal Certificate") + return ctrl.Result{}, err + } else if healed { + return ctrl.Result{RequeueAfter: 2 * time.Second}, nil + } + if err := r.reconcileCertificate(ctx, rd); err != nil { log.Error(err, "failed to reconcile Certificate") return ctrl.Result{}, err @@ -191,6 +210,84 @@ func (r *DecoRedirectReconciler) updateStatus(ctx context.Context, rd *decosites return certReady, r.Status().Patch(ctx, patch, client.MergeFrom(rd)) } +// maybeHealCertificate deletes a Certificate that is stuck in Failed backoff when DNS +// is already pointing correctly to the Deco redirect infrastructure. Returning true +// means the Certificate was deleted and the caller should requeue before recreating it. +func (r *DecoRedirectReconciler) maybeHealCertificate(ctx context.Context, rd *decositesv1alpha1.DecoRedirect) (bool, error) { + log := logf.FromContext(ctx) + + cert := &cmv1.Certificate{} + if err := r.Get(ctx, types.NamespacedName{Name: resourceName(rd.Spec.From), Namespace: rd.Namespace}, cert); err != nil { + return false, client.IgnoreNotFound(err) + } + + // Only act when cert-manager has given up and entered backoff (Issuing=False, Reason=Failed). + if !isCertFailed(cert) { + return false, nil + } + + if !isDNSReady(ctx, rd.Spec.From) { + log.Info("certificate in Failed backoff but DNS not ready yet", "domain", rd.Spec.From) + return false, nil + } + + log.Info("certificate in Failed backoff and DNS is ready — deleting to trigger retry", "domain", rd.Spec.From) + if err := r.Delete(ctx, cert); err != nil { + return false, client.IgnoreNotFound(err) + } + return true, nil +} + +// isCertFailed reports whether the Certificate is stuck in cert-manager's exponential +// backoff after a failed issuance attempt (Issuing=False, Reason=Failed). +func isCertFailed(cert *cmv1.Certificate) bool { + for _, c := range cert.Status.Conditions { + if c.Type == cmv1.CertificateConditionIssuing { + return c.Status == cmmeta.ConditionFalse && c.Reason == "Failed" + } + } + return false +} + +// isDNSReady checks that the domain is correctly pointing to Deco's redirect +// infrastructure by verifying two conditions: +// 1. An HTTP request returns a redirect served by the deco nginx (X-Redirect-By: deco). +// 2. No AAAA record in the GCP range (2600:1901::/32) exists, which would cause +// Let's Encrypt's IPv6 validation to hit Deno Deploy instead of nginx. +func isDNSReady(ctx context.Context, domain string) bool { + httpClient := &http.Client{ + CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }, + Timeout: 5 * time.Second, + } + req, err := http.NewRequestWithContext(ctx, http.MethodGet, "http://"+domain+"/", nil) + if err != nil { + return false + } + resp, err := httpClient.Do(req) + if err != nil { + return false + } + resp.Body.Close() + if resp.Header.Get("X-Redirect-By") != "deco" { + return false + } + + addrs, err := net.DefaultResolver.LookupIPAddr(ctx, domain) + if err != nil { + return false + } + for _, a := range addrs { + ip := a.IP + if ip.To4() != nil { + continue // IPv4 — skip + } + if gcpIPv6Range.Contains(ip) { + return false // Deno Deploy IPv6 still present + } + } + return true +} + // resourceName returns a deterministic k8s-safe name for a domain, capped at 253 chars. // "client.com" → "redirect-client-com" func resourceName(domain string) string { From b5271dabba89ac03e40a4233751fe2547ed4b9a5 Mon Sep 17 00:00:00 2001 From: igoramf Date: Wed, 3 Jun 2026 16:06:18 -0300 Subject: [PATCH 2/9] fix(decoredirect): skip cert mutation while DeletionTimestamp is set --- internal/controller/decoredirect_controller.go | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/internal/controller/decoredirect_controller.go b/internal/controller/decoredirect_controller.go index 289dc8c..3e7fa3c 100644 --- a/internal/controller/decoredirect_controller.go +++ b/internal/controller/decoredirect_controller.go @@ -105,6 +105,10 @@ func (r *DecoRedirectReconciler) reconcileCertificate(ctx context.Context, rd *d } _, err := controllerutil.CreateOrUpdate(ctx, r.Client, cert, func() error { + // Skip mutation while the object is being deleted — the Watch will re-trigger once gone. + if cert.DeletionTimestamp != nil { + return nil + } cert.Spec.SecretName = tlsSecretName(rd.Spec.From) cert.Spec.DNSNames = []string{rd.Spec.From} cert.Spec.IssuerRef = cmmeta.ObjectReference{ From 1fa4c1e07d187fb3993df11bf3267979d01eacaf Mon Sep 17 00:00:00 2001 From: igoramf Date: Wed, 3 Jun 2026 17:57:00 -0300 Subject: [PATCH 3/9] fix(lint): use _ to discard resp.Body.Close error (errcheck) --- internal/api/handlers.go | 2 +- internal/controller/decoredirect_controller.go | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/internal/api/handlers.go b/internal/api/handlers.go index 6286304..73041f6 100644 --- a/internal/api/handlers.go +++ b/internal/api/handlers.go @@ -249,7 +249,7 @@ func checkDNSReady(ctx context.Context, domain string) error { if err != nil { return fmt.Errorf("domain not reachable: %w", err) } - resp.Body.Close() + _ = resp.Body.Close() if resp.Header.Get("X-Redirect-By") != "deco" { return fmt.Errorf("domain is not pointing to Deco redirect IPs yet") } diff --git a/internal/controller/decoredirect_controller.go b/internal/controller/decoredirect_controller.go index 3e7fa3c..02243f5 100644 --- a/internal/controller/decoredirect_controller.go +++ b/internal/controller/decoredirect_controller.go @@ -271,7 +271,7 @@ func isDNSReady(ctx context.Context, domain string) bool { if err != nil { return false } - resp.Body.Close() + _ = resp.Body.Close() if resp.Header.Get("X-Redirect-By") != "deco" { return false } From aff2319c6db9289ff666b666ca5ccd3576087f81 Mon Sep 17 00:00:00 2001 From: igoramf Date: Fri, 5 Jun 2026 11:08:54 -0300 Subject: [PATCH 4/9] =?UTF-8?q?feat(decoredirect):=20remove=20retry-cert?= =?UTF-8?q?=20endpoint=20=E2=80=94=20controller=20auto-heals?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/api/handlers.go | 102 --------------------------------------- internal/api/server.go | 1 - 2 files changed, 103 deletions(-) diff --git a/internal/api/handlers.go b/internal/api/handlers.go index 73041f6..7864bd1 100644 --- a/internal/api/handlers.go +++ b/internal/api/handlers.go @@ -1,28 +1,17 @@ package api import ( - "context" "encoding/json" - "fmt" - "net" "net/http" "regexp" "strings" - "time" - cmv1 "github.com/cert-manager/cert-manager/pkg/apis/certmanager/v1" - cmmeta "github.com/cert-manager/cert-manager/pkg/apis/meta/v1" decositesv1alpha1 "github.com/deco-sites/decofile-operator/api/v1alpha1" apierrors "k8s.io/apimachinery/pkg/api/errors" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "sigs.k8s.io/controller-runtime/pkg/client" ) -var apiGCPIPv6Range = func() *net.IPNet { - _, n, _ := net.ParseCIDR("2600:1901::/32") - return n -}() - var domainRe = regexp.MustCompile(`^[a-zA-Z0-9]([a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])?)*$`) type Handlers struct { @@ -179,97 +168,6 @@ func (h *Handlers) list(w http.ResponseWriter, r *http.Request) { _ = json.NewEncoder(w).Encode(items) } -func (h *Handlers) retryCert(w http.ResponseWriter, r *http.Request) { - rawDomain := strings.ToLower(strings.TrimSpace(r.PathValue("domain"))) - if !domainRe.MatchString(rawDomain) { - http.Error(w, "invalid domain", http.StatusBadRequest) - return - } - domain := domainToName(rawDomain) - ns := h.nsOrDefault(r.URL.Query().Get("namespace")) - - rd := &decositesv1alpha1.DecoRedirect{} - if err := h.client.Get(r.Context(), client.ObjectKey{Name: domain, Namespace: ns}, rd); err != nil { - status := http.StatusInternalServerError - if apierrors.IsNotFound(err) { - status = http.StatusNotFound - } - http.Error(w, err.Error(), status) - return - } - - if err := checkDNSReady(r.Context(), rawDomain); err != nil { - http.Error(w, err.Error(), http.StatusUnprocessableEntity) - return - } - - cert := &cmv1.Certificate{} - certName := "redirect-" + domain - if err := h.client.Get(r.Context(), client.ObjectKey{Name: certName, Namespace: ns}, cert); err == nil { - // noop: cert is already ready or actively being issued - for _, c := range cert.Status.Conditions { - if c.Type == cmv1.CertificateConditionReady && c.Status == cmmeta.ConditionTrue { - w.Header().Set("Content-Type", "application/json") - _ = json.NewEncoder(w).Encode(toResponse(rd)) - return - } - if c.Type == cmv1.CertificateConditionIssuing && c.Status == cmmeta.ConditionTrue { - w.Header().Set("Content-Type", "application/json") - _ = json.NewEncoder(w).Encode(toResponse(rd)) - return - } - } - // stuck in backoff — delete so the controller recreates it fresh - if err := h.client.Delete(r.Context(), cert); err != nil && !apierrors.IsNotFound(err) { - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - } else if !apierrors.IsNotFound(err) { - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - - w.Header().Set("Content-Type", "application/json") - _ = json.NewEncoder(w).Encode(toResponse(rd)) -} - -// checkDNSReady verifica se o domínio está apontando para a infraestrutura Deco: -// 1. HTTP retorna redirect servido pelo nginx deco (X-Redirect-By: deco). -// 2. Sem AAAA no range GCP (2600:1901::/32) que faria o LE validar via Deno Deploy. -func checkDNSReady(ctx context.Context, domain string) error { - httpClient := &http.Client{ - CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }, - Timeout: 5 * time.Second, - } - req, err := http.NewRequestWithContext(ctx, http.MethodGet, "http://"+domain+"/", nil) - if err != nil { - return err - } - resp, err := httpClient.Do(req) - if err != nil { - return fmt.Errorf("domain not reachable: %w", err) - } - _ = resp.Body.Close() - if resp.Header.Get("X-Redirect-By") != "deco" { - return fmt.Errorf("domain is not pointing to Deco redirect IPs yet") - } - - addrs, err := net.DefaultResolver.LookupIPAddr(ctx, domain) - if err != nil { - return fmt.Errorf("DNS lookup failed: %w", err) - } - for _, a := range addrs { - ip := a.IP - if ip.To4() != nil { - continue - } - if apiGCPIPv6Range.Contains(ip) { - return fmt.Errorf("AAAA record %s points to Deno Deploy (GCP) — remove it first", ip) - } - } - return nil -} - // domainToName converts a domain to a valid k8s resource name (dots → dashes). func domainToName(d string) string { return strings.ReplaceAll(d, ".", "-") diff --git a/internal/api/server.go b/internal/api/server.go index eab4bf2..05ba842 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -23,7 +23,6 @@ func NewServer(addr, user, pass string, h *Handlers) *Server { mux.HandleFunc("POST /redirects", h.create) mux.HandleFunc("GET /redirects/{domain}", h.get) mux.HandleFunc("DELETE /redirects/{domain}", h.delete) - mux.HandleFunc("POST /redirects/{domain}/retry-cert", h.retryCert) return &Server{ addr: addr, handler: basicAuth(user, pass, mux), From abd15ce7e6dbae75ae623fb1a8742169a4024c40 Mon Sep 17 00:00:00 2001 From: igoramf Date: Fri, 5 Jun 2026 11:28:24 -0300 Subject: [PATCH 5/9] test(decoredirect): add auto-healing scenarios and make DNSReadyFunc injectable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../controller/decoredirect_controller.go | 13 +- .../decoredirect_controller_test.go | 157 ++++++++++++++++++ 2 files changed, 167 insertions(+), 3 deletions(-) diff --git a/internal/controller/decoredirect_controller.go b/internal/controller/decoredirect_controller.go index 02243f5..05e5422 100644 --- a/internal/controller/decoredirect_controller.go +++ b/internal/controller/decoredirect_controller.go @@ -40,6 +40,9 @@ type DecoRedirectReconciler struct { Scheme *runtime.Scheme IngressClass string // nginx ingress class name, e.g. "nginx" ClusterIssuer string // cert-manager ClusterIssuer name, e.g. "letsencrypt" + // DNSReadyFunc checks if the domain DNS is correctly pointing to Deco infrastructure. + // Defaults to isDNSReady. Injectable for testing. + DNSReadyFunc func(ctx context.Context, domain string) bool } // dummyBackendName satisfies the k8s Ingress API requirement for a backend on every path. @@ -225,12 +228,16 @@ func (r *DecoRedirectReconciler) maybeHealCertificate(ctx context.Context, rd *d return false, client.IgnoreNotFound(err) } - // Only act when cert-manager has given up and entered backoff (Issuing=False, Reason=Failed). - if !isCertFailed(cert) { + // Skip if already being deleted or not in the Failed backoff state. + if cert.DeletionTimestamp != nil || !isCertFailed(cert) { return false, nil } - if !isDNSReady(ctx, rd.Spec.From) { + dnsReady := r.DNSReadyFunc + if dnsReady == nil { + dnsReady = isDNSReady + } + if !dnsReady(ctx, rd.Spec.From) { log.Info("certificate in Failed backoff but DNS not ready yet", "domain", rd.Spec.From) return false, nil } diff --git a/internal/controller/decoredirect_controller_test.go b/internal/controller/decoredirect_controller_test.go index a73890a..a3846db 100644 --- a/internal/controller/decoredirect_controller_test.go +++ b/internal/controller/decoredirect_controller_test.go @@ -184,4 +184,161 @@ var _ = Describe("DecoRedirect Controller", func() { Expect(ing.Annotations["nginx.ingress.kubernetes.io/permanent-redirect-code"]).To(Equal("301")) }) }) + + Context("Auto-healing: maybeHealCertificate", func() { + const healNS = "default" + ctx := context.Background() + + newReconciler := func(dnsReady bool) *DecoRedirectReconciler { + return &DecoRedirectReconciler{ + Client: k8sClient, + Scheme: k8sClient.Scheme(), + IngressClass: "nginx", + ClusterIssuer: "letsencrypt", + DNSReadyFunc: func(_ context.Context, _ string) bool { return dnsReady }, + } + } + + // Each test uses a unique name to avoid state sharing between tests. + setup := func(suffix string) (nn, certNN types.NamespacedName, cleanup func()) { + name := "heal-" + suffix + domain := name + ".com" + nn = types.NamespacedName{Name: name + "-com", Namespace: healNS} + certNN = types.NamespacedName{Name: "redirect-" + name + "-com", Namespace: healNS} + + rd := &decositesv1alpha1.DecoRedirect{ + ObjectMeta: metav1.ObjectMeta{Name: name + "-com", Namespace: healNS}, + Spec: decositesv1alpha1.DecoRedirectSpec{ + From: domain, + To: "https://www." + domain, + }, + } + Expect(k8sClient.Create(ctx, rd)).To(Succeed()) + + cleanup = func() { + r := &decositesv1alpha1.DecoRedirect{} + if err := k8sClient.Get(ctx, nn, r); err == nil { + _ = k8sClient.Delete(ctx, r) + } + c := &cmv1.Certificate{} + if err := k8sClient.Get(ctx, certNN, c); err == nil { + _ = k8sClient.Delete(ctx, c) + } + } + return nn, certNN, cleanup + } + + patchCertFailed := func(certNN types.NamespacedName) { + cert := &cmv1.Certificate{} + Expect(k8sClient.Get(ctx, certNN, cert)).To(Succeed()) + patch := cert.DeepCopy() + patch.Status.Conditions = []cmv1.CertificateCondition{ + {Type: cmv1.CertificateConditionReady, Status: "False", Reason: "DoesNotExist", Message: "secret not found", LastTransitionTime: &[]metav1.Time{metav1.Now()}[0]}, + {Type: cmv1.CertificateConditionIssuing, Status: "False", Reason: "Failed", Message: "cert request failed", LastTransitionTime: &[]metav1.Time{metav1.Now()}[0]}, + } + Expect(k8sClient.Status().Patch(ctx, patch, client.MergeFrom(cert))).To(Succeed()) + } + + It("should delete the Certificate when it is in Failed backoff and DNS is ready", func() { + nn, certNN, cleanup := setup("delete") + DeferCleanup(cleanup) + + _, err := newReconciler(true).Reconcile(ctx, reconcile.Request{NamespacedName: nn}) + Expect(err).NotTo(HaveOccurred()) + + patchCertFailed(certNN) + + rd := &decositesv1alpha1.DecoRedirect{} + Expect(k8sClient.Get(ctx, nn, rd)).To(Succeed()) + + healed, err := newReconciler(true).maybeHealCertificate(ctx, rd) + Expect(err).NotTo(HaveOccurred()) + Expect(healed).To(BeTrue()) + + cert := &cmv1.Certificate{} + Expect(k8sClient.Get(ctx, certNN, cert)).To(MatchError(ContainSubstring("not found"))) + }) + + It("should NOT delete the Certificate when DNS is not ready", func() { + nn, certNN, cleanup := setup("dns-wrong") + DeferCleanup(cleanup) + + _, err := newReconciler(false).Reconcile(ctx, reconcile.Request{NamespacedName: nn}) + Expect(err).NotTo(HaveOccurred()) + + patchCertFailed(certNN) + + rd := &decositesv1alpha1.DecoRedirect{} + Expect(k8sClient.Get(ctx, nn, rd)).To(Succeed()) + + healed, err := newReconciler(false).maybeHealCertificate(ctx, rd) + Expect(err).NotTo(HaveOccurred()) + Expect(healed).To(BeFalse()) + + cert := &cmv1.Certificate{} + Expect(k8sClient.Get(ctx, certNN, cert)).To(Succeed()) + }) + + It("should NOT delete the Certificate when it is Issuing (actively trying)", func() { + nn, certNN, cleanup := setup("issuing") + DeferCleanup(cleanup) + + _, err := newReconciler(true).Reconcile(ctx, reconcile.Request{NamespacedName: nn}) + Expect(err).NotTo(HaveOccurred()) + + cert := &cmv1.Certificate{} + Expect(k8sClient.Get(ctx, certNN, cert)).To(Succeed()) + patch := cert.DeepCopy() + patch.Status.Conditions = []cmv1.CertificateCondition{ + {Type: cmv1.CertificateConditionIssuing, Status: "True", Reason: "Issuing", LastTransitionTime: &[]metav1.Time{metav1.Now()}[0]}, + } + Expect(k8sClient.Status().Patch(ctx, patch, client.MergeFrom(cert))).To(Succeed()) + + rd := &decositesv1alpha1.DecoRedirect{} + Expect(k8sClient.Get(ctx, nn, rd)).To(Succeed()) + + healed, err := newReconciler(true).maybeHealCertificate(ctx, rd) + Expect(err).NotTo(HaveOccurred()) + Expect(healed).To(BeFalse()) + + Expect(k8sClient.Get(ctx, certNN, cert)).To(Succeed()) + }) + + It("should NOT delete the Certificate when it is Ready", func() { + nn, certNN, cleanup := setup("ready") + DeferCleanup(cleanup) + + _, err := newReconciler(true).Reconcile(ctx, reconcile.Request{NamespacedName: nn}) + Expect(err).NotTo(HaveOccurred()) + + cert := &cmv1.Certificate{} + Expect(k8sClient.Get(ctx, certNN, cert)).To(Succeed()) + patch := cert.DeepCopy() + patch.Status.Conditions = []cmv1.CertificateCondition{ + {Type: cmv1.CertificateConditionReady, Status: "True", Reason: "Ready", LastTransitionTime: &[]metav1.Time{metav1.Now()}[0]}, + } + Expect(k8sClient.Status().Patch(ctx, patch, client.MergeFrom(cert))).To(Succeed()) + + rd := &decositesv1alpha1.DecoRedirect{} + Expect(k8sClient.Get(ctx, nn, rd)).To(Succeed()) + + healed, err := newReconciler(true).maybeHealCertificate(ctx, rd) + Expect(err).NotTo(HaveOccurred()) + Expect(healed).To(BeFalse()) + + Expect(k8sClient.Get(ctx, certNN, cert)).To(Succeed()) + }) + + It("should do nothing when the Certificate does not exist yet", func() { + nn, _, cleanup := setup("no-cert") + DeferCleanup(cleanup) + + rd := &decositesv1alpha1.DecoRedirect{} + Expect(k8sClient.Get(ctx, nn, rd)).To(Succeed()) + + healed, err := newReconciler(true).maybeHealCertificate(ctx, rd) + Expect(err).NotTo(HaveOccurred()) + Expect(healed).To(BeFalse()) + }) + }) }) From f6268423ad098ead9a9d397a32ec1e956570357c Mon Sep 17 00:00:00 2001 From: igoramf Date: Fri, 5 Jun 2026 11:49:49 -0300 Subject: [PATCH 6/9] 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. --- cmd/main.go | 27 +++++++++++--- .../controller/decoredirect_controller.go | 36 ++++++++++--------- 2 files changed, 43 insertions(+), 20 deletions(-) diff --git a/cmd/main.go b/cmd/main.go index a937366..56ebbf9 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -21,6 +21,7 @@ import ( "crypto/tls" "flag" "fmt" + "net" "os" "path/filepath" "strings" @@ -135,6 +136,10 @@ func main() { flag.StringVar(&redirectClusterIssuer, "redirect-cluster-issuer", getEnvOrDefault("REDIRECT_CLUSTER_ISSUER", "letsencrypt"), "cert-manager ClusterIssuer name (matches redirect.clusterIssuer.name in values).") + var redirectBlockedIPv6 string + flag.StringVar(&redirectBlockedIPv6, "redirect-blocked-ipv6", + getEnvOrDefault("REDIRECT_BLOCKED_IPV6", ""), + "Comma-separated list of IPv6 CIDR ranges that block cert issuance when present in a domain's AAAA records (e.g. 2600:1901::/32).") var controllersFlag string flag.StringVar(&controllersFlag, "controllers", "*", "Comma-separated list of controllers to enable. Use \"*\" to enable all. Valid values: "+ @@ -371,11 +376,25 @@ func main() { } if enabled(controller.DecoRedirectControllerName) { + var blockedIPv6CIDRs []*net.IPNet + for _, cidr := range strings.Split(redirectBlockedIPv6, ",") { + cidr = strings.TrimSpace(cidr) + if cidr == "" { + continue + } + _, ipNet, err := net.ParseCIDR(cidr) + if err != nil { + setupLog.Error(err, "invalid CIDR in --redirect-blocked-ipv6", "cidr", cidr) + os.Exit(1) + } + blockedIPv6CIDRs = append(blockedIPv6CIDRs, ipNet) + } if err = (&controller.DecoRedirectReconciler{ - Client: mgr.GetClient(), - Scheme: mgr.GetScheme(), - IngressClass: redirectIngressClass, - ClusterIssuer: redirectClusterIssuer, + Client: mgr.GetClient(), + Scheme: mgr.GetScheme(), + IngressClass: redirectIngressClass, + ClusterIssuer: redirectClusterIssuer, + BlockedIPv6CIDRs: blockedIPv6CIDRs, }).SetupWithManager(mgr); err != nil { setupLog.Error(err, "unable to create controller", "controller", "DecoRedirect") os.Exit(1) diff --git a/internal/controller/decoredirect_controller.go b/internal/controller/decoredirect_controller.go index 05e5422..3f004df 100644 --- a/internal/controller/decoredirect_controller.go +++ b/internal/controller/decoredirect_controller.go @@ -26,13 +26,6 @@ import ( decositesv1alpha1 "github.com/deco-sites/decofile-operator/api/v1alpha1" ) -// gcpIPv6Range is Google Cloud's IPv6 range used by Deno Deploy. -// Presence of a AAAA record in this range means Let's Encrypt will validate via -// Deno Deploy's IPv6 address and fail the HTTP-01 challenge. -var gcpIPv6Range = func() *net.IPNet { - _, n, _ := net.ParseCIDR("2600:1901::/32") - return n -}() // DecoRedirectReconciler reconciles a DecoRedirect object. type DecoRedirectReconciler struct { @@ -40,7 +33,12 @@ type DecoRedirectReconciler struct { Scheme *runtime.Scheme IngressClass string // nginx ingress class name, e.g. "nginx" ClusterIssuer string // cert-manager ClusterIssuer name, e.g. "letsencrypt" - // DNSReadyFunc checks if the domain DNS is correctly pointing to Deco infrastructure. + // BlockedIPv6CIDRs is a list of IPv6 CIDR ranges that, if present in a domain's + // AAAA records, indicate DNS is not ready for cert issuance. Typically these are + // legacy infrastructure addresses that intercept Let's Encrypt validation and return + // incorrect responses. When empty, no AAAA check is performed. + BlockedIPv6CIDRs []*net.IPNet + // DNSReadyFunc checks if the domain DNS is correctly pointing to the redirect infrastructure. // Defaults to isDNSReady. Injectable for testing. DNSReadyFunc func(ctx context.Context, domain string) bool } @@ -235,7 +233,7 @@ func (r *DecoRedirectReconciler) maybeHealCertificate(ctx context.Context, rd *d dnsReady := r.DNSReadyFunc if dnsReady == nil { - dnsReady = isDNSReady + dnsReady = r.isDNSReady } if !dnsReady(ctx, rd.Spec.From) { log.Info("certificate in Failed backoff but DNS not ready yet", "domain", rd.Spec.From) @@ -260,12 +258,12 @@ func isCertFailed(cert *cmv1.Certificate) bool { return false } -// isDNSReady checks that the domain is correctly pointing to Deco's redirect +// isDNSReady checks that the domain DNS is correctly pointing to the redirect // infrastructure by verifying two conditions: -// 1. An HTTP request returns a redirect served by the deco nginx (X-Redirect-By: deco). -// 2. No AAAA record in the GCP range (2600:1901::/32) exists, which would cause -// Let's Encrypt's IPv6 validation to hit Deno Deploy instead of nginx. -func isDNSReady(ctx context.Context, domain string) bool { +// 1. An HTTP request returns a redirect served by the nginx (X-Redirect-By: deco header). +// 2. No AAAA record falls within any of the BlockedIPv6CIDRs ranges, which would cause +// Let's Encrypt's IPv6 validation to reach the wrong server and fail the HTTP-01 challenge. +func (r *DecoRedirectReconciler) isDNSReady(ctx context.Context, domain string) bool { httpClient := &http.Client{ CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }, Timeout: 5 * time.Second, @@ -283,6 +281,10 @@ func isDNSReady(ctx context.Context, domain string) bool { return false } + if len(r.BlockedIPv6CIDRs) == 0 { + return true + } + addrs, err := net.DefaultResolver.LookupIPAddr(ctx, domain) if err != nil { return false @@ -292,8 +294,10 @@ func isDNSReady(ctx context.Context, domain string) bool { if ip.To4() != nil { continue // IPv4 — skip } - if gcpIPv6Range.Contains(ip) { - return false // Deno Deploy IPv6 still present + for _, blocked := range r.BlockedIPv6CIDRs { + if blocked.Contains(ip) { + return false + } } } return true From fe59e713f3836a5c1e74039cbf43c393403dc642 Mon Sep 17 00:00:00 2001 From: igoramf Date: Fri, 5 Jun 2026 11:51:32 -0300 Subject: [PATCH 7/9] Revert "feat(decoredirect): make blocked IPv6 CIDRs configurable via --redirect-blocked-ipv6" This reverts commit f6268423ad098ead9a9d397a32ec1e956570357c. --- cmd/main.go | 27 +++----------- .../controller/decoredirect_controller.go | 36 +++++++++---------- 2 files changed, 20 insertions(+), 43 deletions(-) diff --git a/cmd/main.go b/cmd/main.go index 56ebbf9..a937366 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -21,7 +21,6 @@ import ( "crypto/tls" "flag" "fmt" - "net" "os" "path/filepath" "strings" @@ -136,10 +135,6 @@ func main() { flag.StringVar(&redirectClusterIssuer, "redirect-cluster-issuer", getEnvOrDefault("REDIRECT_CLUSTER_ISSUER", "letsencrypt"), "cert-manager ClusterIssuer name (matches redirect.clusterIssuer.name in values).") - var redirectBlockedIPv6 string - flag.StringVar(&redirectBlockedIPv6, "redirect-blocked-ipv6", - getEnvOrDefault("REDIRECT_BLOCKED_IPV6", ""), - "Comma-separated list of IPv6 CIDR ranges that block cert issuance when present in a domain's AAAA records (e.g. 2600:1901::/32).") var controllersFlag string flag.StringVar(&controllersFlag, "controllers", "*", "Comma-separated list of controllers to enable. Use \"*\" to enable all. Valid values: "+ @@ -376,25 +371,11 @@ func main() { } if enabled(controller.DecoRedirectControllerName) { - var blockedIPv6CIDRs []*net.IPNet - for _, cidr := range strings.Split(redirectBlockedIPv6, ",") { - cidr = strings.TrimSpace(cidr) - if cidr == "" { - continue - } - _, ipNet, err := net.ParseCIDR(cidr) - if err != nil { - setupLog.Error(err, "invalid CIDR in --redirect-blocked-ipv6", "cidr", cidr) - os.Exit(1) - } - blockedIPv6CIDRs = append(blockedIPv6CIDRs, ipNet) - } if err = (&controller.DecoRedirectReconciler{ - Client: mgr.GetClient(), - Scheme: mgr.GetScheme(), - IngressClass: redirectIngressClass, - ClusterIssuer: redirectClusterIssuer, - BlockedIPv6CIDRs: blockedIPv6CIDRs, + Client: mgr.GetClient(), + Scheme: mgr.GetScheme(), + IngressClass: redirectIngressClass, + ClusterIssuer: redirectClusterIssuer, }).SetupWithManager(mgr); err != nil { setupLog.Error(err, "unable to create controller", "controller", "DecoRedirect") os.Exit(1) diff --git a/internal/controller/decoredirect_controller.go b/internal/controller/decoredirect_controller.go index 3f004df..05e5422 100644 --- a/internal/controller/decoredirect_controller.go +++ b/internal/controller/decoredirect_controller.go @@ -26,6 +26,13 @@ import ( decositesv1alpha1 "github.com/deco-sites/decofile-operator/api/v1alpha1" ) +// gcpIPv6Range is Google Cloud's IPv6 range used by Deno Deploy. +// Presence of a AAAA record in this range means Let's Encrypt will validate via +// Deno Deploy's IPv6 address and fail the HTTP-01 challenge. +var gcpIPv6Range = func() *net.IPNet { + _, n, _ := net.ParseCIDR("2600:1901::/32") + return n +}() // DecoRedirectReconciler reconciles a DecoRedirect object. type DecoRedirectReconciler struct { @@ -33,12 +40,7 @@ type DecoRedirectReconciler struct { Scheme *runtime.Scheme IngressClass string // nginx ingress class name, e.g. "nginx" ClusterIssuer string // cert-manager ClusterIssuer name, e.g. "letsencrypt" - // BlockedIPv6CIDRs is a list of IPv6 CIDR ranges that, if present in a domain's - // AAAA records, indicate DNS is not ready for cert issuance. Typically these are - // legacy infrastructure addresses that intercept Let's Encrypt validation and return - // incorrect responses. When empty, no AAAA check is performed. - BlockedIPv6CIDRs []*net.IPNet - // DNSReadyFunc checks if the domain DNS is correctly pointing to the redirect infrastructure. + // DNSReadyFunc checks if the domain DNS is correctly pointing to Deco infrastructure. // Defaults to isDNSReady. Injectable for testing. DNSReadyFunc func(ctx context.Context, domain string) bool } @@ -233,7 +235,7 @@ func (r *DecoRedirectReconciler) maybeHealCertificate(ctx context.Context, rd *d dnsReady := r.DNSReadyFunc if dnsReady == nil { - dnsReady = r.isDNSReady + dnsReady = isDNSReady } if !dnsReady(ctx, rd.Spec.From) { log.Info("certificate in Failed backoff but DNS not ready yet", "domain", rd.Spec.From) @@ -258,12 +260,12 @@ func isCertFailed(cert *cmv1.Certificate) bool { return false } -// isDNSReady checks that the domain DNS is correctly pointing to the redirect +// isDNSReady checks that the domain is correctly pointing to Deco's redirect // infrastructure by verifying two conditions: -// 1. An HTTP request returns a redirect served by the nginx (X-Redirect-By: deco header). -// 2. No AAAA record falls within any of the BlockedIPv6CIDRs ranges, which would cause -// Let's Encrypt's IPv6 validation to reach the wrong server and fail the HTTP-01 challenge. -func (r *DecoRedirectReconciler) isDNSReady(ctx context.Context, domain string) bool { +// 1. An HTTP request returns a redirect served by the deco nginx (X-Redirect-By: deco). +// 2. No AAAA record in the GCP range (2600:1901::/32) exists, which would cause +// Let's Encrypt's IPv6 validation to hit Deno Deploy instead of nginx. +func isDNSReady(ctx context.Context, domain string) bool { httpClient := &http.Client{ CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }, Timeout: 5 * time.Second, @@ -281,10 +283,6 @@ func (r *DecoRedirectReconciler) isDNSReady(ctx context.Context, domain string) return false } - if len(r.BlockedIPv6CIDRs) == 0 { - return true - } - addrs, err := net.DefaultResolver.LookupIPAddr(ctx, domain) if err != nil { return false @@ -294,10 +292,8 @@ func (r *DecoRedirectReconciler) isDNSReady(ctx context.Context, domain string) if ip.To4() != nil { continue // IPv4 — skip } - for _, blocked := range r.BlockedIPv6CIDRs { - if blocked.Contains(ip) { - return false - } + if gcpIPv6Range.Contains(ip) { + return false // Deno Deploy IPv6 still present } } return true From e837ffabcddb591bc09ae9f9f515a43654bf5a82 Mon Sep 17 00:00:00 2001 From: igoramf Date: Fri, 5 Jun 2026 12:01:44 -0300 Subject: [PATCH 8/9] 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). --- ...eployment-operator-controller-manager.yaml | 3 ++ chart/values.yaml | 1 + cmd/main.go | 27 +++++++++++-- .../controller/decoredirect_controller.go | 40 ++++++++++--------- 4 files changed, 48 insertions(+), 23 deletions(-) diff --git a/chart/templates/deployment-operator-controller-manager.yaml b/chart/templates/deployment-operator-controller-manager.yaml index e3d51af..d1e5e9d 100644 --- a/chart/templates/deployment-operator-controller-manager.yaml +++ b/chart/templates/deployment-operator-controller-manager.yaml @@ -38,6 +38,9 @@ spec: - --redirect-ingress-class={{ .Values.redirect.ingressClass }} - --redirect-cluster-issuer={{ .Values.redirect.clusterIssuer.name }} {{- end }} + {{- if .Values.redirect.blockedIPv6CIDRs }} + - --redirect-blocked-ipv6={{ join "," .Values.redirect.blockedIPv6CIDRs }} + {{- end }} {{- if .Values.redirect.namespace }} - --redirect-namespace={{ .Values.redirect.namespace }} {{- end }} diff --git a/chart/values.yaml b/chart/values.yaml index eb3be50..e83e90f 100644 --- a/chart/values.yaml +++ b/chart/values.yaml @@ -140,6 +140,7 @@ operatorApi: redirect: namespace: "deco-redirect-system" ingressClass: "" # set to enable DecoRedirect controller (e.g. "redirect-nginx") + blockedIPv6CIDRs: [] # IPv6 CIDRs that block cert issuance when present in AAAA records (e.g. ["2600:1901::/32"]) clusterIssuer: enabled: false # set true to create the Let's Encrypt ClusterIssuer name: "" # ClusterIssuer name (e.g. "letsencrypt") diff --git a/cmd/main.go b/cmd/main.go index a937366..bd1e99c 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -21,6 +21,7 @@ import ( "crypto/tls" "flag" "fmt" + "net" "os" "path/filepath" "strings" @@ -135,6 +136,10 @@ func main() { flag.StringVar(&redirectClusterIssuer, "redirect-cluster-issuer", getEnvOrDefault("REDIRECT_CLUSTER_ISSUER", "letsencrypt"), "cert-manager ClusterIssuer name (matches redirect.clusterIssuer.name in values).") + var redirectBlockedIPv6 string + flag.StringVar(&redirectBlockedIPv6, "redirect-blocked-ipv6", + getEnvOrDefault("REDIRECT_BLOCKED_IPV6", ""), + "Comma-separated IPv6 CIDRs that block cert issuance when present in a domain's AAAA records (e.g. 2600:1901::/32).") var controllersFlag string flag.StringVar(&controllersFlag, "controllers", "*", "Comma-separated list of controllers to enable. Use \"*\" to enable all. Valid values: "+ @@ -371,11 +376,25 @@ func main() { } if enabled(controller.DecoRedirectControllerName) { + var blockedIPv6CIDRs []*net.IPNet + for _, cidr := range strings.Split(redirectBlockedIPv6, ",") { + cidr = strings.TrimSpace(cidr) + if cidr == "" { + continue + } + _, ipNet, cidrErr := net.ParseCIDR(cidr) + if cidrErr != nil { + setupLog.Error(cidrErr, "invalid CIDR in --redirect-blocked-ipv6", "cidr", cidr) + os.Exit(1) + } + blockedIPv6CIDRs = append(blockedIPv6CIDRs, ipNet) + } if err = (&controller.DecoRedirectReconciler{ - Client: mgr.GetClient(), - Scheme: mgr.GetScheme(), - IngressClass: redirectIngressClass, - ClusterIssuer: redirectClusterIssuer, + Client: mgr.GetClient(), + Scheme: mgr.GetScheme(), + IngressClass: redirectIngressClass, + ClusterIssuer: redirectClusterIssuer, + BlockedIPv6CIDRs: blockedIPv6CIDRs, }).SetupWithManager(mgr); err != nil { setupLog.Error(err, "unable to create controller", "controller", "DecoRedirect") os.Exit(1) diff --git a/internal/controller/decoredirect_controller.go b/internal/controller/decoredirect_controller.go index 05e5422..16a4482 100644 --- a/internal/controller/decoredirect_controller.go +++ b/internal/controller/decoredirect_controller.go @@ -26,21 +26,18 @@ import ( decositesv1alpha1 "github.com/deco-sites/decofile-operator/api/v1alpha1" ) -// gcpIPv6Range is Google Cloud's IPv6 range used by Deno Deploy. -// Presence of a AAAA record in this range means Let's Encrypt will validate via -// Deno Deploy's IPv6 address and fail the HTTP-01 challenge. -var gcpIPv6Range = func() *net.IPNet { - _, n, _ := net.ParseCIDR("2600:1901::/32") - return n -}() - // DecoRedirectReconciler reconciles a DecoRedirect object. type DecoRedirectReconciler struct { client.Client Scheme *runtime.Scheme IngressClass string // nginx ingress class name, e.g. "nginx" ClusterIssuer string // cert-manager ClusterIssuer name, e.g. "letsencrypt" - // DNSReadyFunc checks if the domain DNS is correctly pointing to Deco infrastructure. + // BlockedIPv6CIDRs is a list of IPv6 CIDR ranges that, if present in a domain's + // AAAA records, indicate DNS is not ready for cert issuance. Typically legacy + // infrastructure addresses that intercept Let's Encrypt validation incorrectly. + // When empty, no AAAA check is performed. + BlockedIPv6CIDRs []*net.IPNet + // DNSReadyFunc checks if the domain DNS is correctly pointing to the redirect infrastructure. // Defaults to isDNSReady. Injectable for testing. DNSReadyFunc func(ctx context.Context, domain string) bool } @@ -235,7 +232,7 @@ func (r *DecoRedirectReconciler) maybeHealCertificate(ctx context.Context, rd *d dnsReady := r.DNSReadyFunc if dnsReady == nil { - dnsReady = isDNSReady + dnsReady = r.isDNSReady } if !dnsReady(ctx, rd.Spec.From) { log.Info("certificate in Failed backoff but DNS not ready yet", "domain", rd.Spec.From) @@ -260,12 +257,11 @@ func isCertFailed(cert *cmv1.Certificate) bool { return false } -// isDNSReady checks that the domain is correctly pointing to Deco's redirect -// infrastructure by verifying two conditions: -// 1. An HTTP request returns a redirect served by the deco nginx (X-Redirect-By: deco). -// 2. No AAAA record in the GCP range (2600:1901::/32) exists, which would cause -// Let's Encrypt's IPv6 validation to hit Deno Deploy instead of nginx. -func isDNSReady(ctx context.Context, domain string) bool { +// isDNSReady checks that the domain is correctly pointing to the redirect infrastructure: +// 1. An HTTP request returns a redirect served by the nginx (X-Redirect-By: deco header). +// 2. No AAAA record falls within any BlockedIPv6CIDRs range, which would cause +// Let's Encrypt's IPv6 validation to reach the wrong server and fail the challenge. +func (r *DecoRedirectReconciler) isDNSReady(ctx context.Context, domain string) bool { httpClient := &http.Client{ CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }, Timeout: 5 * time.Second, @@ -283,6 +279,10 @@ func isDNSReady(ctx context.Context, domain string) bool { return false } + if len(r.BlockedIPv6CIDRs) == 0 { + return true + } + addrs, err := net.DefaultResolver.LookupIPAddr(ctx, domain) if err != nil { return false @@ -290,10 +290,12 @@ func isDNSReady(ctx context.Context, domain string) bool { for _, a := range addrs { ip := a.IP if ip.To4() != nil { - continue // IPv4 — skip + continue } - if gcpIPv6Range.Contains(ip) { - return false // Deno Deploy IPv6 still present + for _, blocked := range r.BlockedIPv6CIDRs { + if blocked.Contains(ip) { + return false + } } } return true From 34d997bc75aea5df2fb7c9e12279c2d848cfe860 Mon Sep 17 00:00:00 2001 From: igoramf Date: Fri, 5 Jun 2026 12:08:05 -0300 Subject: [PATCH 9/9] fix(helm): add blockedIPv6CIDRs arg to helm-generator --- hack/helm-generator/main.go | 3 +++ 1 file changed, 3 insertions(+) diff --git a/hack/helm-generator/main.go b/hack/helm-generator/main.go index 56c7cb8..c322040 100644 --- a/hack/helm-generator/main.go +++ b/hack/helm-generator/main.go @@ -434,6 +434,9 @@ func addRedirectControllerArgs(templatesDir string) error { - --redirect-ingress-class={{ .Values.redirect.ingressClass }} - --redirect-cluster-issuer={{ .Values.redirect.clusterIssuer.name }} {{- end }} + {{- if .Values.redirect.blockedIPv6CIDRs }} + - --redirect-blocked-ipv6={{ join "," .Values.redirect.blockedIPv6CIDRs }} + {{- end }} {{- if .Values.redirect.namespace }} - --redirect-namespace={{ .Values.redirect.namespace }} {{- end }}`