Skip to content

Commit

Permalink
Merge pull request #104 from gympass/PE1-1796/cert-auto-discovery
Browse files Browse the repository at this point in the history
[PE1-1796] feat: discover ACM certificates automatically
  • Loading branch information
LCaparelli authored Oct 5, 2023
2 parents c02f1f4 + f3f9fb6 commit 9763a34
Show file tree
Hide file tree
Showing 17 changed files with 386 additions and 77 deletions.
10 changes: 6 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,12 +51,11 @@ The controller has several [infrastructure configurations](#configuration). In o

| Parameter | Required | Description | | |
|----------------|----------|------------------------------------------------------------------------------------------------------------------------------------------------------------------|---|---|
| certificateArn | yes | The ARN of ACM certificate which should be used by the distributions. | | |
| hostedZoneID | yes | The ID of the Route53 zone where the aliases should be created in. | | |
| createAlias | yes | Whether the controller should create DNS records for a distribution's alternate domain names. | | |
| txtOwnerValue | yes | The controller creates TXT records for managing aliases. In it, a value is written to bind that given record to a particular instance of the controller running. | | |

For example, imagine you need some of your CloudFront distributions to be in the `foo.com` zone and the others on the `bar.com` zone. In order to do that you need create both `CDNClass` kinds and set different values for the `hostedZoneID`, `certificateArn`, `createAlias` and `txtOwnerValue` parameters.
For example, imagine you need some of your CloudFront distributions to be in the `foo.com` zone and the others on the `bar.com` zone. In order to do that you need create both `CDNClass` kinds and set different values for the `hostedZoneID`, `createAlias` and `txtOwnerValue` parameters.

For this example, for the first kind we should have:

Expand All @@ -66,7 +65,6 @@ kind: CDNClass
metadata:
name: foo-com
spec:
certificateArn: "<Certificate ARN from given hosted zone>"
hostedZoneID: "<foo-com hosted zone ID>"
createAlias: true
txtOwnerValue: "<foo-owner value>"
Expand All @@ -80,7 +78,6 @@ kind: CDNClass
metadata:
name: bar-com
spec:
certificateArn: "<Certificate ARN from given hosted zone>"
hostedZoneID: "<bar-com hosted zone ID>"
createAlias: true
txtOwnerValue: "<bar-owner value>"
Expand All @@ -99,6 +96,11 @@ While Ingresses that serve as origins for CloudFronts at the `bar.com` zone shou
``` yaml
cdn-origin-controller.gympass.com/cdn.class: bar-com
```
### TLS Certificate configuration

TLS will automatically be enabled if the `CF_SECURITY_POLICY` env var is set, and is disabled by default.

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.

## Behavior ordering

Expand Down
3 changes: 1 addition & 2 deletions api/v1alpha1/cdnclass_types.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,8 +28,7 @@ import (

// CDNClassSpec defines the desired state of CDNClass
type CDNClassSpec struct {
// CertificateArn represents a valid Certificate ARN for a domain name
// +kubebuilder:validation:Required
// CertificateArn is deprecated, certs are now automatically discovered and this field is ignored
CertificateArn string `json:"certificateArn"`
// HostedZoneID represents a valid hosted zone ID for a domain name
// +kubebuilder:validation:Required
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,8 +35,8 @@ spec:
description: CDNClassSpec defines the desired state of CDNClass
properties:
certificateArn:
description: CertificateArn represents a valid Certificate ARN for
a domain name
description: CertificateArn is deprecated, certs are now automatically
discovered and this field is ignored
type: string
createAlias:
description: CreateAlias determine if should create an DNS alias for
Expand Down
4 changes: 2 additions & 2 deletions config/crd/bases/cdn.gympass.com_cdnclasses.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -35,8 +35,8 @@ spec:
description: CDNClassSpec defines the desired state of CDNClass
properties:
certificateArn:
description: CertificateArn represents a valid Certificate ARN for
a domain name
description: CertificateArn is deprecated, certs are now automatically
discovered and this field is ignored
type: string
createAlias:
description: CreateAlias determine if should create an DNS alias for
Expand Down
3 changes: 1 addition & 2 deletions config/samples/cdn_v1alpha1_cdnclass.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@ kind: CDNClass
metadata:
name: cdnclass-sample
spec:
certificateArn: arn:aws:acm:us-east-1:215023620964:certificate/928e8e0d-eb37-4c47-b324-159cdbffbef2
hostedZoneID: Z02652453DOG4JF0CLCC7
hostedZoneID: 000000000000000000000
createAlias: true
txtOwnerValue: "owner value"
7 changes: 0 additions & 7 deletions config/samples/cdn_v1alpha1_eu_cdnclass.yaml

This file was deleted.

51 changes: 51 additions & 0 deletions internal/certificate/certificate.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
// Copyright (c) 2023 GPBR Participacoes LTDA.
//
// Permission is hereby granted, free of charge, to any person obtaining a copy of
// this software and associated documentation files (the "Software"), to deal in
// the Software without restriction, including without limitation the rights to
// use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
// the Software, and to permit persons to whom the Software is furnished to do so,
// subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in all
// copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
// FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
// COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
// IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
// CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.

package certificate

// New creates a Certificate
func New(arn, domainName string, alternativeNames []string /*, renewalEligibility string*/) Certificate {
return Certificate{
arn: arn,
domainName: domainName,
alternativeNames: alternativeNames,
}
}

// Certificate represents a basic certificate
type Certificate struct {
arn string
domainName string
alternativeNames []string
}

// DomainName returns the main certificate domain name
func (c Certificate) DomainName() string {
return c.domainName
}

// AlternativeNames returns a list of certificate subject alternative names
func (c Certificate) AlternativeNames() []string {
return c.alternativeNames
}

// ARN returns the certificate identifier
func (c Certificate) ARN() string {
return c.arn
}
93 changes: 93 additions & 0 deletions internal/certificate/repository.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
// Copyright (c) 2023 GPBR Participacoes LTDA.
//
// Permission is hereby granted, free of charge, to any person obtaining a copy of
// this software and associated documentation files (the "Software"), to deal in
// the Software without restriction, including without limitation the rights to
// use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
// the Software, and to permit persons to whom the Software is furnished to do so,
// subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in all
// copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
// FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
// COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
// IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
// CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.

package certificate

import (
"errors"
"fmt"

"github.com/aws/aws-sdk-go/aws"
"github.com/aws/aws-sdk-go/service/acm"
"github.com/aws/aws-sdk-go/service/acm/acmiface"
)

var (
errFindCert = errors.New("finding certificate")
)

// CertFilter type represents a certificate filter interface
type CertFilter func(c Certificate) bool

// Repository provides methods for manipulating Custom domain names on AWS
type Repository interface {
FindByFilter(CertFilter) ([]Certificate, error)
}

type acmCertRepository struct {
client acmiface.ACMAPI
}

// NewRepository creates a new Repository
func NewRepository(c acmiface.ACMAPI) Repository {
return acmCertRepository{client: c}
}

// FindByFilter find a certificate given a filter
func (r acmCertRepository) FindByFilter(filter CertFilter) ([]Certificate, error) {

input := &acm.ListCertificatesInput{
CertificateStatuses: aws.StringSlice([]string{acm.CertificateStatusIssued}),
}

var certs []Certificate
var certDiscoveryErr error

err := r.client.ListCertificatesPages(input, func(output *acm.ListCertificatesOutput, _ bool) bool {
for _, acmCertSummary := range output.CertificateSummaryList {
acmCert, err := r.client.DescribeCertificate(&acm.DescribeCertificateInput{
CertificateArn: acmCertSummary.CertificateArn,
})

if err != nil {
certDiscoveryErr = fmt.Errorf("describing certificate (ARN: %s): %v", *acmCertSummary.CertificateArn, err)
return false
}

certDetails := acmCert.Certificate
dnCert := New(*certDetails.CertificateArn,
*certDetails.DomainName,
aws.StringValueSlice(acmCert.Certificate.SubjectAlternativeNames),
)
if filter(dnCert) {
certs = append(certs, dnCert)
}
}
return true
})

if certDiscoveryErr != nil {
err = certDiscoveryErr
}
if err != nil {
return []Certificate{}, fmt.Errorf("%w: %v", errFindCert, err)
}

return certs, nil
}
84 changes: 84 additions & 0 deletions internal/certificate/service.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
// Copyright (c) 2023 GPBR Participacoes LTDA.
//
// Permission is hereby granted, free of charge, to any person obtaining a copy of
// this software and associated documentation files (the "Software"), to deal in
// the Software without restriction, including without limitation the rights to
// use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
// the Software, and to permit persons to whom the Software is furnished to do so,
// subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in all
// copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
// FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
// COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
// IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
// CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.

package certificate

import (
"errors"
"fmt"
"strings"
)

var (
// ErrNoMatchingCert any matching certificate error
ErrNoMatchingCert = errors.New("could not find any matching certificate")
)

// Service handle the certificate actions as discovery
type Service interface {
DiscoverByHost(string) (Certificate, error)
}

// NewService creates a new Certificate Service
func NewService(c Repository) Service {
return acmCertService{repo: c}
}

type acmCertService struct {
repo Repository
}

// DiscoverByHost tries to discover a certificate given a host
func (a acmCertService) DiscoverByHost(host string) (Certificate, error) {

certs, err := a.repo.FindByFilter(matchingDomainFilter(host))

if err != nil {
return Certificate{}, fmt.Errorf("discovery certificate: %v", err)
}

if len(certs) == 0 {
return Certificate{}, ErrNoMatchingCert
}

return certs[0], nil
}

func matchingDomainFilter(host string) CertFilter {
return func(c Certificate) bool {
if host == c.DomainName() {
return true
}

for _, alterName := range c.AlternativeNames() {
hs := strings.Split(host, ".")
hostDomain := strings.Join(hs[1:], ".")

if strings.HasPrefix(alterName, "*.") {
alterName = strings.ReplaceAll(alterName, "*.", "")
}

if alterName == hostDomain {
return true
}
}

return false
}
}
57 changes: 57 additions & 0 deletions internal/certificate/service_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
// Copyright (c) 2023 GPBR Participacoes LTDA.
//
// Permission is hereby granted, free of charge, to any person obtaining a copy of
// this software and associated documentation files (the "Software"), to deal in
// the Software without restriction, including without limitation the rights to
// use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
// the Software, and to permit persons to whom the Software is furnished to do so,
// subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in all
// copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
// FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
// COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
// IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
// CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.

package certificate

import (
"testing"

"github.com/stretchr/testify/suite"
)

func TestRunCertificateServiceSuite(t *testing.T) {
t.Parallel()
suite.Run(t, &CertificateServiceTestSuite{})
}

type CertificateServiceTestSuite struct {
suite.Suite
}

func (s *CertificateServiceTestSuite) TestMatchDomainFilter_MainDomain() {
cert := New(
"arn:xpto",
"foo.xpto.com",
[]string{"foo.xpto.com", "*.foo.xpto.com"},
)

filter := matchingDomainFilter("foo.xpto.com")
s.True(filter(cert))
}

func (s *CertificateServiceTestSuite) TestMatchDomainFilter_SubDomain() {
cert := New(
"arn:xpto",
"foo.xpto.com",
[]string{"foo.xpto.com", "*.foo.xpto.com"},
)

filter := matchingDomainFilter("baz.foo.xpto.com")
s.True(filter(cert))
}
Loading

0 comments on commit 9763a34

Please sign in to comment.