diff --git a/api/v1alpha1/httpsedge_types.go b/api/v1alpha1/httpsedge_types.go index b99a0ed9..bd03bae2 100644 --- a/api/v1alpha1/httpsedge_types.go +++ b/api/v1alpha1/httpsedge_types.go @@ -53,6 +53,9 @@ type HTTPSEdgeRouteSpec struct { // IPRestriction is an IPRestriction to apply to this route IPRestriction *EndpointIPPolicy `json:"ipRestriction,omitempty"` + + // Headers are request/response headers to apply to this route + Headers *EndpointHeaders `json:"headers,omitempty"` } // HTTPSEdgeSpec defines the desired state of HTTPSEdge diff --git a/api/v1alpha1/ngrok_common.go b/api/v1alpha1/ngrok_common.go index 63b903bf..58907bb3 100644 --- a/api/v1alpha1/ngrok_common.go +++ b/api/v1alpha1/ngrok_common.go @@ -14,10 +14,38 @@ type ngrokAPICommon struct { type EndpointCompression struct { // Enabled is whether or not to enable compression for this endpoint - Enabled *bool `json:"enabled,omitempty"` + Enabled bool `json:"enabled,omitempty"` } type EndpointIPPolicy struct { - Enabled *bool `json:"enabled,omitempty"` IPPolicyIDs []string `json:"policyIDs,omitempty"` } + +// EndpointRequestHeaders is the configuration for a HTTPSEdgeRoute's request headers +// to be added or removed from the request before it is sent to the backend service. +type EndpointRequestHeaders struct { + // a map of header key to header value that will be injected into the HTTP Request + // before being sent to the upstream application server + Add map[string]string `json:"add,omitempty"` + // a list of header names that will be removed from the HTTP Request before being + // sent to the upstream application server + Remove []string `json:"remove,omitempty"` +} + +// EndpointResponseHeaders is the configuration for a HTTPSEdgeRoute's response headers +// to be added or removed from the response before it is sent to the client. +type EndpointResponseHeaders struct { + // a map of header key to header value that will be injected into the HTTP Response + // returned to the HTTP client + Add map[string]string `json:"add,omitempty"` + // a list of header names that will be removed from the HTTP Response returned to + // the HTTP client + Remove []string `json:"remove,omitempty"` +} + +type EndpointHeaders struct { + // Request headers are the request headers module configuration or null + Request *EndpointRequestHeaders `json:"request,omitempty"` + // Response headers are the response headers module configuration or null + Response *EndpointResponseHeaders `json:"response,omitempty"` +} diff --git a/api/v1alpha1/zz_generated.deepcopy.go b/api/v1alpha1/zz_generated.deepcopy.go index beba86c0..114aa9f6 100644 --- a/api/v1alpha1/zz_generated.deepcopy.go +++ b/api/v1alpha1/zz_generated.deepcopy.go @@ -131,11 +131,6 @@ func (in *DomainStatus) DeepCopy() *DomainStatus { // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *EndpointCompression) DeepCopyInto(out *EndpointCompression) { *out = *in - if in.Enabled != nil { - in, out := &in.Enabled, &out.Enabled - *out = new(bool) - **out = **in - } } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new EndpointCompression. @@ -149,13 +144,33 @@ func (in *EndpointCompression) DeepCopy() *EndpointCompression { } // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. -func (in *EndpointIPPolicy) DeepCopyInto(out *EndpointIPPolicy) { +func (in *EndpointHeaders) DeepCopyInto(out *EndpointHeaders) { *out = *in - if in.Enabled != nil { - in, out := &in.Enabled, &out.Enabled - *out = new(bool) - **out = **in + if in.Request != nil { + in, out := &in.Request, &out.Request + *out = new(EndpointRequestHeaders) + (*in).DeepCopyInto(*out) + } + if in.Response != nil { + in, out := &in.Response, &out.Response + *out = new(EndpointResponseHeaders) + (*in).DeepCopyInto(*out) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new EndpointHeaders. +func (in *EndpointHeaders) DeepCopy() *EndpointHeaders { + if in == nil { + return nil } + out := new(EndpointHeaders) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *EndpointIPPolicy) DeepCopyInto(out *EndpointIPPolicy) { + *out = *in if in.IPPolicyIDs != nil { in, out := &in.IPPolicyIDs, &out.IPPolicyIDs *out = make([]string, len(*in)) @@ -173,6 +188,60 @@ func (in *EndpointIPPolicy) DeepCopy() *EndpointIPPolicy { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *EndpointRequestHeaders) DeepCopyInto(out *EndpointRequestHeaders) { + *out = *in + if in.Add != nil { + in, out := &in.Add, &out.Add + *out = make(map[string]string, len(*in)) + for key, val := range *in { + (*out)[key] = val + } + } + if in.Remove != nil { + in, out := &in.Remove, &out.Remove + *out = make([]string, len(*in)) + copy(*out, *in) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new EndpointRequestHeaders. +func (in *EndpointRequestHeaders) DeepCopy() *EndpointRequestHeaders { + if in == nil { + return nil + } + out := new(EndpointRequestHeaders) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *EndpointResponseHeaders) DeepCopyInto(out *EndpointResponseHeaders) { + *out = *in + if in.Add != nil { + in, out := &in.Add, &out.Add + *out = make(map[string]string, len(*in)) + for key, val := range *in { + (*out)[key] = val + } + } + if in.Remove != nil { + in, out := &in.Remove, &out.Remove + *out = make([]string, len(*in)) + copy(*out, *in) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new EndpointResponseHeaders. +func (in *EndpointResponseHeaders) DeepCopy() *EndpointResponseHeaders { + if in == nil { + return nil + } + out := new(EndpointResponseHeaders) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *HTTPSEdge) DeepCopyInto(out *HTTPSEdge) { *out = *in @@ -240,13 +309,18 @@ func (in *HTTPSEdgeRouteSpec) DeepCopyInto(out *HTTPSEdgeRouteSpec) { if in.Compression != nil { in, out := &in.Compression, &out.Compression *out = new(EndpointCompression) - (*in).DeepCopyInto(*out) + **out = **in } if in.IPRestriction != nil { in, out := &in.IPRestriction, &out.IPRestriction *out = new(EndpointIPPolicy) (*in).DeepCopyInto(*out) } + if in.Headers != nil { + in, out := &in.Headers, &out.Headers + *out = new(EndpointHeaders) + (*in).DeepCopyInto(*out) + } } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new HTTPSEdgeRouteSpec. diff --git a/helm/ingress-controller/templates/crds/ingress.k8s.ngrok.com_httpsedges.yaml b/helm/ingress-controller/templates/crds/ingress.k8s.ngrok.com_httpsedges.yaml index 5dda8c24..05c124da 100644 --- a/helm/ingress-controller/templates/crds/ingress.k8s.ngrok.com_httpsedges.yaml +++ b/helm/ingress-controller/templates/crds/ingress.k8s.ngrok.com_httpsedges.yaml @@ -88,12 +88,52 @@ spec: description: Description is a human-readable description of the object in the ngrok API/Dashboard type: string + headers: + description: Headers are request/response headers to apply to + this route + properties: + request: + description: Request headers are the request headers module + configuration or null + properties: + add: + additionalProperties: + type: string + description: a map of header key to header value that + will be injected into the HTTP Request before being + sent to the upstream application server + type: object + remove: + description: a list of header names that will be removed + from the HTTP Request before being sent to the upstream + application server + items: + type: string + type: array + type: object + response: + description: Response headers are the response headers module + configuration or null + properties: + add: + additionalProperties: + type: string + description: a map of header key to header value that + will be injected into the HTTP Response returned to + the HTTP client + type: object + remove: + description: a list of header names that will be removed + from the HTTP Response returned to the HTTP client + items: + type: string + type: array + type: object + type: object ipRestriction: description: IPRestriction is an IPRestriction to apply to this route properties: - enabled: - type: boolean policyIDs: items: type: string diff --git a/helm/ingress-controller/templates/crds/ingress.k8s.ngrok.com_tcpedges.yaml b/helm/ingress-controller/templates/crds/ingress.k8s.ngrok.com_tcpedges.yaml index f887679f..eef0568d 100644 --- a/helm/ingress-controller/templates/crds/ingress.k8s.ngrok.com_tcpedges.yaml +++ b/helm/ingress-controller/templates/crds/ingress.k8s.ngrok.com_tcpedges.yaml @@ -80,8 +80,6 @@ spec: ipRestriction: description: IPRestriction is an IPRestriction to apply to this route properties: - enabled: - type: boolean policyIDs: items: type: string diff --git a/internal/annotations/annotations.go b/internal/annotations/annotations.go index 5170f561..05998e36 100644 --- a/internal/annotations/annotations.go +++ b/internal/annotations/annotations.go @@ -20,6 +20,7 @@ import ( "github.com/imdario/mergo" ingressv1alpha1 "github.com/ngrok/kubernetes-ingress-controller/api/v1alpha1" "github.com/ngrok/kubernetes-ingress-controller/internal/annotations/compression" + "github.com/ngrok/kubernetes-ingress-controller/internal/annotations/headers" "github.com/ngrok/kubernetes-ingress-controller/internal/annotations/ip_policies" "github.com/ngrok/kubernetes-ingress-controller/internal/annotations/parser" "github.com/ngrok/kubernetes-ingress-controller/internal/errors" @@ -32,6 +33,7 @@ const DeniedKeyName = "Denied" type RouteModules struct { Compression *ingressv1alpha1.EndpointCompression + Headers *ingressv1alpha1.EndpointHeaders IPRestriction *ingressv1alpha1.EndpointIPPolicy } @@ -43,6 +45,7 @@ func NewAnnotationsExtractor() Extractor { return Extractor{ annotations: map[string]parser.IngressAnnotation{ "Compression": compression.NewParser(), + "Headers": headers.NewParser(), "IPRestriction": ip_policies.NewParser(), }, } diff --git a/internal/annotations/annotations_test.go b/internal/annotations/annotations_test.go index d0dbb4d8..aae06ba7 100644 --- a/internal/annotations/annotations_test.go +++ b/internal/annotations/annotations_test.go @@ -4,10 +4,11 @@ import ( "testing" ingressv1alpha1 "github.com/ngrok/kubernetes-ingress-controller/api/v1alpha1" + "github.com/ngrok/kubernetes-ingress-controller/internal/annotations/parser" + "github.com/ngrok/kubernetes-ingress-controller/internal/annotations/testutil" "github.com/stretchr/testify/assert" networking "k8s.io/api/networking/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - "k8s.io/utils/pointer" ) func newIngressWithAnnotations(annotations map[string]string) *networking.Ingress { @@ -18,29 +19,16 @@ func newIngressWithAnnotations(annotations map[string]string) *networking.Ingres } } -func TestCompression(t *testing.T) { +func TestParsesIPPolicies(t *testing.T) { e := NewAnnotationsExtractor() - modules := e.Extract(newIngressWithAnnotations(map[string]string{ - "k8s.ngrok.com/https-compression": "false", - })) - assert.False(t, *modules.Compression.Enabled) + ing := testutil.NewIngress() + ing.SetAnnotations(map[string]string{ + parser.GetAnnotationWithPrefix("ip-policy-ids"): "abc123,def456", + }) - modules = e.Extract(newIngressWithAnnotations(map[string]string{ - "k8s.ngrok.com/https-compression": "true", - })) - assert.True(t, *modules.Compression.Enabled) + modules := e.Extract(ing) - modules = e.Extract(newIngressWithAnnotations(map[string]string{})) - assert.Nil(t, modules.Compression) -} - -func TestIPPolicies(t *testing.T) { - e := NewAnnotationsExtractor() - modules := e.Extract(newIngressWithAnnotations(map[string]string{ - "k8s.ngrok.com/ip-policy-ids": "abc123,def456", - })) assert.Equal(t, &ingressv1alpha1.EndpointIPPolicy{ - Enabled: pointer.Bool(true), IPPolicyIDs: []string{ "abc123", "def456", diff --git a/internal/annotations/compression/compression.go b/internal/annotations/compression/compression.go index aac4d882..40c57669 100644 --- a/internal/annotations/compression/compression.go +++ b/internal/annotations/compression/compression.go @@ -4,7 +4,6 @@ import ( ingressv1alpha1 "github.com/ngrok/kubernetes-ingress-controller/api/v1alpha1" "github.com/ngrok/kubernetes-ingress-controller/internal/annotations/parser" networking "k8s.io/api/networking/v1" - "k8s.io/utils/pointer" ) type compression struct{} @@ -13,6 +12,9 @@ func NewParser() parser.IngressAnnotation { return compression{} } +// Parse parses the annotations contained in the ingress and returns a +// compression configuration or an error. If no compression annotations are +// found, the returned error an errors.ErrMissingAnnotations. func (c compression) Parse(ing *networking.Ingress) (interface{}, error) { v, err := parser.GetBoolAnnotation("https-compression", ing) if err != nil { @@ -20,6 +22,6 @@ func (c compression) Parse(ing *networking.Ingress) (interface{}, error) { } return &ingressv1alpha1.EndpointCompression{ - Enabled: pointer.Bool(v), + Enabled: v, }, nil } diff --git a/internal/annotations/compression/compression_test.go b/internal/annotations/compression/compression_test.go new file mode 100644 index 00000000..257e69a8 --- /dev/null +++ b/internal/annotations/compression/compression_test.go @@ -0,0 +1,53 @@ +package compression + +import ( + "testing" + + ingressv1alpha1 "github.com/ngrok/kubernetes-ingress-controller/api/v1alpha1" + "github.com/ngrok/kubernetes-ingress-controller/internal/annotations/parser" + "github.com/ngrok/kubernetes-ingress-controller/internal/annotations/testutil" + "github.com/ngrok/kubernetes-ingress-controller/internal/errors" + "github.com/stretchr/testify/assert" +) + +func TestCompressionWhenNotSupplied(t *testing.T) { + ing := testutil.NewIngress() + ing.SetAnnotations(map[string]string{}) + parsed, err := NewParser().Parse(ing) + + assert.Nil(t, parsed) + assert.Error(t, err) + assert.True(t, errors.IsMissingAnnotations(err)) +} + +func TestCompressionWhenSuppliedAndTrue(t *testing.T) { + ing := testutil.NewIngress() + annotations := map[string]string{} + annotations[parser.GetAnnotationWithPrefix("https-compression")] = "true" + ing.SetAnnotations(annotations) + + parsed, err := NewParser().Parse(ing) + assert.NoError(t, err) + + compression, ok := parsed.(*ingressv1alpha1.EndpointCompression) + if !ok { + t.Fatalf("expected *ingressv1alpha1.EndpointCompression, got %T", parsed) + } + assert.Equal(t, true, compression.Enabled) +} + +func TestCompressionWhenSuppliedAndFalse(t *testing.T) { + ing := testutil.NewIngress() + annotations := map[string]string{} + annotations[parser.GetAnnotationWithPrefix("https-compression")] = "false" + ing.SetAnnotations(annotations) + + parsed, err := NewParser().Parse(ing) + assert.NoError(t, err) + + compression, ok := parsed.(*ingressv1alpha1.EndpointCompression) + if !ok { + t.Fatalf("expected *ingressv1alpha1.EndpointCompression, got %T", parsed) + } + assert.Equal(t, false, compression.Enabled) +} diff --git a/internal/annotations/headers/headers.go b/internal/annotations/headers/headers.go new file mode 100644 index 00000000..0f30f86f --- /dev/null +++ b/internal/annotations/headers/headers.go @@ -0,0 +1,91 @@ +package headers + +import ( + ingressv1alpha1 "github.com/ngrok/kubernetes-ingress-controller/api/v1alpha1" + "github.com/ngrok/kubernetes-ingress-controller/internal/annotations/parser" + "github.com/ngrok/kubernetes-ingress-controller/internal/errors" + networking "k8s.io/api/networking/v1" +) + +type EndpointHeaders = ingressv1alpha1.EndpointHeaders +type EndpointRequestHeaders = ingressv1alpha1.EndpointRequestHeaders +type EndpointResponseHeaders = ingressv1alpha1.EndpointResponseHeaders + +type headers struct{} + +func NewParser() parser.IngressAnnotation { + return headers{} +} + +func (h headers) Parse(ing *networking.Ingress) (interface{}, error) { + parsed := &EndpointHeaders{} + + v, err := parser.GetStringSliceAnnotation("request-headers-remove", ing) + if err != nil { + if !errors.IsMissingAnnotations(err) { + return parsed, err + } + } + + if len(v) > 0 { + if parsed.Request == nil { + parsed.Request = &EndpointRequestHeaders{} + } + parsed.Request.Remove = v + } + + m, err := parser.GetStringMapAnnotation("request-headers-add", ing) + if err != nil { + if !errors.IsMissingAnnotations(err) { + return parsed, err + } + } + + if len(m) > 0 { + if parsed.Request == nil { + parsed.Request = &EndpointRequestHeaders{} + } + parsed.Request.Add = m + } + + if err != nil { + if !errors.IsMissingAnnotations(err) { + return parsed, err + } + } + + v, err = parser.GetStringSliceAnnotation("response-headers-remove", ing) + if err != nil { + if !errors.IsMissingAnnotations(err) { + return parsed, err + } + } + + if len(v) > 0 { + if parsed.Response == nil { + parsed.Response = &EndpointResponseHeaders{} + } + parsed.Response.Remove = v + } + + m, err = parser.GetStringMapAnnotation("response-headers-add", ing) + if err != nil { + if !errors.IsMissingAnnotations(err) { + return parsed, err + } + } + + if len(m) > 0 { + if parsed.Response == nil { + parsed.Response = &EndpointResponseHeaders{} + } + parsed.Response.Add = m + } + + // If none of the annotations are present, return the missing annotations error + if parsed.Request == nil && parsed.Response == nil { + return nil, errors.ErrMissingAnnotations + } + + return parsed, nil +} diff --git a/internal/annotations/headers/headers_test.go b/internal/annotations/headers/headers_test.go new file mode 100644 index 00000000..55a22ee6 --- /dev/null +++ b/internal/annotations/headers/headers_test.go @@ -0,0 +1,85 @@ +package headers + +import ( + "testing" + + "github.com/ngrok/kubernetes-ingress-controller/internal/annotations/parser" + "github.com/ngrok/kubernetes-ingress-controller/internal/annotations/testutil" + "github.com/ngrok/kubernetes-ingress-controller/internal/errors" + "github.com/stretchr/testify/assert" +) + +func TestHeadersWhenNotSupplied(t *testing.T) { + ing := testutil.NewIngress() + ing.SetAnnotations(map[string]string{}) + parsed, err := NewParser().Parse(ing) + + assert.Nil(t, parsed) + assert.Error(t, err) + assert.True(t, errors.IsMissingAnnotations(err)) +} + +func TestHeadersWhenRequestHeadersSupplied(t *testing.T) { + ing := testutil.NewIngress() + annotations := map[string]string{} + annotations[parser.GetAnnotationWithPrefix("request-headers-remove")] = "Server" + annotations[parser.GetAnnotationWithPrefix("request-headers-add")] = `{"X-Request-Header": "value"}` + ing.SetAnnotations(annotations) + + parsed, err := NewParser().Parse(ing) + assert.NoError(t, err) + assert.NotNil(t, parsed) + + endpointHeaders, ok := parsed.(*EndpointHeaders) + if !ok { + t.Fatalf("expected *EndpointHeaders, got %T", parsed) + } + + assert.Nil(t, endpointHeaders.Response) + assert.Equal(t, []string{"Server"}, endpointHeaders.Request.Remove) + assert.Equal(t, map[string]string{"X-Request-Header": "value"}, endpointHeaders.Request.Add) +} + +func TestHeadersWhenResponseHeadersSupplied(t *testing.T) { + ing := testutil.NewIngress() + annotations := map[string]string{} + annotations[parser.GetAnnotationWithPrefix("response-headers-remove")] = "Server" + annotations[parser.GetAnnotationWithPrefix("response-headers-add")] = `{"X-Response-Header": "value"}` + ing.SetAnnotations(annotations) + + parsed, err := NewParser().Parse(ing) + assert.NoError(t, err) + assert.NotNil(t, parsed) + + endpointHeaders, ok := parsed.(*EndpointHeaders) + if !ok { + t.Fatalf("expected *EndpointHeaders, got %T", parsed) + } + assert.Nil(t, endpointHeaders.Request) + assert.Equal(t, []string{"Server"}, endpointHeaders.Response.Remove) + assert.Equal(t, map[string]string{"X-Response-Header": "value"}, endpointHeaders.Response.Add) +} + +func TestInvalidRequestHeadersAdd(t *testing.T) { + ing := testutil.NewIngress() + annotations := map[string]string{} + // Not valid JSON + annotations[parser.GetAnnotationWithPrefix("request-headers-add")] = `{X-Request-Header: value}` + ing.SetAnnotations(annotations) + + _, err := NewParser().Parse(ing) + assert.Error(t, err) + assert.True(t, errors.IsInvalidContent(err)) +} + +func TestInvalidResponseHeadersAdd(t *testing.T) { + ing := testutil.NewIngress() + annotations := map[string]string{} + // Not valid JSON + annotations[parser.GetAnnotationWithPrefix("response-headers-add")] = `{X-Response-Header: value}` + ing.SetAnnotations(annotations) + + _, err := NewParser().Parse(ing) + assert.Error(t, err) + assert.True(t, errors.IsInvalidContent(err)) +} diff --git a/internal/annotations/ip_policies/ip_policy.go b/internal/annotations/ip_policies/ip_policy.go index 1981b253..110704f3 100644 --- a/internal/annotations/ip_policies/ip_policy.go +++ b/internal/annotations/ip_policies/ip_policy.go @@ -4,7 +4,6 @@ import ( ingressv1alpha1 "github.com/ngrok/kubernetes-ingress-controller/api/v1alpha1" "github.com/ngrok/kubernetes-ingress-controller/internal/annotations/parser" networking "k8s.io/api/networking/v1" - "k8s.io/utils/pointer" ) type ipPolicy struct{} @@ -20,7 +19,6 @@ func (p ipPolicy) Parse(ing *networking.Ingress) (interface{}, error) { } return &ingressv1alpha1.EndpointIPPolicy{ - Enabled: pointer.Bool(true), IPPolicyIDs: v, }, nil } diff --git a/internal/annotations/parser/parser.go b/internal/annotations/parser/parser.go index 375decd5..5802c0a6 100644 --- a/internal/annotations/parser/parser.go +++ b/internal/annotations/parser/parser.go @@ -17,6 +17,7 @@ limitations under the License. package parser import ( + "encoding/json" "fmt" "net/url" "strconv" @@ -86,6 +87,21 @@ func (a ingAnnotations) parseStringSlice(name string) ([]string, error) { return []string{}, errors.ErrMissingAnnotations } +func (a ingAnnotations) parseStringMap(name string) (map[string]string, error) { + val, ok := a[name] + if !ok { + return nil, errors.ErrMissingAnnotations + } + + m := map[string]string{} + err := json.Unmarshal([]byte(val), &m) + if err != nil { + return nil, errors.NewInvalidAnnotationContent(name, val) + } + + return m, nil +} + func (a ingAnnotations) parseInt(name string) (int, error) { val, ok := a[name] if ok { @@ -152,6 +168,16 @@ func GetStringSliceAnnotation(name string, ing *networking.Ingress) ([]string, e return ingAnnotations(ing.GetAnnotations()).parseStringSlice(v) } +func GetStringMapAnnotation(name string, ing *networking.Ingress) (map[string]string, error) { + v := GetAnnotationWithPrefix(name) + err := checkAnnotation(v, ing) + if err != nil { + return nil, err + } + + return ingAnnotations(ing.GetAnnotations()).parseStringMap(v) +} + // GetIntAnnotation extracts an int from an Ingress annotation func GetIntAnnotation(name string, ing *networking.Ingress) (int, error) { v := GetAnnotationWithPrefix(name) diff --git a/internal/annotations/testutil/ingress.go b/internal/annotations/testutil/ingress.go new file mode 100644 index 00000000..64174469 --- /dev/null +++ b/internal/annotations/testutil/ingress.go @@ -0,0 +1,41 @@ +// Test Utilities for Ingress Annotations +package testutil + +import ( + v1 "k8s.io/api/core/v1" + networking "k8s.io/api/networking/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +func NewIngress() *networking.Ingress { + return &networking.Ingress{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-ingress", + Namespace: v1.NamespaceDefault, + }, + Spec: networking.IngressSpec{ + Rules: []networking.IngressRule{ + { + Host: "test.ngrok.io", + IngressRuleValue: networking.IngressRuleValue{ + HTTP: &networking.HTTPIngressRuleValue{ + Paths: []networking.HTTPIngressPath{ + { + Path: "/foo", + Backend: networking.IngressBackend{ + Service: &networking.IngressServiceBackend{ + Name: "foo", + Port: networking.ServiceBackendPort{ + Number: 80, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + } +} diff --git a/internal/controllers/httpsedge_controller.go b/internal/controllers/httpsedge_controller.go index b078af0c..636744c3 100644 --- a/internal/controllers/httpsedge_controller.go +++ b/internal/controllers/httpsedge_controller.go @@ -32,6 +32,7 @@ import ( v1 "k8s.io/api/core/v1" "k8s.io/apimachinery/pkg/runtime" "k8s.io/client-go/tools/record" + "k8s.io/utils/pointer" ctrl "sigs.k8s.io/controller-runtime" "sigs.k8s.io/controller-runtime/pkg/client" @@ -161,6 +162,7 @@ func (r *HTTPSEdgeReconciler) reconcileRoutes(ctx context.Context, edge *ingress return err } + // TODO: clean this up. This is way too much nesting for i, routeSpec := range edge.Spec.Routes { backend, err := tunnelGroupReconciler.findOrCreate(ctx, routeSpec.Backend) if err != nil { @@ -180,17 +182,6 @@ func (r *HTTPSEdgeReconciler) reconcileRoutes(ctx context.Context, edge *ingress BackendID: backend.ID, }, } - if routeSpec.Compression != nil { - req.Compression = &ngrok.EndpointCompression{ - Enabled: routeSpec.Compression.Enabled, - } - } - if routeSpec.IPRestriction != nil { - req.IPRestriction = &ngrok.EndpointIPPolicyMutate{ - Enabled: routeSpec.IPRestriction.Enabled, - IPPolicyIDs: routeSpec.IPRestriction.IPPolicyIDs, - } - } route, err = r.NgrokClientset.HTTPSEdgeRoutes().Create(ctx, req) } else { r.Log.Info("Updating route", "edgeID", edge.Status.ID, "match", routeSpec.Match, "matchType", routeSpec.MatchType, "backendID", backend.ID) @@ -204,17 +195,6 @@ func (r *HTTPSEdgeReconciler) reconcileRoutes(ctx context.Context, edge *ingress BackendID: backend.ID, }, } - if routeSpec.Compression != nil { - req.Compression = &ngrok.EndpointCompression{ - Enabled: routeSpec.Compression.Enabled, - } - } - if routeSpec.IPRestriction != nil { - req.IPRestriction = &ngrok.EndpointIPPolicyMutate{ - Enabled: routeSpec.IPRestriction.Enabled, - IPPolicyIDs: routeSpec.IPRestriction.IPPolicyIDs, - } - } route, err = r.NgrokClientset.HTTPSEdgeRoutes().Update(ctx, req) } if err != nil { @@ -231,6 +211,27 @@ func (r *HTTPSEdgeReconciler) reconcileRoutes(ctx context.Context, edge *ingress ID: route.Backend.Backend.ID, } } + + if err := r.setEdgeRouteCompression(ctx, edge.Status.ID, route.ID, routeSpec.Compression); err != nil { + return err + } + if err := r.setEdgeRouteIPRestriction(ctx, edge.Status.ID, route.ID, routeSpec.IPRestriction); err != nil { + return err + } + var requestHeaders *ingressv1alpha1.EndpointRequestHeaders + if routeSpec.Headers != nil { + requestHeaders = routeSpec.Headers.Request + } + if err := r.setEdgeRouteRequestHeaders(ctx, edge.Status.ID, route.ID, requestHeaders); err != nil { + return err + } + var responseHeaders *ingressv1alpha1.EndpointResponseHeaders + if routeSpec.Headers != nil { + responseHeaders = routeSpec.Headers.Response + } + if err := r.setEdgeRouteResponseHeaders(ctx, edge.Status.ID, route.ID, responseHeaders); err != nil { + return err + } } edge.Status.Routes = routeStatuses @@ -238,6 +239,82 @@ func (r *HTTPSEdgeReconciler) reconcileRoutes(ctx context.Context, edge *ingress return r.Status().Update(ctx, edge) } +func (r *HTTPSEdgeReconciler) setEdgeRouteCompression(ctx context.Context, edgeID string, routeID string, compression *ingressv1alpha1.EndpointCompression) error { + client := r.NgrokClientset.EdgeModules().HTTPS().Routes().Compression() + + if compression == nil { + return client.Delete(ctx, &ngrok.EdgeRouteItem{EdgeID: edgeID, ID: routeID}) + } + + _, err := client.Replace(ctx, &ngrok.EdgeRouteCompressionReplace{ + EdgeID: edgeID, + ID: routeID, + Module: ngrok.EndpointCompression{ + Enabled: pointer.Bool(compression.Enabled), + }, + }) + return err +} + +func (r *HTTPSEdgeReconciler) setEdgeRouteIPRestriction(ctx context.Context, edgeID string, routeID string, ipRestriction *ingressv1alpha1.EndpointIPPolicy) error { + client := r.NgrokClientset.EdgeModules().HTTPS().Routes().IPRestriction() + if ipRestriction == nil || len(ipRestriction.IPPolicyIDs) == 0 { + return client.Delete(ctx, &ngrok.EdgeRouteItem{EdgeID: edgeID, ID: routeID}) + } + _, err := client.Replace(ctx, &ngrok.EdgeRouteIPRestrictionReplace{ + EdgeID: edgeID, + ID: routeID, + Module: ngrok.EndpointIPPolicyMutate{ + IPPolicyIDs: ipRestriction.IPPolicyIDs, + }, + }) + return err +} + +func (r *HTTPSEdgeReconciler) setEdgeRouteRequestHeaders(ctx context.Context, edgeID string, routeID string, requestHeaders *ingressv1alpha1.EndpointRequestHeaders) error { + client := r.NgrokClientset.EdgeModules().HTTPS().Routes().RequestHeaders() + if requestHeaders == nil { + return client.Delete(ctx, &ngrok.EdgeRouteItem{EdgeID: edgeID, ID: routeID}) + } + + module := ngrok.EndpointRequestHeaders{} + if len(requestHeaders.Add) > 0 { + module.Add = requestHeaders.Add + } + if len(requestHeaders.Remove) > 0 { + module.Remove = requestHeaders.Remove + } + + _, err := client.Replace(ctx, &ngrok.EdgeRouteRequestHeadersReplace{ + EdgeID: edgeID, + ID: routeID, + Module: module, + }) + return err +} + +func (r *HTTPSEdgeReconciler) setEdgeRouteResponseHeaders(ctx context.Context, edgeID string, routeID string, responseHeaders *ingressv1alpha1.EndpointResponseHeaders) error { + client := r.NgrokClientset.EdgeModules().HTTPS().Routes().ResponseHeaders() + if responseHeaders == nil { + return client.Delete(ctx, &ngrok.EdgeRouteItem{EdgeID: edgeID, ID: routeID}) + } + + module := ngrok.EndpointResponseHeaders{} + if len(responseHeaders.Add) > 0 { + module.Add = responseHeaders.Add + } + if len(responseHeaders.Remove) > 0 { + module.Remove = responseHeaders.Remove + } + + _, err := client.Replace(ctx, &ngrok.EdgeRouteResponseHeadersReplace{ + EdgeID: edgeID, + ID: routeID, + Module: module, + }) + return err +} + func (r *HTTPSEdgeReconciler) findEdgeByHostports(ctx context.Context, hostports []string) (*ngrok.HTTPSEdge, error) { iter := r.NgrokClientset.HTTPSEdges().List(&ngrok.Paging{}) for iter.Next(ctx) { diff --git a/internal/controllers/ingress_controller.go b/internal/controllers/ingress_controller.go index 060a09bb..a61a99ef 100644 --- a/internal/controllers/ingress_controller.go +++ b/internal/controllers/ingress_controller.go @@ -174,6 +174,7 @@ func (irec *IngressReconciler) routesPlanner(ctx context.Context, ingress *netv1 }, Compression: parsedRouteModules.Compression, IPRestriction: parsedRouteModules.IPRestriction, + Headers: parsedRouteModules.Headers, } ngrokRoutes = append(ngrokRoutes, route) diff --git a/internal/controllers/ingress_controller_test.go b/internal/controllers/ingress_controller_test.go index 7cb7db83..5abbc92c 100644 --- a/internal/controllers/ingress_controller_test.go +++ b/internal/controllers/ingress_controller_test.go @@ -10,7 +10,6 @@ import ( "github.com/stretchr/testify/assert" netv1 "k8s.io/api/networking/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - "k8s.io/utils/pointer" ) func makeTestBackend(serviceName string, servicePort int32) netv1.IngressBackend { @@ -72,6 +71,9 @@ func TestIngressReconcilerIngressToEdge(t *testing.T) { ObjectMeta: metav1.ObjectMeta{ Name: "test-ingress", Namespace: "test-namespace", + Annotations: map[string]string{ + "k8s.ngrok.com/https-compression": "false", + }, }, Spec: netv1.IngressSpec{ Rules: []netv1.IngressRule{ @@ -110,6 +112,9 @@ func TestIngressReconcilerIngressToEdge(t *testing.T) { "k8s.ngrok.com/port": "8080", }, }, + Compression: &ingressv1alpha1.EndpointCompression{ + Enabled: false, + }, }, }, }, @@ -164,10 +169,9 @@ func TestIngressReconcilerIngressToEdge(t *testing.T) { }, }, Compression: &ingressv1alpha1.EndpointCompression{ - Enabled: pointer.Bool(true), + Enabled: true, }, IPRestriction: &ingressv1alpha1.EndpointIPPolicy{ - Enabled: pointer.Bool(true), IPPolicyIDs: []string{"policy-1", "policy-2"}, }, }, diff --git a/internal/controllers/tcpedge_controller.go b/internal/controllers/tcpedge_controller.go index 0f5db483..3339fd09 100644 --- a/internal/controllers/tcpedge_controller.go +++ b/internal/controllers/tcpedge_controller.go @@ -167,14 +167,6 @@ func (r *TCPEdgeReconciler) reconcileTunnelGroupBackend(ctx context.Context, edg } func (r *TCPEdgeReconciler) reconcileEdge(ctx context.Context, edge *ingressv1alpha1.TCPEdge) error { - var ipRestriction *ngrok.EndpointIPPolicyMutate - if edge.Spec.IPRestriction != nil { - ipRestriction = &ngrok.EndpointIPPolicyMutate{ - Enabled: edge.Spec.IPRestriction.Enabled, - IPPolicyIDs: edge.Spec.IPRestriction.IPPolicyIDs, - } - } - if edge.Status.ID != "" { // An edge already exists, make sure everything matches resp, err := r.NgrokClientset.TCPEdges().Get(ctx, edge.Status.ID) @@ -191,8 +183,7 @@ func (r *TCPEdgeReconciler) reconcileEdge(ctx context.Context, edge *ingressv1al // If the backend or hostports do not match, update the edge with the desired backend and hostports if resp.Backend.Backend.ID != edge.Status.Backend.ID || - !reflect.DeepEqual(resp.Hostports, edge.Status.Hostports) || - !reflect.DeepEqual(resp.IpRestriction, ipRestriction) { + !reflect.DeepEqual(resp.Hostports, edge.Status.Hostports) { resp, err = r.NgrokClientset.TCPEdges().Update(ctx, &ngrok.TCPEdgeUpdate{ ID: resp.ID, Description: pointer.String(edge.Spec.Description), @@ -201,7 +192,6 @@ func (r *TCPEdgeReconciler) reconcileEdge(ctx context.Context, edge *ingressv1al Backend: &ngrok.EndpointBackendMutate{ BackendID: edge.Status.Backend.ID, }, - IPRestriction: ipRestriction, }) if err != nil { return err @@ -229,14 +219,18 @@ func (r *TCPEdgeReconciler) reconcileEdge(ctx context.Context, edge *ingressv1al Backend: &ngrok.EndpointBackendMutate{ BackendID: edge.Status.Backend.ID, }, - IPRestriction: ipRestriction, }) if err != nil { return err } r.Log.Info("Created new TCPEdge", "edge.ID", resp.ID, "name", edge.Name, "namespace", edge.Namespace) - return r.updateEdgeStatus(ctx, edge, resp) + if err := r.updateEdgeStatus(ctx, edge, resp); err != nil { + return err + } + + return r.updateIPRestrictionRouteModule(ctx, edge, resp) + } func (r *TCPEdgeReconciler) findEdgeByBackendLabels(ctx context.Context, backendLabels map[string]string) (*ngrok.TCPEdge, error) { @@ -321,3 +315,17 @@ func (r *TCPEdgeReconciler) metadataForEdge(edge *ingressv1alpha1.TCPEdge) strin func (r *TCPEdgeReconciler) descriptionForEdge(edge *ingressv1alpha1.TCPEdge) string { return fmt.Sprintf("Reserved for %s/%s", edge.Namespace, edge.Name) } + +func (r *TCPEdgeReconciler) updateIPRestrictionRouteModule(ctx context.Context, edge *ingressv1alpha1.TCPEdge, remoteEdge *ngrok.TCPEdge) error { + if edge.Spec.IPRestriction == nil { + return r.NgrokClientset.EdgeModules().TCP().IPRestriction().Delete(ctx, edge.Status.ID) + } else { + _, err := r.NgrokClientset.EdgeModules().TCP().IPRestriction().Replace(ctx, &ngrok.EdgeIPRestrictionReplace{ + ID: edge.Status.ID, + Module: ngrok.EndpointIPPolicyMutate{ + IPPolicyIDs: edge.Spec.IPRestriction.IPPolicyIDs, + }, + }) + return err + } +}