diff --git a/.gitignore b/.gitignore index 9753218..770db48 100644 --- a/.gitignore +++ b/.gitignore @@ -28,3 +28,4 @@ go.work *.swp *.swo *~ +values-local.yaml diff --git a/api/v1alpha1/decoredirect_types.go b/api/v1alpha1/decoredirect_types.go index 2aff149..67eac01 100644 --- a/api/v1alpha1/decoredirect_types.go +++ b/api/v1alpha1/decoredirect_types.go @@ -22,6 +22,13 @@ type DecoRedirectSpec struct { // +kubebuilder:validation:MaxLength=2048 // +kubebuilder:validation:Pattern=`^https?://` To string `json:"to"` + + // RedirectCode is the HTTP status code used for the redirect. Allowed values: 301, 307. + // Defaults to 307 (Temporary Redirect) if not set. + // +kubebuilder:validation:Enum=301;307 + // +kubebuilder:default=307 + // +optional + RedirectCode *int `json:"redirectCode,omitempty"` } // DecoRedirectStatus defines the observed state of DecoRedirect. diff --git a/api/v1alpha1/zz_generated.deepcopy.go b/api/v1alpha1/zz_generated.deepcopy.go index 03a1280..aa9b034 100644 --- a/api/v1alpha1/zz_generated.deepcopy.go +++ b/api/v1alpha1/zz_generated.deepcopy.go @@ -163,7 +163,7 @@ func (in *DecoRedirect) DeepCopyInto(out *DecoRedirect) { *out = *in out.TypeMeta = in.TypeMeta in.ObjectMeta.DeepCopyInto(&out.ObjectMeta) - out.Spec = in.Spec + in.Spec.DeepCopyInto(&out.Spec) in.Status.DeepCopyInto(&out.Status) } @@ -220,6 +220,11 @@ func (in *DecoRedirectList) DeepCopyObject() runtime.Object { // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *DecoRedirectSpec) DeepCopyInto(out *DecoRedirectSpec) { *out = *in + if in.RedirectCode != nil { + in, out := &in.RedirectCode, &out.RedirectCode + *out = new(int) + **out = **in + } } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new DecoRedirectSpec. diff --git a/chart/templates/NOTES.txt b/chart/templates/NOTES.txt new file mode 100644 index 0000000..c41b018 --- /dev/null +++ b/chart/templates/NOTES.txt @@ -0,0 +1,9 @@ +{{- if .Values.redirect.customHeaders.enabled }} +Custom response headers are enabled for apex redirects: +{{- range $key, $val := .Values.redirect.customHeaders.headers }} + {{ $key }}: {{ $val }} +{{- end }} + +To activate them, make sure the nginx controller is configured to read the header ConfigMap: + ingress-nginx.controller.config.add-headers: "{{ .Values.redirect.namespace }}/redirect-custom-headers" +{{- end }} diff --git a/chart/templates/configmap-redirect-custom-headers.yaml b/chart/templates/configmap-redirect-custom-headers.yaml new file mode 100644 index 0000000..11ac2bf --- /dev/null +++ b/chart/templates/configmap-redirect-custom-headers.yaml @@ -0,0 +1,11 @@ +{{- if and .Values.redirect.customHeaders.enabled .Values.redirect.customHeaders.headers }} +apiVersion: v1 +kind: ConfigMap +metadata: + name: redirect-response-headers + namespace: {{ .Values.redirect.namespace }} +data: + {{- range $key, $val := .Values.redirect.customHeaders.headers }} + {{ $key }}: {{ $val | quote }} + {{- end }} +{{- end }} diff --git a/chart/templates/customresourcedefinition-decoredict.deco.sites.yaml b/chart/templates/customresourcedefinition-decoredict.deco.sites.yaml index cf47501..e5bb60a 100644 --- a/chart/templates/customresourcedefinition-decoredict.deco.sites.yaml +++ b/chart/templates/customresourcedefinition-decoredict.deco.sites.yaml @@ -55,6 +55,15 @@ spec: minLength: 1 pattern: ^[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: string + redirectCode: + default: 307 + description: |- + RedirectCode is the HTTP status code used for the redirect. Allowed values: 301, 307. + Defaults to 307 (Temporary Redirect) if not set. + enum: + - 301 + - 307 + type: integer to: description: To is the full target URL within the same domain (e.g. "https://www.client.com"). diff --git a/chart/values.yaml b/chart/values.yaml index 5cfad51..eb3be50 100644 --- a/chart/values.yaml +++ b/chart/values.yaml @@ -146,6 +146,13 @@ redirect: email: "" # required by Let's Encrypt ACME staging: false # set true to use Let's Encrypt staging (avoids rate limits when testing) solverAnnotations: {} # extra annotations on the HTTP-01 challenge Ingress (e.g. nginx.ingress.kubernetes.io/ssl-redirect: "false") + customHeaders: + enabled: false # set true to inject custom response headers into all redirects served by this nginx instance + headers: {} # map of header name → value; e.g. X-Redirect-By: "deco" + # Example: + # headers: + # X-Redirect-By: "deco" + # X-Powered-By: "myplatform" # ingress-nginx subchart (opt-in) — set enabled: true to deploy nginx alongside the operator. # namespaceOverride isolates nginx from the operator namespace. @@ -160,6 +167,10 @@ ingress-nginx: controllerValue: "k8s.io/redirect-nginx" service: annotations: {} + config: + # Set to "/redirect-response-headers" when redirect.customHeaders.enabled=true. + # The ConfigMap is only created when redirect.customHeaders.enabled=true. + add-headers: "" # Name overrides nameOverride: "" diff --git a/config/crd/bases/deco.sites_decoredict.yaml b/config/crd/bases/deco.sites_decoredict.yaml index af25b0e..069ede5 100644 --- a/config/crd/bases/deco.sites_decoredict.yaml +++ b/config/crd/bases/deco.sites_decoredict.yaml @@ -56,6 +56,15 @@ spec: minLength: 1 pattern: ^[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: string + redirectCode: + default: 307 + description: |- + RedirectCode is the HTTP status code used for the redirect. Allowed values: 301, 307. + Defaults to 307 (Temporary Redirect) if not set. + enum: + - 301 + - 307 + type: integer to: description: To is the full target URL within the same domain (e.g. "https://www.client.com"). diff --git a/hack/helm-generator/main.go b/hack/helm-generator/main.go index c97b0d5..56c7cb8 100644 --- a/hack/helm-generator/main.go +++ b/hack/helm-generator/main.go @@ -105,6 +105,9 @@ func main() { if err := addClusterIssuer(templatesDir); err != nil { fmt.Fprintf(os.Stderr, "Warning: Could not add ClusterIssuer: %v\n", err) } + if err := addRedirectCustomHeaders(templatesDir); err != nil { + fmt.Fprintf(os.Stderr, "Warning: Could not add redirect custom headers ConfigMap: %v\n", err) + } if err := addRedirectControllerArgs(templatesDir); err != nil { fmt.Fprintf(os.Stderr, "Warning: Could not add redirect controller args: %v\n", err) } @@ -399,6 +402,22 @@ spec: return os.WriteFile(filepath.Join(templatesDir, "clusterissuer-letsencrypt.yaml"), []byte(content), 0644) } +func addRedirectCustomHeaders(templatesDir string) error { + content := `{{- if and .Values.redirect.customHeaders.enabled .Values.redirect.customHeaders.headers }} +apiVersion: v1 +kind: ConfigMap +metadata: + name: redirect-response-headers + namespace: {{ .Values.redirect.namespace }} +data: + {{- range $key, $val := .Values.redirect.customHeaders.headers }} + {{ $key }}: {{ $val | quote }} + {{- end }} +{{- end }} +` + return os.WriteFile(filepath.Join(templatesDir, "configmap-redirect-custom-headers.yaml"), []byte(content), 0644) +} + func addRedirectControllerArgs(templatesDir string) error { files, err := filepath.Glob(filepath.Join(templatesDir, "deployment-*.yaml")) if err != nil || len(files) == 0 { diff --git a/internal/api/handlers.go b/internal/api/handlers.go index 3971417..7864bd1 100644 --- a/internal/api/handlers.go +++ b/internal/api/handlers.go @@ -27,14 +27,16 @@ func NewHandlers(c client.Client, defaultNamespace string) *Handlers { } type redirectRequest struct { - From string `json:"from"` - To string `json:"to"` - Namespace string `json:"namespace,omitempty"` + From string `json:"from"` + To string `json:"to"` + Namespace string `json:"namespace,omitempty"` + RedirectCode *int `json:"redirectCode,omitempty"` } type redirectResponse struct { From string `json:"from"` To string `json:"to"` + RedirectCode *int `json:"redirectCode,omitempty"` CertificateReady bool `json:"certificateReady"` Message string `json:"message,omitempty"` CreatedAt string `json:"createdAt"` @@ -42,9 +44,10 @@ type redirectResponse struct { func toResponse(rd *decositesv1alpha1.DecoRedirect) redirectResponse { resp := redirectResponse{ - From: rd.Spec.From, - To: rd.Spec.To, - CreatedAt: rd.CreationTimestamp.UTC().Format("2006-01-02T15:04:05Z"), + From: rd.Spec.From, + To: rd.Spec.To, + RedirectCode: rd.Spec.RedirectCode, + CreatedAt: rd.CreationTimestamp.UTC().Format("2006-01-02T15:04:05Z"), } for _, c := range rd.Status.Conditions { if c.Type == "CertificateReady" { @@ -85,10 +88,12 @@ func (h *Handlers) create(w http.ResponseWriter, r *http.Request) { Namespace: ns, }, Spec: decositesv1alpha1.DecoRedirectSpec{ - From: from, // original domain preserved for CEL validation - To: to, + From: from, // original domain preserved for CEL validation + To: to, + RedirectCode: req.RedirectCode, }, } + // redirectCode enum validation (301|307) is enforced by the CRD schema; invalid values return 422. if err := h.client.Create(r.Context(), rd); err != nil { status := http.StatusInternalServerError if apierrors.IsInvalid(err) { diff --git a/internal/api/server_test.go b/internal/api/server_test.go index 7ad2dd3..129e302 100644 --- a/internal/api/server_test.go +++ b/internal/api/server_test.go @@ -173,3 +173,64 @@ func TestList_HappyPath(t *testing.T) { t.Fatalf("expected 1 item, got %d", len(items)) } } + +func TestCreate_WithRedirectCode(t *testing.T) { + scheme := runtime.NewScheme() + _ = clientgoscheme.AddToScheme(scheme) + _ = decositesv1alpha1.AddToScheme(scheme) + fc := fake.NewClientBuilder().WithScheme(scheme).Build() + h := api.NewHandlers(fc, "deco-redirect-system") + srv := api.NewServer(":0", "user", "pass", h) + + code := 301 + body, _ := json.Marshal(map[string]interface{}{"from": "example.com", "to": "https://www.example.com", "redirectCode": code}) + req := httptest.NewRequest(http.MethodPost, "/redirects", bytes.NewReader(body)) + req.SetBasicAuth("user", "pass") + rec := httptest.NewRecorder() + srv.ServeHTTP(rec, req) + if rec.Code != http.StatusCreated { + t.Fatalf("expected 201, got %d: %s", rec.Code, rec.Body.String()) + } + + list := &decositesv1alpha1.DecoRedirectList{} + _ = fc.List(context.Background(), list) + if len(list.Items) != 1 { + t.Fatalf("expected 1 item, got %d", len(list.Items)) + } + if list.Items[0].Spec.RedirectCode == nil || *list.Items[0].Spec.RedirectCode != 301 { + t.Fatalf("expected redirectCode=301, got %v", list.Items[0].Spec.RedirectCode) + } +} + +func TestGet_IncludesRedirectCode(t *testing.T) { + scheme := runtime.NewScheme() + _ = clientgoscheme.AddToScheme(scheme) + _ = decositesv1alpha1.AddToScheme(scheme) + code := 301 + fc := fake.NewClientBuilder().WithScheme(scheme).WithObjects(&decositesv1alpha1.DecoRedirect{ + ObjectMeta: metav1.ObjectMeta{Name: "example-com", Namespace: "deco-redirect-system"}, + Spec: decositesv1alpha1.DecoRedirectSpec{ + From: "example.com", + To: "https://www.example.com", + RedirectCode: &code, + }, + }).Build() + h := api.NewHandlers(fc, "deco-redirect-system") + srv := api.NewServer(":0", "user", "pass", h) + + req := httptest.NewRequest(http.MethodGet, "/redirects/example.com", nil) + req.SetBasicAuth("user", "pass") + rec := httptest.NewRecorder() + srv.ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("expected 200, got %d", rec.Code) + } + var item struct { + From string `json:"from"` + RedirectCode *int `json:"redirectCode"` + } + _ = json.NewDecoder(rec.Body).Decode(&item) + if item.RedirectCode == nil || *item.RedirectCode != 301 { + t.Fatalf("expected redirectCode=301 in response, got %v", item.RedirectCode) + } +} diff --git a/internal/controller/decoredirect_controller.go b/internal/controller/decoredirect_controller.go index dfc8448..0238ca0 100644 --- a/internal/controller/decoredirect_controller.go +++ b/internal/controller/decoredirect_controller.go @@ -4,6 +4,7 @@ import ( "context" "crypto/sha256" "fmt" + "strconv" "strings" "time" @@ -108,10 +109,16 @@ func (r *DecoRedirectReconciler) reconcileIngress(ctx context.Context, rd *decos return err } - // nginx returns 301 via the permanent-redirect annotation before reaching any backend. + code := 307 + if rd.Spec.RedirectCode != nil { + code = *rd.Spec.RedirectCode + } + + // nginx returns the configured redirect code (default 307) via the permanent-redirect annotation before reaching any backend. _, err := controllerutil.CreateOrUpdate(ctx, r.Client, ingress, func() error { ingress.Annotations = map[string]string{ - "nginx.ingress.kubernetes.io/permanent-redirect": rd.Spec.To, + "nginx.ingress.kubernetes.io/permanent-redirect": rd.Spec.To, + "nginx.ingress.kubernetes.io/permanent-redirect-code": strconv.Itoa(code), } ingress.Spec = networkingv1.IngressSpec{ IngressClassName: &r.IngressClass, diff --git a/internal/controller/decoredirect_controller_test.go b/internal/controller/decoredirect_controller_test.go index 7ce6c4f..a73890a 100644 --- a/internal/controller/decoredirect_controller_test.go +++ b/internal/controller/decoredirect_controller_test.go @@ -117,6 +117,20 @@ var _ = Describe("DecoRedirect Controller", func() { Expect(err.Error()).To(ContainSubstring("redirect target must be within the same domain")) }) + It("should reject a DecoRedirect with an invalid redirectCode", func() { + invalidCode := 302 + err := k8sClient.Create(ctx, &decositesv1alpha1.DecoRedirect{ + ObjectMeta: metav1.ObjectMeta{Name: "invalid-code", Namespace: rdNS}, + Spec: decositesv1alpha1.DecoRedirectSpec{ + From: "invalid-code.com", + To: "https://www.invalid-code.com", + RedirectCode: &invalidCode, + }, + }) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("redirectCode")) + }) + It("should not create duplicate Certificate on repeated reconcile", func() { _, err := newReconciler().Reconcile(ctx, reconcile.Request{NamespacedName: nn}) Expect(err).NotTo(HaveOccurred()) @@ -134,5 +148,40 @@ var _ = Describe("DecoRedirect Controller", func() { } Expect(count).To(Equal(1)) }) + + It("should set permanent-redirect-code to 307 by default", func() { + _, err := newReconciler().Reconcile(ctx, reconcile.Request{NamespacedName: nn}) + Expect(err).NotTo(HaveOccurred()) + + ing := &networkingv1.Ingress{} + Expect(k8sClient.Get(ctx, types.NamespacedName{ + Name: "redirect-client-com", Namespace: rdNS, + }, ing)).To(Succeed()) + Expect(ing.Annotations["nginx.ingress.kubernetes.io/permanent-redirect-code"]).To(Equal("307")) + }) + + It("should use redirectCode 301 in the Ingress annotation when specified", func() { + code := 301 + rd301 := &decositesv1alpha1.DecoRedirect{ + ObjectMeta: metav1.ObjectMeta{Name: "test-redirect-301", Namespace: rdNS}, + Spec: decositesv1alpha1.DecoRedirectSpec{ + From: "redirect301.com", + To: "https://www.redirect301.com", + RedirectCode: &code, + }, + } + Expect(k8sClient.Create(ctx, rd301)).To(Succeed()) + DeferCleanup(func() { _ = k8sClient.Delete(ctx, rd301) }) + + nn301 := types.NamespacedName{Name: "test-redirect-301", Namespace: rdNS} + _, err := newReconciler().Reconcile(ctx, reconcile.Request{NamespacedName: nn301}) + Expect(err).NotTo(HaveOccurred()) + + ing := &networkingv1.Ingress{} + Expect(k8sClient.Get(ctx, types.NamespacedName{ + Name: "redirect-redirect301-com", Namespace: rdNS, + }, ing)).To(Succeed()) + Expect(ing.Annotations["nginx.ingress.kubernetes.io/permanent-redirect-code"]).To(Equal("301")) + }) }) })