diff --git a/README.md b/README.md index 9eebfc6..3c93e2c 100644 --- a/README.md +++ b/README.md @@ -39,6 +39,7 @@ The following annotation controls how origins and behaviors are attached to Clou mykey1: myvalue1 mykey2: myvalue2 ``` +- `cdn-origin-controller.gympass.com/cf.origin-headers`: HTTP headers to be added to each request made for an origin. Refer to the [dedicated section](#custom-headers) for more details. The controller needs permission to manipulate the CloudFront distributions. A [sample IAM Policy](docs/iam_policy.json) is provided with the necessary IAM actions. @@ -103,6 +104,32 @@ TLS will automatically be enabled if the `CF_SECURITY_POLICY` env var is set, an The controller will automatically search for TLS certificates in [AWS ACM](https://aws.amazon.com/certificate-manager/). If it finds a certificate matching any of the Distribution's alternate domain names, it will bind that certificate to the Distribution. +## Custom Headers + +CloudFront allows you to specify headers that should be added to each request for a given origin. For example: + +```yaml +kind: Ingress +metadata: + annotations: + cdn-origin-controller.gympass.com/cf.origin-headers: "static=value,dynamic={{origin.host}}" +``` + +This configures two HTTP headers: + +- "static": which is mapped to the value "value" +- "dynamic": which uses a template to calculate the value during runtime. This is useful for fields which are not known beforehand, such as the origin's host + +Currently supported template values: + +| field | description | +| ----------- | ----------------------- | +| origin.host | The host of this origin | + +### Custom Headers on user-supplied origins/behaviors + +You can also use this feature with user-supplied origins/behaviors. Refer to the [dedicated section](#user-supplied-originbehavior-configuration). + ## Behavior ordering During reconciliation, the controller will assemble desired behaviors based on all @@ -201,9 +228,14 @@ metadata: behaviors: - path: /bar - path: /bar/* + headers: + static: value + dynamic: '{{origin.host}}' ``` +> **IMPORTANT**: when using the `headers` field, make sure you add quotes when using templates, to preven YAML parsing errors. (`'{{origin.host}}'`, not `{{origin.host}}`). Check the [dedicated section](#custom-headers) for all available template values. + The `.host` is the hostname of the origin you're configuring. The `.behaviors` field is a list of objects representing the cache behaviors that should be configured. It contains a required string `path`, and an optional `functionAssociation` that is defined as shown [here](#function-associations). @@ -224,6 +256,7 @@ The table below maps remaining available fields of an entry in this list to an a | .viewerFunctionARN | cdn-origin-controller.gympass.com/cf.viewer-function-arn | deprecated, prefer defining associtions in .behaviors[].functionAssociations | | .cachePolicy | cdn-origin-controller.gympass.com/cf.cache-policy | - | | .webACLARN | cdn-origin-controller.gympass.com/cf.web-acl-arn | - | +| .headers | cdn-origin-controller.gympass.com/cf.origin-headers | - | ### Bucket origin access diff --git a/internal/cloudfront/aws_models.go b/internal/cloudfront/aws_models.go index 5bc3126..d7cb23a 100644 --- a/internal/cloudfront/aws_models.go +++ b/internal/cloudfront/aws_models.go @@ -150,7 +150,7 @@ func newAWSOrigin(o Origin) *cloudfront.Origin { } return &cloudfront.Origin{ - CustomHeaders: &cloudfront.CustomHeaders{Quantity: aws.Int64(0)}, + CustomHeaders: newCustomHeaders(o), CustomOriginConfig: customOriginConfig, DomainName: aws.String(o.Host), Id: aws.String(o.Host), @@ -160,6 +160,21 @@ func newAWSOrigin(o Origin) *cloudfront.Origin { } } +func newCustomHeaders(o Origin) *cloudfront.CustomHeaders { + var items []*cloudfront.OriginCustomHeader + for k, v := range o.Headers() { + items = append(items, &cloudfront.OriginCustomHeader{ + HeaderName: aws.String(k), + HeaderValue: aws.String(v), + }) + } + + return &cloudfront.CustomHeaders{ + Items: items, + Quantity: aws.Int64(int64(len(items))), + } +} + func newCacheBehavior(b Behavior) *cloudfront.CacheBehavior { cb := baseCacheBehavior(b) var cfFunctions []Function diff --git a/internal/cloudfront/origin.go b/internal/cloudfront/origin.go index 12f6013..f1f58e6 100644 --- a/internal/cloudfront/origin.go +++ b/internal/cloudfront/origin.go @@ -20,6 +20,8 @@ package cloudfront import ( + "strings" + "github.com/Gympass/cdn-origin-controller/internal/config" "github.com/Gympass/cdn-origin-controller/internal/k8s" ) @@ -30,9 +32,37 @@ const ( ) const ( - defaultResponseTimeout = 30 + defaultResponseTimeout = 30 + templateOriginHeadersHost = "{{origin.host}}" ) +// originHeaders represents pairs of HTTP header key/values that should be added to requests to the origin +type originHeaders struct { + originHost string + headers map[string]string +} + +func newOriginHeaders(originHost string, headers map[string]string) originHeaders { + return originHeaders{ + originHost: originHost, + headers: headers, + } +} + +func (h originHeaders) get() map[string]string { + if h.headers == nil { + return nil + } + + result := make(map[string]string, len(h.headers)) + for k, v := range h.headers { + v = strings.ReplaceAll(v, templateOriginHeadersHost, h.originHost) + result[k] = v + } + + return result +} + // Origin represents a CloudFront Origin and aggregates Behaviors associated with it type Origin struct { // Host is the origin's hostname @@ -45,6 +75,8 @@ type Origin struct { Access string // OAC configures Access Origin Control for this Origin OAC OAC + + headers originHeaders } // HasEqualParameters returns whether both Origins have the same parameters. It ignores differences in Behaviors @@ -52,6 +84,13 @@ func (o Origin) HasEqualParameters(o2 Origin) bool { return o.Host == o2.Host && o.ResponseTimeout == o2.ResponseTimeout && o.Access == o2.Access && o.OAC == o2.OAC } +// Headers returns the headers that are bound to this Origin. +// The stored value may be changed in order to use values that are known +// only at runtime, such as the Origin's host. +func (o Origin) Headers() map[string]string { + return o.headers.get() +} + func (o Origin) isBucketBased() bool { return o.Access == OriginAccessBucket } @@ -73,6 +112,7 @@ type Behavior struct { // OriginBuilder allows the construction of an Origin type OriginBuilder struct { host string + headers map[string]string requestPolicy string distributionName string cachePolicy string @@ -138,6 +178,12 @@ func (b OriginBuilder) WithResponseTimeout(rpTimeout int64) OriginBuilder { return b } +// WithOriginHeaders associates a map of HTTP headers that CloudFront should add on every request to the Origin +func (b OriginBuilder) WithOriginHeaders(headers map[string]string) OriginBuilder { + b.headers = headers + return b +} + // Build creates an Origin based on configuration made so far func (b OriginBuilder) Build() Origin { origin := Origin{ @@ -145,6 +191,8 @@ func (b OriginBuilder) Build() Origin { ResponseTimeout: b.respTimeout, } + origin.headers = newOriginHeaders(b.host, b.headers) + origin = b.addBehaviors(origin) origin = b.addCachePolicyBehaviors(origin) diff --git a/internal/cloudfront/origin_test.go b/internal/cloudfront/origin_test.go index 4c55853..d300f81 100644 --- a/internal/cloudfront/origin_test.go +++ b/internal/cloudfront/origin_test.go @@ -152,3 +152,26 @@ func (s *OriginTestSuite) TestNewOriginBuilder_TestHasDifferentParameters() { s.False(o.HasEqualParameters(o3)) s.True(o.HasEqualParameters(o)) } + +func (s *OriginTestSuite) TestNewOriginBuilder_OriginHeadersAreNotPassedAndShouldBeNil() { + o := NewOriginBuilder("dist", "origin.com", "Public", s.cfg). + Build() + + s.Nil(o.Headers()) +} + +func (s *OriginTestSuite) TestNewOriginBuilder_StaticOriginHeadersArePassedAndShouldBePresentAtResult() { + o := NewOriginBuilder("dist", "origin.com", "Public", s.cfg). + WithOriginHeaders(map[string]string{"key": "val"}). + Build() + + s.Equal(map[string]string{"key": "val"}, o.Headers()) +} + +func (s *OriginTestSuite) TestNewOriginBuilder_DynamicOriginHeadersArePassedAndShouldBeTemplatedAtResult() { + o := NewOriginBuilder("dist", "origin.com", "Public", s.cfg). + WithOriginHeaders(map[string]string{"key": "{{origin.host}}"}). + Build() + + s.Equal(map[string]string{"key": "origin.com"}, o.Headers()) +} diff --git a/internal/cloudfront/service_helpers.go b/internal/cloudfront/service_helpers.go index cafecf5..a3a9034 100644 --- a/internal/cloudfront/service_helpers.go +++ b/internal/cloudfront/service_helpers.go @@ -38,7 +38,8 @@ func newOrigin(ing k8s.CDNIngress, cfg config.Config, shared k8s.SharedIngressPa builder := NewOriginBuilder(ing.Group, ing.OriginHost, ing.OriginAccess, cfg). WithResponseTimeout(ing.OriginRespTimeout). WithRequestPolicy(ing.OriginReqPolicy). - WithCachePolicy(ing.CachePolicy) + WithCachePolicy(ing.CachePolicy). + WithOriginHeaders(ing.OriginHeaders) for _, p := range shared.PathsFromOrigin(ing.OriginHost) { for _, pp := range pathPatternsForPath(p) { diff --git a/internal/k8s/ingress.go b/internal/k8s/ingress.go index 3cd5116..7bcfc7a 100644 --- a/internal/k8s/ingress.go +++ b/internal/k8s/ingress.go @@ -51,6 +51,7 @@ const ( cfAlternateDomainNamesAnnotation = "cdn-origin-controller.gympass.com/cf.alternate-domain-names" cfWebACLARNAnnotation = "cdn-origin-controller.gympass.com/cf.web-acl-arn" cfTagsAnnotation = "cdn-origin-controller.gympass.com/cf.tags" + cfOrigHeadersAnnotation = "cdn-origin-controller.gympass.com/cf.origin-headers" ) // Path represents a path item within an Ingress @@ -67,6 +68,7 @@ type CDNIngress struct { Group string UnmergedPaths []Path OriginReqPolicy string + OriginHeaders map[string]string CachePolicy string OriginRespTimeout int64 AlternateDomainNames []string @@ -209,6 +211,11 @@ func NewCDNIngressFromV1(ctx context.Context, ing *networkingv1.Ingress, class C return CDNIngress{}, err } + headers, err := headersV1(ing) + if err != nil { + return CDNIngress{}, err + } + result := CDNIngress{ NamespacedName: types.NamespacedName{ Namespace: ing.Namespace, @@ -217,6 +224,7 @@ func NewCDNIngressFromV1(ctx context.Context, ing *networkingv1.Ingress, class C Group: groupAnnotationValue(ing), UnmergedPaths: paths, OriginReqPolicy: originReqPolicy(ing), + OriginHeaders: headers, CachePolicy: cachePolicy(ing), OriginRespTimeout: originRespTimeout(ing), AlternateDomainNames: alternateDomainNames(ing), @@ -299,6 +307,40 @@ func pathsForFunctionAssociations(ctx context.Context, ing *networkingv1.Ingress return paths } +func headersV1(ing *networkingv1.Ingress) (map[string]string, error) { + val, ok := ing.GetAnnotations()[cfOrigHeadersAnnotation] + if !ok { + return nil, nil + } + + headers, err := parseOriginHeaders(val) + if err != nil { + return nil, fmt.Errorf("parsing origin headers from annotation %q: %v", cfViewerFnAnnotation, err) + } + + return headers, nil +} + +func parseOriginHeaders(rawHeaders string) (map[string]string, error) { + if len(rawHeaders) == 0 { + return nil, nil + } + + headers := strings.Split(rawHeaders, ",") + + result := make(map[string]string) + for _, kv := range headers { + kvParts := strings.Split(kv, "=") + if len(kvParts) != 2 || kvParts[0] == "" || kvParts[1] == "" { + return nil, fmt.Errorf("informed origin header does not follow 'key=value' format: %s", kv) + } + + result[kvParts[0]] = kvParts[1] + } + + return result, nil +} + func viewerFnARN(obj client.Object) string { return obj.GetAnnotations()[cfViewerFnAnnotation] } diff --git a/internal/k8s/ingress_test.go b/internal/k8s/ingress_test.go index 837a5f6..4f437f6 100644 --- a/internal/k8s/ingress_test.go +++ b/internal/k8s/ingress_test.go @@ -180,6 +180,95 @@ func (s *CDNIngressSuite) TestNewCDNIngressFromV1_UsingFunctionAssociationsAndVi s.Empty(got) } +func (s *CDNIngressSuite) TestNewCDNIngressFromV1_NilOriginHeadersAnnotationIsValid() { + ing := &networkingv1.Ingress{ + ObjectMeta: metav1.ObjectMeta{ + Name: "foo", + Namespace: "bar", + }, + } + + got, err := NewCDNIngressFromV1(context.Background(), ing, CDNClass{}) + s.NoError(err) + s.Nil(got.OriginHeaders) +} + +func (s *CDNIngressSuite) TestNewCDNIngressFromV1_EmptyOriginHeadersAnnotationIsValid() { + ing := &networkingv1.Ingress{ + ObjectMeta: metav1.ObjectMeta{ + Name: "foo", + Namespace: "bar", + Annotations: map[string]string{ + cfOrigHeadersAnnotation: "", + }, + }, + } + + got, err := NewCDNIngressFromV1(context.Background(), ing, CDNClass{}) + s.NoError(err) + s.Nil(got.OriginHeaders) +} + +func (s *CDNIngressSuite) TestNewCDNIngressFromV1_WithOriginHeadersAnnotationIsValid() { + ing := &networkingv1.Ingress{ + ObjectMeta: metav1.ObjectMeta{ + Name: "foo", + Namespace: "bar", + Annotations: map[string]string{ + cfOrigHeadersAnnotation: "key=value", + }, + }, + } + + got, err := NewCDNIngressFromV1(context.Background(), ing, CDNClass{}) + s.NoError(err) + s.Equal(map[string]string{"key": "value"}, got.OriginHeaders) +} + +func (s *CDNIngressSuite) TestNewCDNIngressFromV1_WithMalformedOriginHeadersAnnotationIsInvalid() { + testCases := []struct { + name string + annotation string + }{ + { + name: "Missing the key is invalid", + annotation: "=val", + }, + { + name: "Missing the val is invalid", + annotation: "key=", + }, + { + name: "Missing the key and value is invalid", + annotation: "=", + }, + { + name: "More than one equal sign is invalid", + annotation: "key=val=whoKnows", + }, + { + name: "No equal sign is invalid", + annotation: "key_val", + }, + } + + for _, tc := range testCases { + ing := &networkingv1.Ingress{ + ObjectMeta: metav1.ObjectMeta{ + Name: "foo", + Namespace: "bar", + Annotations: map[string]string{ + cfOrigHeadersAnnotation: tc.annotation, + }, + }, + } + + got, err := NewCDNIngressFromV1(context.Background(), ing, CDNClass{}) + s.Errorf(err, "test case: %s", tc.name) + s.Emptyf(got, "test case: %s", tc.name) + } +} + func (s *CDNIngressSuite) Test_sharedIngressParams_SingleOriginIsValid() { params := []CDNIngress{ { diff --git a/internal/k8s/user_origin.go b/internal/k8s/user_origin.go index 706bc49..9afd215 100644 --- a/internal/k8s/user_origin.go +++ b/internal/k8s/user_origin.go @@ -55,6 +55,7 @@ func cdnIngressesForUserOrigins(obj client.Object) ([]CDNIngress, error) { ing := CDNIngress{ NamespacedName: types.NamespacedName{Namespace: obj.GetNamespace(), Name: obj.GetName()}, OriginHost: o.Host, + OriginHeaders: o.Headers, Group: groupAnnotationValue(obj), UnmergedPaths: o.paths(), OriginReqPolicy: o.RequestPolicy, @@ -71,6 +72,7 @@ func cdnIngressesForUserOrigins(obj client.Object) ([]CDNIngress, error) { type userOrigin struct { Host string `yaml:"host"` + Headers map[string]string `yaml:"headers"` ResponseTimeout int64 `yaml:"responseTimeout"` Paths []string `yaml:"paths"` // deprecated in favor of Behaviors Behaviors []customOriginBehavior `yaml:"behaviors"` diff --git a/internal/k8s/user_origin_test.go b/internal/k8s/user_origin_test.go index 0bfcfc2..f8ce8ce 100644 --- a/internal/k8s/user_origin_test.go +++ b/internal/k8s/user_origin_test.go @@ -273,3 +273,28 @@ func (s *userOriginSuite) Test_cdnIngressesForUserOrigins_InvalidAnnotationValue s.Nil(got, "test: %s", tc.name) } } + +func (s *userOriginSuite) Test_cdnIngressesForUserOrigins_WithHeadersIsValid() { + userOriginsYAML := ` +- host: foo.com + responseTimeout: 30 + headers: + static: val + dynamic: '{{origin.host}}' + behaviors: + - path: /bar +` + + ing := &networkingv1.Ingress{} + ing.Annotations = map[string]string{ + cfUserOriginsAnnotation: userOriginsYAML, + } + + got, err := cdnIngressesForUserOrigins(ing) + s.NoError(err) + s.Len(got, 1) + s.Equal(map[string]string{ + "static": "val", + "dynamic": "{{origin.host}}", + }, got[0].OriginHeaders) +}