├── ci ├── app │ ├── Staticfile │ └── index.html ├── run-tests.sh ├── create-db.sh ├── create-db.yml ├── acceptance-tests.yml ├── run-tests.yml ├── credentials.example.yml ├── acceptance-tests.sh └── pipeline.yml ├── CODEOWNERS ├── manifest-base.yml ├── .github └── pull_request_template.md ├── .codeclimate.yml ├── .travis.yml ├── cf ├── client.go └── mocks │ └── Client.go ├── manifest-broker.yml ├── manifest-cron.yml ├── .gitignore ├── healthchecks ├── postgresql.go ├── cloudfront.go ├── cloudfoundry.go ├── letsencrypt.go ├── s3.go └── healthchecks.go ├── broker ├── broker_bind_test.go ├── broker_last_operation_test.go ├── broker_update_test.go ├── broker_provision_test.go └── broker.go ├── utils ├── headers.go ├── headers_test.go ├── iam.go ├── certs.go └── cloudfront.go ├── catalog.json ├── cmd ├── cdn-broker │ ├── main_test.go │ └── main.go └── cdn-cron │ └── main.go ├── SECURITY.md ├── CONTRIBUTING.md ├── LICENSE.md ├── config └── config.go ├── go.mod ├── models ├── mocks │ └── RouteManagerIface.go ├── models_test.go └── models.go ├── README.md └── go.sum /ci/app/Staticfile: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /CODEOWNERS: -------------------------------------------------------------------------------- 1 | * @cloud-gov/customer-success-squad 2 | 3 | -------------------------------------------------------------------------------- /ci/app/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 |

CDN Broker Test

4 | 5 | 6 | -------------------------------------------------------------------------------- /manifest-base.yml: -------------------------------------------------------------------------------- 1 | --- 2 | buildpack: go_buildpack 3 | memory: 128M 4 | services: 5 | - rds-cdn-broker 6 | -------------------------------------------------------------------------------- /.github/pull_request_template.md: -------------------------------------------------------------------------------- 1 | ## Changes proposed in this pull request: 2 | - 3 | - 4 | - 5 | 6 | ## security considerations 7 | [Note the any security considerations here, or make note of why there are none] 8 | -------------------------------------------------------------------------------- /.codeclimate.yml: -------------------------------------------------------------------------------- 1 | engines: 2 | fixme: 3 | enabled: true 4 | gofmt: 5 | enabled: true 6 | govet: 7 | enabled: true 8 | ratings: 9 | paths: 10 | - "**.go" 11 | exclude_paths: 12 | - vendor/ 13 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: go 2 | 3 | go: 4 | - "1.7" 5 | - tip 6 | 7 | # Dependencies live in /vendor/ 8 | install: true 9 | 10 | # Skip vendor tests 11 | script: go test -v $(go list ./... | grep -v /vendor/) 12 | -------------------------------------------------------------------------------- /cf/client.go: -------------------------------------------------------------------------------- 1 | package cf 2 | 3 | import "github.com/cloudfoundry-community/go-cfclient" 4 | 5 | type Client interface { 6 | GetDomainByName(name string) (cfclient.Domain, error) 7 | GetOrgByGuid(guid string) (cfclient.Org, error) 8 | } 9 | -------------------------------------------------------------------------------- /manifest-broker.yml: -------------------------------------------------------------------------------- 1 | --- 2 | inherit: manifest-base.yml 3 | applications: 4 | - name: cdn-broker 5 | command: cdn-broker 6 | env: 7 | GO_INSTALL_PACKAGE_SPEC: "./cmd/cdn-broker" 8 | GOPACKAGENAME: "github.com/cloud-gov/cf-cdn-service-broker" 9 | -------------------------------------------------------------------------------- /ci/run-tests.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -eux 4 | 5 | export GOPATH=$(pwd)/gopath 6 | export PATH=${PATH}:${GOPATH}/bin 7 | mkdir -p ${GOPATH}/bin 8 | 9 | pushd gopath/src/github.com/cloud-gov/cf-cdn-service-broker 10 | go test $(go list ./... | grep -v /vendor/) 11 | popd 12 | -------------------------------------------------------------------------------- /manifest-cron.yml: -------------------------------------------------------------------------------- 1 | --- 2 | inherit: manifest-base.yml 3 | applications: 4 | - name: cdn-cron 5 | command: cdn-cron 6 | health-check-type: process 7 | no-route: true 8 | env: 9 | GO_INSTALL_PACKAGE_SPEC: "./cmd/cdn-cron" 10 | GOPACKAGENAME: "github.com/cloud-gov/cf-cdn-service-broker" 11 | -------------------------------------------------------------------------------- /ci/create-db.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -e -u 4 | 5 | cf login -a $CF_API_URL -u $CF_USERNAME -p $CF_PASSWORD -o $CF_ORGANIZATION -s $CF_SPACE 6 | 7 | # Create database service instance if not exists 8 | if ! cf service $SERVICE_NAME ; then 9 | cf create-service $SERVICE_TYPE $SERVICE_PLAN $SERVICE_NAME 10 | fi 11 | -------------------------------------------------------------------------------- /ci/create-db.yml: -------------------------------------------------------------------------------- 1 | --- 2 | platform: linux 3 | 4 | image_resource: 5 | type: registry-image 6 | source: 7 | aws_access_key_id: ((ecr_aws_key)) 8 | aws_secret_access_key: ((ecr_aws_secret)) 9 | repository: general-task 10 | aws_region: us-gov-west-1 11 | tag: latest 12 | 13 | inputs: 14 | - name: broker-src 15 | 16 | run: 17 | path: broker-src/ci/create-db.sh 18 | -------------------------------------------------------------------------------- /ci/acceptance-tests.yml: -------------------------------------------------------------------------------- 1 | --- 2 | platform: linux 3 | 4 | image_resource: 5 | type: registry-image 6 | source: 7 | aws_access_key_id: ((ecr_aws_key)) 8 | aws_secret_access_key: ((ecr_aws_secret)) 9 | repository: general-task 10 | aws_region: us-gov-west-1 11 | tag: latest 12 | 13 | inputs: 14 | - name: broker-src 15 | 16 | run: 17 | path: broker-src/ci/acceptance-tests.sh 18 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Compiled Object files, Static and Dynamic libs (Shared Objects) 2 | *.o 3 | *.a 4 | *.so 5 | 6 | # Folders 7 | _obj 8 | _test 9 | tmp 10 | 11 | # Architecture specific extensions/prefixes 12 | *.[568vq] 13 | [568vq].out 14 | 15 | *.cgo1.go 16 | *.cgo2.c 17 | _cgo_defun.c 18 | _cgo_gotypes.go 19 | _cgo_export.* 20 | 21 | _testmain.go 22 | 23 | *.exe 24 | *.test 25 | *.prof 26 | 27 | credentials.yml 28 | 29 | vendor/ 30 | -------------------------------------------------------------------------------- /healthchecks/postgresql.go: -------------------------------------------------------------------------------- 1 | package healthchecks 2 | 3 | import ( 4 | "github.com/jinzhu/gorm" 5 | _ "github.com/jinzhu/gorm/dialects/postgres" 6 | 7 | "github.com/cloud-gov/cf-cdn-service-broker/config" 8 | ) 9 | 10 | func Postgresql(settings config.Settings) error { 11 | db, err := gorm.Open("postgres", settings.DatabaseUrl) 12 | defer db.Close() 13 | 14 | if err != nil { 15 | return err 16 | } 17 | 18 | return nil 19 | } 20 | -------------------------------------------------------------------------------- /ci/run-tests.yml: -------------------------------------------------------------------------------- 1 | --- 2 | platform: linux 3 | 4 | image_resource: 5 | type: registry-image 6 | source: 7 | aws_access_key_id: ((ecr_aws_key)) 8 | aws_secret_access_key: ((ecr_aws_secret)) 9 | repository: general-task 10 | aws_region: us-gov-west-1 11 | tag: latest 12 | 13 | inputs: 14 | - name: broker-src 15 | path: gopath/src/github.com/cloud-gov/cf-cdn-service-broker 16 | 17 | run: 18 | path: gopath/src/github.com/cloud-gov/cf-cdn-service-broker/ci/run-tests.sh 19 | -------------------------------------------------------------------------------- /healthchecks/cloudfront.go: -------------------------------------------------------------------------------- 1 | package healthchecks 2 | 3 | import ( 4 | "github.com/aws/aws-sdk-go/aws" 5 | "github.com/aws/aws-sdk-go/aws/session" 6 | "github.com/aws/aws-sdk-go/service/cloudfront" 7 | 8 | "github.com/cloud-gov/cf-cdn-service-broker/config" 9 | ) 10 | 11 | func Cloudfront(settings config.Settings) error { 12 | session := session.New(aws.NewConfig().WithRegion(settings.AwsDefaultRegion)) 13 | svc := cloudfront.New(session) 14 | 15 | params := &cloudfront.ListDistributionsInput{} 16 | _, err := svc.ListDistributions(params) 17 | if err != nil { 18 | return err 19 | } 20 | 21 | return nil 22 | } 23 | -------------------------------------------------------------------------------- /broker/broker_bind_test.go: -------------------------------------------------------------------------------- 1 | package broker_test 2 | 3 | import ( 4 | "context" 5 | "testing" 6 | 7 | "github.com/stretchr/testify/assert" 8 | 9 | "github.com/pivotal-cf/brokerapi" 10 | 11 | "github.com/cloud-gov/cf-cdn-service-broker/broker" 12 | ) 13 | 14 | func TestBind(t *testing.T) { 15 | b := broker.CdnServiceBroker{} 16 | _, err := b.Bind(context.Background(), "", "", brokerapi.BindDetails{}) 17 | assert.NotNil(t, err) 18 | } 19 | 20 | func TestUnbind(t *testing.T) { 21 | b := broker.CdnServiceBroker{} 22 | err := b.Unbind(context.Background(), "", "", brokerapi.UnbindDetails{}) 23 | assert.NotNil(t, err) 24 | } 25 | -------------------------------------------------------------------------------- /utils/headers.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "net/textproto" 5 | ) 6 | 7 | type Headers map[string]bool 8 | 9 | func (h Headers) Add(header string) { 10 | canonicalHeader := textproto.CanonicalMIMEHeaderKey(header) 11 | h[canonicalHeader] = true 12 | } 13 | 14 | func (h Headers) Contains(header string) bool { 15 | canonicalHeader := textproto.CanonicalMIMEHeaderKey(header) 16 | _, isPresent := h[canonicalHeader] 17 | return isPresent 18 | } 19 | 20 | func (h Headers) Strings() []string { 21 | headers := []string{} 22 | for header, _ := range h { 23 | headers = append(headers, header) 24 | } 25 | return headers 26 | } 27 | -------------------------------------------------------------------------------- /healthchecks/cloudfoundry.go: -------------------------------------------------------------------------------- 1 | package healthchecks 2 | 3 | import ( 4 | "net/http" 5 | "time" 6 | 7 | "github.com/cloudfoundry-community/go-cfclient" 8 | 9 | "github.com/cloud-gov/cf-cdn-service-broker/config" 10 | ) 11 | 12 | func Cloudfoundry(settings config.Settings) error { 13 | // We're only validating that the CF endpoint is contactable here, as 14 | // testing the authentication is tricky 15 | _, err := cfclient.NewClient(&cfclient.Config{ 16 | ApiAddress: settings.APIAddress, 17 | ClientID: settings.ClientID, 18 | ClientSecret: settings.ClientSecret, 19 | HttpClient: &http.Client{ 20 | Timeout: time.Second * 10, 21 | }, 22 | }) 23 | if err != nil { 24 | return err 25 | } 26 | 27 | return nil 28 | } 29 | -------------------------------------------------------------------------------- /catalog.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "8478b533-2b59-4007-8494-2feec5970f94", 3 | "name": "cdn-route", 4 | "description": "Custom domains, CDN caching, and TLS certificates with automatic renewal", 5 | "bindable": true, 6 | "metadata": { 7 | "displayName": "CDN Route", 8 | "documentationUrl": "https://cloud.gov/docs/services/cdn-route/" 9 | }, 10 | "plan_updateable": true, 11 | "plans": [ 12 | { 13 | "id": "fc055c72-1075-44c9-9aee-bddd52e1b053", 14 | "name": "cdn-route", 15 | "description": "Custom domains, CDN caching, and TLS certificates with automatic renewal", 16 | "free": true, 17 | "metadata": { 18 | "displayName": "Content Distribution Network Route" 19 | } 20 | } 21 | ] 22 | } 23 | -------------------------------------------------------------------------------- /healthchecks/letsencrypt.go: -------------------------------------------------------------------------------- 1 | package healthchecks 2 | 3 | import ( 4 | "crypto" 5 | "github.com/xenolf/lego/acme" 6 | 7 | "github.com/cloud-gov/cf-cdn-service-broker/config" 8 | ) 9 | 10 | type User struct { 11 | Email string 12 | Registration *acme.RegistrationResource 13 | key crypto.PrivateKey 14 | } 15 | 16 | func (u *User) GetEmail() string { 17 | return u.Email 18 | } 19 | 20 | func (u *User) GetRegistration() *acme.RegistrationResource { 21 | return u.Registration 22 | } 23 | 24 | func (u *User) GetPrivateKey() crypto.PrivateKey { 25 | return u.key 26 | } 27 | 28 | func LetsEncrypt(settings config.Settings) error { 29 | user := &User{key: "cheese"} 30 | _, err := acme.NewClient("https://acme-v01.api.letsencrypt.org/directory", user, acme.RSA2048) 31 | return err 32 | } 33 | -------------------------------------------------------------------------------- /cmd/cdn-broker/main_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "net/http" 5 | "net/http/httptest" 6 | "testing" 7 | 8 | "code.cloudfoundry.org/lager" 9 | "github.com/cloud-gov/cf-cdn-service-broker/broker" 10 | "github.com/cloud-gov/cf-cdn-service-broker/config" 11 | "github.com/pivotal-cf/brokerapi" 12 | ) 13 | 14 | func TestHTTPHandler(t *testing.T) { 15 | brokerAPI := brokerapi.New( 16 | &broker.CdnServiceBroker{}, 17 | lager.NewLogger("main.test"), 18 | brokerapi.BrokerCredentials{}, 19 | ) 20 | handler := bindHTTPHandlers(brokerAPI, config.Settings{}) 21 | req, err := http.NewRequest("GET", "http://example.com/healthcheck/http", nil) 22 | if err != nil { 23 | t.Error("Building new HTTP request: error should not have occurred") 24 | } 25 | 26 | w := httptest.NewRecorder() 27 | handler.ServeHTTP(w, req) 28 | 29 | if w.Code != 200 { 30 | t.Errorf("HTTP response: response code was %d, expecting 200", w.Code) 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /SECURITY.md: -------------------------------------------------------------------------------- 1 | 2 | **Reporting Security Issues** 3 | 4 | Please refrain from reporting security vulnerabilities through public GitHub issues. 5 | 6 | Instead, kindly report them via the information provided in [cloud.gov's security.txt](https://cloud.gov/.well-known/security.txt). 7 | 8 | When reporting, include the following details (as much as possible) to help us understand the nature and extent of the potential issue: 9 | 10 | - Type of issue (e.g., buffer overflow, SQL injection, cross-site scripting, etc.) 11 | - Full paths of related source file(s) 12 | - Location of affected source code (tag/branch/commit or direct URL) 13 | - Any special configuration required to reproduce the issue 14 | - Step-by-step instructions to reproduce the issue 15 | - Proof-of-concept or exploit code (if available) 16 | - Impact of the issue, including potential exploitation by attackers 17 | 18 | Providing this information will facilitate a quicker triage of your report. 19 | -------------------------------------------------------------------------------- /healthchecks/s3.go: -------------------------------------------------------------------------------- 1 | package healthchecks 2 | 3 | import ( 4 | "strings" 5 | 6 | "github.com/aws/aws-sdk-go/aws" 7 | "github.com/aws/aws-sdk-go/aws/session" 8 | "github.com/aws/aws-sdk-go/service/s3" 9 | 10 | "github.com/cloud-gov/cf-cdn-service-broker/config" 11 | ) 12 | 13 | func S3(settings config.Settings) error { 14 | bucket := settings.Bucket 15 | key := "healthcheck-test-key" 16 | 17 | session := session.New(aws.NewConfig().WithRegion(settings.AwsDefaultRegion)) 18 | svc := s3.New(session) 19 | 20 | input := s3.PutObjectInput{ 21 | Bucket: aws.String(bucket), 22 | Key: aws.String(key), 23 | Body: strings.NewReader("cheese"), 24 | } 25 | 26 | _, err := svc.PutObject(&input) 27 | if err != nil { 28 | return err 29 | } 30 | 31 | _, err = svc.DeleteObject(&s3.DeleteObjectInput{ 32 | Bucket: aws.String(bucket), 33 | Key: aws.String(key), 34 | }) 35 | if err != nil { 36 | return err 37 | } 38 | 39 | return nil 40 | } 41 | -------------------------------------------------------------------------------- /utils/headers_test.go: -------------------------------------------------------------------------------- 1 | package utils_test 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/suite" 7 | 8 | . "github.com/cloud-gov/cf-cdn-service-broker/utils" 9 | ) 10 | 11 | func TestHeaders(t *testing.T) { 12 | suite.Run(t, new(HeadersSuite)) 13 | } 14 | 15 | type HeadersSuite struct { 16 | suite.Suite 17 | } 18 | 19 | func (h *HeadersSuite) SetupTest() {} 20 | 21 | func (h *HeadersSuite) TestAdd() { 22 | headers := Headers{} 23 | headers.Add("abc-def") 24 | h.Equal(headers, Headers{"Abc-Def": true}) 25 | } 26 | 27 | func (h *HeadersSuite) TestContains() { 28 | headers := Headers{"Abc-Def": true} 29 | h.True(headers.Contains("Abc-Def")) 30 | h.False(headers.Contains("Ghi-Jkl")) 31 | } 32 | 33 | func (h *HeadersSuite) TestStrings() { 34 | headers := Headers{"Abc-Def": true, "User-Agent": true} 35 | headerStrings := headers.Strings() 36 | h.Contains(headerStrings, "Abc-Def") 37 | h.Contains(headerStrings, "User-Agent") 38 | h.Equal(len(headerStrings), 2) 39 | } 40 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | **Contribution Policy** 2 | 3 | Cloud.gov is an open source project operated by the U.S. General Services Administration (GSA) to support federal agency missions. While we value transparency and collaboration, we must balance openness with the responsibilities of operating a secure, compliant, and trusted federal platform. 4 | 5 | ✅ **Who can contribute** 6 | We welcome contributions from: 7 | 8 | - Employees of U.S. federal agencies 9 | - Contractors working under a current agreement with a U.S. government entity 10 | - GSA-approved contributors as part of official interagency collaboration 11 | 12 | ❌ **Who we cannot accept contributions from** 13 | To avoid the appearance of government endorsement, manage supply chain risk, and maintain the integrity of our compliance posture, we do **not** accept unsolicited contributions from: 14 | 15 | - Individuals unaffiliated with the U.S. government 16 | - International contributors or organizations 17 | - Unvetted accounts or first-time contributors submitting minor changes 18 | 19 | If you're unsure whether your contribution fits, feel free to open an issue first so we can discuss it. 20 | -------------------------------------------------------------------------------- /healthchecks/healthchecks.go: -------------------------------------------------------------------------------- 1 | package healthchecks 2 | 3 | import ( 4 | "fmt" 5 | "github.com/cloud-gov/cf-cdn-service-broker/config" 6 | "net/http" 7 | ) 8 | 9 | var checks = map[string]func(config.Settings) error{ 10 | "letsencrypt": LetsEncrypt, 11 | "s3": S3, 12 | "cloudfront": Cloudfront, 13 | "cloudfoundry": Cloudfoundry, 14 | "postgresql": Postgresql, 15 | } 16 | 17 | func Bind(mux *http.ServeMux, settings config.Settings) { 18 | mux.HandleFunc("/healthcheck", func(w http.ResponseWriter, r *http.Request) { 19 | body := "" 20 | for name, function := range checks { 21 | err := function(settings) 22 | if err != nil { 23 | body = body + fmt.Sprintf("%s error: %s\n", name, err) 24 | } 25 | } 26 | if body != "" { 27 | w.WriteHeader(http.StatusInternalServerError) 28 | fmt.Fprintf(w, "%s", body) 29 | } else { 30 | w.WriteHeader(http.StatusOK) 31 | } 32 | }) 33 | 34 | mux.HandleFunc("/healthcheck/http", func(w http.ResponseWriter, r *http.Request) { 35 | w.WriteHeader(http.StatusOK) 36 | }) 37 | 38 | for name, function := range checks { 39 | mux.HandleFunc("/healthcheck/"+name, func(w http.ResponseWriter, r *http.Request) { 40 | err := function(settings) 41 | if err != nil { 42 | w.WriteHeader(http.StatusInternalServerError) 43 | fmt.Fprintf(w, "%s error: %s", name, err) 44 | } else { 45 | w.WriteHeader(http.StatusOK) 46 | } 47 | }) 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | As a work of the United States Government, this project is in the 2 | public domain within the United States. 3 | 4 | Additionally, we waive copyright and related rights in the work 5 | worldwide through the CC0 1.0 Universal public domain dedication. 6 | 7 | ## CC0 1.0 Universal Summary 8 | 9 | This is a human-readable summary of the [Legal Code (read the full text)](https://creativecommons.org/publicdomain/zero/1.0/legalcode). 10 | 11 | ### No Copyright 12 | 13 | The person who associated a work with this deed has dedicated the work to 14 | the public domain by waiving all of his or her rights to the work worldwide 15 | under copyright law, including all related and neighboring rights, to the 16 | extent allowed by law. 17 | 18 | You can copy, modify, distribute and perform the work, even for commercial 19 | purposes, all without asking permission. 20 | 21 | ### Other Information 22 | 23 | In no way are the patent or trademark rights of any person affected by CC0, 24 | nor are the rights that other persons may have in the work or in how the 25 | work is used, such as publicity or privacy rights. 26 | 27 | Unless expressly stated otherwise, the person who associated a work with 28 | this deed makes no warranties about the work, and disclaims liability for 29 | all uses of the work, to the fullest extent permitted by applicable law. 30 | When using or citing the work, you should not imply endorsement by the 31 | author or the affirmer. 32 | -------------------------------------------------------------------------------- /cf/mocks/Client.go: -------------------------------------------------------------------------------- 1 | package mocks 2 | 3 | import cf "github.com/cloud-gov/cf-cdn-service-broker/cf" 4 | import cfclient "github.com/cloudfoundry-community/go-cfclient" 5 | import mock "github.com/stretchr/testify/mock" 6 | 7 | // Client is an autogenerated mock type for the Client type 8 | type Client struct { 9 | mock.Mock 10 | } 11 | 12 | // GetDomainByName provides a mock function with given fields: name 13 | func (_m *Client) GetDomainByName(name string) (cfclient.Domain, error) { 14 | ret := _m.Called(name) 15 | 16 | var r0 cfclient.Domain 17 | if rf, ok := ret.Get(0).(func(string) cfclient.Domain); ok { 18 | r0 = rf(name) 19 | } else { 20 | r0 = ret.Get(0).(cfclient.Domain) 21 | } 22 | 23 | var r1 error 24 | if rf, ok := ret.Get(1).(func(string) error); ok { 25 | r1 = rf(name) 26 | } else { 27 | r1 = ret.Error(1) 28 | } 29 | 30 | return r0, r1 31 | } 32 | 33 | // GetOrgByGuid provides a mock function with given fields: guid 34 | func (_m *Client) GetOrgByGuid(guid string) (cfclient.Org, error) { 35 | ret := _m.Called(guid) 36 | 37 | var r0 cfclient.Org 38 | if rf, ok := ret.Get(0).(func(string) cfclient.Org); ok { 39 | r0 = rf(guid) 40 | } else { 41 | r0 = ret.Get(0).(cfclient.Org) 42 | } 43 | 44 | var r1 error 45 | if rf, ok := ret.Get(1).(func(string) error); ok { 46 | r1 = rf(guid) 47 | } else { 48 | r1 = ret.Error(1) 49 | } 50 | 51 | return r0, r1 52 | } 53 | 54 | var _ cf.Client = (*Client)(nil) 55 | -------------------------------------------------------------------------------- /ci/credentials.example.yml: -------------------------------------------------------------------------------- 1 | cf-cdn-broker-git-url: https://github.com/cloud-gov/cf-cdn-service-broker.git 2 | cf-cdn-broker-git-branch: master 3 | 4 | pipeline-tasks-git-url: https://github.com/cloud-gov/cg-pipeline-tasks 5 | pipeline-tasks-git-branch: master 6 | 7 | cdn-broker-user-staging: user 8 | cdn-broker-pass-staging: pass 9 | cdn-broker-email-staging: 10 | cdn-broker-acme-url-staging: https://acme-staging.api.letsencrypt.org/directory 11 | cdn-broker-bucket-staging: 12 | cdn-broker-iam-path-prefix-staging: 13 | cdn-broker-access-key-id-staging: 14 | cdn-broker-secret-access-key-staging: 15 | cdn-broker-region-staging: 16 | cdn-broker-client-id-staging: 17 | cdn-broker-client-secret-staging: 18 | cdn-broker-default-origin-staging: 19 | 20 | cdn-broker-user-production: user 21 | cdn-broker-pass-production: pass 22 | cdn-broker-email-production: 23 | cdn-broker-acme-url-production: https://acme-v01.api.letsencrypt.org/directory 24 | cdn-broker-bucket-production: 25 | cdn-broker-iam-path-prefix-production: 26 | cdn-broker-access-key-id-production: 27 | cdn-broker-secret-access-key-production: 28 | cdn-broker-region-production: 29 | cdn-broker-client-id-production: 30 | cdn-broker-client-secret-production: 31 | cdn-broker-default-origin-production: 32 | 33 | hosted-zone-id-staging: 34 | domain-url-staging: 35 | 36 | hosted-zone-id-production: 37 | domain-url-production: 38 | 39 | cdn-timeout: 40 | 41 | cf-api-url-staging: https://api.cloud.gov 42 | cf-deploy-username-staging: 43 | cf-deploy-password-staging: 44 | cf-organization-staging: 45 | cf-space-staging: 46 | 47 | cf-api-url-production: https://api.cloud.gov 48 | cf-deploy-username-production: 49 | cf-deploy-password-production: 50 | cf-organization-production: 51 | cf-space-production: 52 | 53 | slack-channel: 54 | slack-username: 55 | slack-webhook-url: 56 | slack-icon-url: 57 | -------------------------------------------------------------------------------- /cmd/cdn-cron/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "math/rand" 5 | "os" 6 | "os/signal" 7 | "time" 8 | 9 | "code.cloudfoundry.org/lager" 10 | "github.com/robfig/cron" 11 | 12 | "github.com/aws/aws-sdk-go/aws" 13 | "github.com/aws/aws-sdk-go/aws/session" 14 | "github.com/aws/aws-sdk-go/service/cloudfront" 15 | "github.com/aws/aws-sdk-go/service/iam" 16 | 17 | "github.com/cloud-gov/cf-cdn-service-broker/config" 18 | "github.com/cloud-gov/cf-cdn-service-broker/models" 19 | "github.com/cloud-gov/cf-cdn-service-broker/utils" 20 | ) 21 | 22 | func main() { 23 | logger := lager.NewLogger("cdn-cron") 24 | logger.RegisterSink(lager.NewWriterSink(os.Stderr, lager.INFO)) 25 | 26 | rand.Seed(time.Now().UnixNano()) 27 | 28 | settings, err := config.NewSettings() 29 | if err != nil { 30 | logger.Fatal("new-settings", err) 31 | } 32 | 33 | session := session.New(aws.NewConfig().WithRegion(settings.AwsDefaultRegion)) 34 | 35 | db, err := config.Connect(settings) 36 | if err != nil { 37 | logger.Fatal("connect", err) 38 | } 39 | 40 | if err := db.AutoMigrate(&models.Route{}, &models.Certificate{}, &models.UserData{}).Error; err != nil { 41 | logger.Fatal("migrate", err) 42 | } 43 | 44 | manager := models.NewManager( 45 | logger, 46 | &utils.Iam{settings, iam.New(session)}, 47 | &utils.Distribution{settings, cloudfront.New(session)}, 48 | settings, 49 | db, 50 | ) 51 | 52 | c := cron.New() 53 | 54 | c.AddFunc(settings.Schedule, func() { 55 | logger.Info("Running renew") 56 | manager.RenewAll() 57 | }) 58 | 59 | c.AddFunc(settings.Schedule, func() { 60 | logger.Info("Running cert cleanup") 61 | manager.DeleteOrphanedCerts() 62 | }) 63 | 64 | logger.Info("Starting cron") 65 | c.Start() 66 | 67 | waitForExit() 68 | } 69 | 70 | func waitForExit() os.Signal { 71 | c := make(chan os.Signal, 1) 72 | signal.Notify(c, os.Interrupt, os.Kill) 73 | return <-c 74 | } 75 | -------------------------------------------------------------------------------- /utils/iam.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/aws/aws-sdk-go/aws" 7 | "github.com/aws/aws-sdk-go/service/iam" 8 | 9 | "github.com/xenolf/lego/acme" 10 | 11 | "github.com/cloud-gov/cf-cdn-service-broker/config" 12 | ) 13 | 14 | type IamIface interface { 15 | UploadCertificate(name string, cert acme.CertificateResource) (string, error) 16 | DeleteCertificate(name string) error 17 | ListCertificates(callback func(iam.ServerCertificateMetadata) bool) error 18 | } 19 | 20 | type Iam struct { 21 | Settings config.Settings 22 | Service *iam.IAM 23 | } 24 | 25 | func (i *Iam) UploadCertificate(name string, cert acme.CertificateResource) (string, error) { 26 | resp, err := i.Service.UploadServerCertificate(&iam.UploadServerCertificateInput{ 27 | CertificateBody: aws.String(string(cert.Certificate)), 28 | PrivateKey: aws.String(string(cert.PrivateKey)), 29 | ServerCertificateName: aws.String(name), 30 | Path: aws.String(fmt.Sprintf("/cloudfront/%s/", i.Settings.IamPathPrefix)), 31 | }) 32 | if err != nil { 33 | return "", err 34 | } 35 | 36 | return *resp.ServerCertificateMetadata.ServerCertificateId, nil 37 | } 38 | 39 | func (i *Iam) ListCertificates(callback func(iam.ServerCertificateMetadata) bool) error { 40 | return i.Service.ListServerCertificatesPages( 41 | &iam.ListServerCertificatesInput{ 42 | PathPrefix: aws.String(fmt.Sprintf("/cloudfront/%s/", i.Settings.IamPathPrefix)), 43 | }, 44 | func(page *iam.ListServerCertificatesOutput, lastPage bool) bool { 45 | for _, v := range page.ServerCertificateMetadataList { 46 | // stop iteration if the callback tells us to 47 | if callback(*v) == false { 48 | return false 49 | } 50 | } 51 | 52 | return true 53 | }, 54 | ) 55 | } 56 | 57 | func (i *Iam) DeleteCertificate(name string) error { 58 | _, err := i.Service.DeleteServerCertificate(&iam.DeleteServerCertificateInput{ 59 | ServerCertificateName: aws.String(name), 60 | }) 61 | 62 | return err 63 | } 64 | -------------------------------------------------------------------------------- /config/config.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "github.com/kelseyhightower/envconfig" 5 | 6 | "github.com/jinzhu/gorm" 7 | _ "github.com/jinzhu/gorm/dialects/postgres" 8 | _ "github.com/lib/pq" 9 | ) 10 | 11 | type Settings struct { 12 | Port string `envconfig:"port" default:"3000"` 13 | BrokerUsername string `envconfig:"broker_username" required:"true"` 14 | BrokerPassword string `envconfig:"broker_password" required:"true"` 15 | DatabaseUrl string `envconfig:"database_url" required:"true"` 16 | Email string `envconfig:"email" required:"true"` 17 | AcmeUrl string `envconfig:"acme_url" required:"true"` 18 | Bucket string `envconfig:"bucket" required:"true"` 19 | IamPathPrefix string `envconfig:"iam_path_prefix" default:"letsencrypt"` 20 | CloudFrontPrefix string `envconfig:"cloudfront_prefix" default:""` 21 | AwsAccessKeyId string `envconfig:"aws_access_key_id" required:"true"` 22 | AwsSecretAccessKey string `envconfig:"aws_secret_access_key" required:"true"` 23 | AwsDefaultRegion string `envconfig:"aws_default_region" required:"true"` 24 | ServerSideEncryption string `envconfig:"server_side_encryption"` 25 | APIAddress string `envconfig:"api_address" required:"true"` 26 | ClientID string `envconfig:"client_id" required:"true"` 27 | ClientSecret string `envconfig:"client_secret" required:"true"` 28 | DefaultOrigin string `envconfig:"default_origin" required:"true"` 29 | Schedule string `envconfig:"schedule" default:"0 0 * * * *"` 30 | UserIdPool []string `envconfig:"user_id_pool" required:"true"` 31 | } 32 | 33 | func NewSettings() (Settings, error) { 34 | var settings Settings 35 | err := envconfig.Process("cdn", &settings) 36 | if err != nil { 37 | return Settings{}, err 38 | } 39 | return settings, nil 40 | } 41 | 42 | func Connect(settings Settings) (*gorm.DB, error) { 43 | return gorm.Open("postgres", settings.DatabaseUrl) 44 | } 45 | -------------------------------------------------------------------------------- /cmd/cdn-broker/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "math/rand" 6 | "net/http" 7 | "os" 8 | "time" 9 | 10 | "code.cloudfoundry.org/lager" 11 | "github.com/cloudfoundry-community/go-cfclient" 12 | "github.com/pivotal-cf/brokerapi" 13 | 14 | "github.com/aws/aws-sdk-go/aws" 15 | "github.com/aws/aws-sdk-go/aws/session" 16 | "github.com/aws/aws-sdk-go/service/cloudfront" 17 | "github.com/aws/aws-sdk-go/service/iam" 18 | 19 | "github.com/cloud-gov/cf-cdn-service-broker/broker" 20 | "github.com/cloud-gov/cf-cdn-service-broker/config" 21 | "github.com/cloud-gov/cf-cdn-service-broker/healthchecks" 22 | "github.com/cloud-gov/cf-cdn-service-broker/models" 23 | "github.com/cloud-gov/cf-cdn-service-broker/utils" 24 | ) 25 | 26 | func main() { 27 | logger := lager.NewLogger("cdn-service-broker") 28 | logger.RegisterSink(lager.NewWriterSink(os.Stderr, lager.INFO)) 29 | 30 | rand.Seed(time.Now().UnixNano()) 31 | 32 | settings, err := config.NewSettings() 33 | if err != nil { 34 | logger.Fatal("new-settings", err) 35 | } 36 | 37 | db, err := config.Connect(settings) 38 | if err != nil { 39 | logger.Fatal("connect", err) 40 | } 41 | 42 | cfClient, err := cfclient.NewClient(&cfclient.Config{ 43 | ApiAddress: settings.APIAddress, 44 | ClientID: settings.ClientID, 45 | ClientSecret: settings.ClientSecret, 46 | }) 47 | if err != nil { 48 | logger.Fatal("cf-client", err) 49 | } 50 | 51 | session := session.New(aws.NewConfig().WithRegion(settings.AwsDefaultRegion)) 52 | 53 | if err := db.AutoMigrate(&models.Route{}, &models.Certificate{}, &models.UserData{}).Error; err != nil { 54 | logger.Fatal("migrate", err) 55 | } 56 | 57 | manager := models.NewManager( 58 | logger, 59 | &utils.Iam{settings, iam.New(session)}, 60 | &utils.Distribution{settings, cloudfront.New(session)}, 61 | settings, 62 | db, 63 | ) 64 | broker := broker.New( 65 | &manager, 66 | cfClient, 67 | settings, 68 | logger, 69 | ) 70 | credentials := brokerapi.BrokerCredentials{ 71 | Username: settings.BrokerUsername, 72 | Password: settings.BrokerPassword, 73 | } 74 | 75 | brokerAPI := brokerapi.New(broker, logger, credentials) 76 | server := bindHTTPHandlers(brokerAPI, settings) 77 | http.ListenAndServe(fmt.Sprintf(":%s", settings.Port), server) 78 | } 79 | 80 | func bindHTTPHandlers(handler http.Handler, settings config.Settings) http.Handler { 81 | mux := http.NewServeMux() 82 | mux.Handle("/", handler) 83 | healthchecks.Bind(mux, settings) 84 | 85 | return mux 86 | } 87 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/cloud-gov/cf-cdn-service-broker 2 | 3 | go 1.25 4 | 5 | require ( 6 | code.cloudfoundry.org/lager v1.0.1-0.20180322215153-25ee72f227fe 7 | github.com/aws/aws-sdk-go v1.34.0 8 | github.com/cloudfoundry-community/go-cfclient v0.0.0-20180323021324-b5f0f59f96d6 9 | github.com/jinzhu/gorm v1.9.1 10 | github.com/kelseyhightower/envconfig v1.3.0 11 | github.com/lib/pq v0.0.0-20180325232643-a96442e255fc 12 | github.com/pivotal-cf/brokerapi v1.0.0 13 | github.com/robfig/cron v1.0.0 14 | github.com/stretchr/testify v1.7.0 15 | github.com/xenolf/lego v0.0.0-00010101000000-000000000000 16 | ) 17 | 18 | require ( 19 | code.cloudfoundry.org/gofileutils v0.0.0-20170111115228-4d0c80011a0f // indirect 20 | github.com/cloudfoundry/gofileutils v0.0.0-20170111115228-4d0c80011a0f // indirect 21 | github.com/codegangsta/inject v0.0.0-20150114235600-33e0aa1cb7c0 // indirect 22 | github.com/davecgh/go-spew v1.1.1 // indirect 23 | github.com/denisenkom/go-mssqldb v0.12.3 // indirect 24 | github.com/drewolson/testflight v1.0.0 // indirect 25 | github.com/erikstmartin/go-testdb v0.0.0-20160219214506-8d10e4a1bae5 // indirect 26 | github.com/go-martini/martini v0.0.0-20170121215854-22fa46961aab // indirect 27 | github.com/go-sql-driver/mysql v1.7.1 // indirect 28 | github.com/golang/protobuf v1.5.3 // indirect 29 | github.com/google/go-cmp v0.5.9 // indirect 30 | github.com/gorilla/context v0.0.0-20160226214623-1ea25387ff6f // indirect 31 | github.com/gorilla/mux v1.6.1 // indirect 32 | github.com/jinzhu/inflection v0.0.0-20180308033659-04140366298a // indirect 33 | github.com/jinzhu/now v1.1.5 // indirect 34 | github.com/jmespath/go-jmespath v0.3.0 // indirect 35 | github.com/martini-contrib/render v0.0.0-20150707142108-ec18f8345a11 // indirect 36 | github.com/mattn/go-sqlite3 v1.14.16 // indirect 37 | github.com/miekg/dns v1.0.4 // indirect 38 | github.com/onsi/ginkgo v1.16.5 // indirect 39 | github.com/onsi/gomega v1.27.7 // indirect 40 | github.com/oxtoacart/bpool v0.0.0-20190530202638-03653db5a59c // indirect 41 | github.com/pborman/uuid v1.2.1 // indirect 42 | github.com/pkg/errors v0.9.1 // indirect 43 | github.com/pmezard/go-difflib v1.0.0 // indirect 44 | github.com/smartystreets/goconvey v1.8.0 // indirect 45 | github.com/stretchr/objx v0.1.0 // indirect 46 | golang.org/x/crypto v0.31.0 // indirect 47 | golang.org/x/net v0.23.0 // indirect 48 | golang.org/x/oauth2 v0.0.0-20180314180239-fdc9e635145a // indirect 49 | golang.org/x/sys v0.28.0 // indirect 50 | google.golang.org/appengine v1.0.0 // indirect 51 | google.golang.org/protobuf v1.33.0 // indirect 52 | gopkg.in/square/go-jose.v1 v1.1.1 // indirect 53 | gopkg.in/yaml.v3 v3.0.1 // indirect 54 | ) 55 | 56 | replace github.com/xenolf/lego => github.com/jmcarp/lego v0.3.2-0.20170424160445-b4deb96f1082 57 | -------------------------------------------------------------------------------- /utils/certs.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "crypto" 5 | "crypto/tls" 6 | "fmt" 7 | "io/ioutil" 8 | "net" 9 | "net/http" 10 | "path" 11 | "strings" 12 | "time" 13 | 14 | "github.com/aws/aws-sdk-go/aws" 15 | "github.com/aws/aws-sdk-go/service/s3" 16 | "github.com/xenolf/lego/acme" 17 | 18 | "github.com/cloud-gov/cf-cdn-service-broker/config" 19 | ) 20 | 21 | func preCheckDNS(fqdn, value string) (bool, error) { 22 | record, err := net.LookupTXT(fqdn) 23 | if err != nil { 24 | return false, err 25 | } 26 | if len(record) == 1 && record[0] == value { 27 | return true, nil 28 | } 29 | return false, fmt.Errorf("DNS precheck failed on name %s, value %s", fqdn, value) 30 | } 31 | 32 | func init() { 33 | acme.PreCheckDNS = preCheckDNS 34 | } 35 | 36 | type User struct { 37 | Email string 38 | Registration *acme.RegistrationResource 39 | key crypto.PrivateKey 40 | } 41 | 42 | func (u *User) GetEmail() string { 43 | return u.Email 44 | } 45 | 46 | func (u *User) GetRegistration() *acme.RegistrationResource { 47 | return u.Registration 48 | } 49 | 50 | func (u *User) GetPrivateKey() crypto.PrivateKey { 51 | return u.key 52 | } 53 | 54 | func (u *User) SetPrivateKey(key crypto.PrivateKey) { 55 | u.key = key 56 | } 57 | 58 | type HTTPProvider struct { 59 | Settings config.Settings 60 | Service *s3.S3 61 | } 62 | 63 | func (p *HTTPProvider) Present(domain, token, keyAuth string) error { 64 | input := s3.PutObjectInput{ 65 | Bucket: aws.String(p.Settings.Bucket), 66 | Key: aws.String(path.Join(".well-known", "acme-challenge", token)), 67 | Body: strings.NewReader(keyAuth), 68 | } 69 | if p.Settings.ServerSideEncryption != "" { 70 | input.ServerSideEncryption = aws.String(p.Settings.ServerSideEncryption) 71 | } 72 | if _, err := p.Service.PutObject(&input); err != nil { 73 | return err 74 | } 75 | 76 | insecureClient := &http.Client{ 77 | Transport: &http.Transport{ 78 | TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, 79 | }, 80 | } 81 | 82 | return acme.WaitFor(10*time.Second, 2*time.Second, func() (bool, error) { 83 | resp, err := insecureClient.Get("https://" + path.Join(domain, ".well-known", "acme-challenge", token)) 84 | if err != nil { 85 | return false, err 86 | } 87 | defer resp.Body.Close() 88 | body, err := ioutil.ReadAll(resp.Body) 89 | if err != nil { 90 | return false, err 91 | } 92 | if string(body) == keyAuth { 93 | return true, nil 94 | } 95 | return false, fmt.Errorf("HTTP-01 token mismatch for %s: expected %s, got %s", token, keyAuth, string(body)) 96 | }) 97 | } 98 | 99 | func (p *HTTPProvider) CleanUp(domain, token, keyAuth string) error { 100 | _, err := p.Service.DeleteObject(&s3.DeleteObjectInput{ 101 | Bucket: aws.String(p.Settings.Bucket), 102 | Key: aws.String(path.Join(".well-known", "acme-challenge", token)), 103 | }) 104 | return err 105 | } 106 | 107 | type DNSProvider struct{} 108 | 109 | func (p *DNSProvider) Present(domain, token, keyAuth string) error { 110 | return nil 111 | } 112 | 113 | func (p *DNSProvider) CleanUp(domain, token, keyAuth string) error { 114 | return nil 115 | } 116 | 117 | func (p *DNSProvider) Timeout() (time.Duration, time.Duration) { 118 | return 10 * time.Second, 2 * time.Second 119 | } 120 | 121 | func NewClient(settings config.Settings, user *User, s3Service *s3.S3, excludes []acme.Challenge) (*acme.Client, error) { 122 | client, err := acme.NewClient(settings.AcmeUrl, user, acme.RSA2048) 123 | if err != nil { 124 | return &acme.Client{}, err 125 | } 126 | 127 | if user.GetRegistration() == nil { 128 | reg, err := client.Register() 129 | if err != nil { 130 | return client, err 131 | } 132 | user.Registration = reg 133 | } 134 | 135 | if err := client.AgreeToTOS(); err != nil { 136 | return client, err 137 | } 138 | 139 | client.SetChallengeProvider(acme.HTTP01, &HTTPProvider{ 140 | Settings: settings, 141 | Service: s3Service, 142 | }) 143 | client.SetChallengeProvider(acme.DNS01, &DNSProvider{}) 144 | client.ExcludeChallenges(excludes) 145 | 146 | return client, nil 147 | } 148 | -------------------------------------------------------------------------------- /broker/broker_last_operation_test.go: -------------------------------------------------------------------------------- 1 | package broker_test 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "strings" 7 | "testing" 8 | 9 | "github.com/stretchr/testify/suite" 10 | 11 | "code.cloudfoundry.org/lager" 12 | "github.com/pivotal-cf/brokerapi" 13 | 14 | "github.com/cloud-gov/cf-cdn-service-broker/broker" 15 | cfmock "github.com/cloud-gov/cf-cdn-service-broker/cf/mocks" 16 | "github.com/cloud-gov/cf-cdn-service-broker/config" 17 | "github.com/cloud-gov/cf-cdn-service-broker/models" 18 | "github.com/cloud-gov/cf-cdn-service-broker/models/mocks" 19 | ) 20 | 21 | func TestLastOperation(t *testing.T) { 22 | suite.Run(t, new(LastOperationSuite)) 23 | } 24 | 25 | type LastOperationSuite struct { 26 | suite.Suite 27 | Manager mocks.RouteManagerIface 28 | Broker *broker.CdnServiceBroker 29 | cfclient cfmock.Client 30 | settings config.Settings 31 | logger lager.Logger 32 | ctx context.Context 33 | } 34 | 35 | func (s *LastOperationSuite) SetupTest() { 36 | s.Manager = mocks.RouteManagerIface{} 37 | s.cfclient = cfmock.Client{} 38 | s.Broker = broker.New( 39 | &s.Manager, 40 | &s.cfclient, 41 | s.settings, 42 | s.logger, 43 | ) 44 | s.ctx = context.Background() 45 | } 46 | 47 | func (s *LastOperationSuite) TestLastOperationMissing() { 48 | manager := mocks.RouteManagerIface{} 49 | manager.On("Get", "").Return(&models.Route{}, errors.New("not found")) 50 | b := broker.New( 51 | &manager, 52 | &s.cfclient, 53 | s.settings, 54 | s.logger, 55 | ) 56 | 57 | operation, err := b.LastOperation(s.ctx, "", "") 58 | s.Equal(operation.State, brokerapi.Failed) 59 | s.Equal(operation.Description, "Service instance not found") 60 | s.Nil(err) 61 | } 62 | 63 | func (s *LastOperationSuite) TestLastOperationSucceeded() { 64 | manager := mocks.RouteManagerIface{} 65 | route := &models.Route{ 66 | State: models.Provisioned, 67 | DomainExternal: "cdn.cloud.gov", 68 | DomainInternal: "abc.cloudfront.net", 69 | Origin: "cdn.apps.cloud.gov", 70 | } 71 | manager.On("Get", "123").Return(route, nil) 72 | manager.On("Poll", route).Return(nil) 73 | b := broker.New( 74 | &manager, 75 | &s.cfclient, 76 | s.settings, 77 | s.logger, 78 | ) 79 | 80 | operation, err := b.LastOperation(s.ctx, "123", "") 81 | s.Equal(operation.State, brokerapi.Succeeded) 82 | s.Equal(operation.Description, "Service instance provisioned [cdn.cloud.gov => cdn.apps.cloud.gov]; CDN domain abc.cloudfront.net") 83 | s.Nil(err) 84 | } 85 | 86 | func (s *LastOperationSuite) TestLastOperationProvisioning() { 87 | manager := mocks.RouteManagerIface{} 88 | route := &models.Route{ 89 | State: models.Provisioning, 90 | DomainExternal: "cdn.cloud.gov", 91 | Origin: "cdn.apps.cloud.gov", 92 | ChallengeJSON: []byte("[]"), 93 | } 94 | manager.On("Get", "123").Return(route, nil) 95 | manager.On("GetDNSInstructions", route).Return([]string{"token"}, nil) 96 | manager.On("Poll", route).Return(nil) 97 | b := broker.New( 98 | &manager, 99 | &s.cfclient, 100 | s.settings, 101 | s.logger, 102 | ) 103 | 104 | operation, err := b.LastOperation(s.ctx, "123", "") 105 | s.Equal(operation.State, brokerapi.InProgress) 106 | s.True(strings.Contains(operation.Description, "Provisioning in progress [cdn.cloud.gov => cdn.apps.cloud.gov]")) 107 | s.Nil(err) 108 | } 109 | 110 | func (s *LastOperationSuite) TestLastOperationDeprovisioning() { 111 | manager := mocks.RouteManagerIface{} 112 | route := &models.Route{ 113 | State: models.Deprovisioning, 114 | DomainExternal: "cdn.cloud.gov", 115 | DomainInternal: "abc.cloudfront.net", 116 | Origin: "cdn.apps.cloud.gov", 117 | } 118 | manager.On("Get", "123").Return(route, nil) 119 | manager.On("Poll", route).Return(nil) 120 | b := broker.New( 121 | &manager, 122 | &s.cfclient, 123 | s.settings, 124 | s.logger, 125 | ) 126 | 127 | operation, err := b.LastOperation(s.ctx, "123", "") 128 | s.Equal(operation.State, brokerapi.InProgress) 129 | s.Equal(operation.Description, "Deprovisioning in progress [cdn.cloud.gov => cdn.apps.cloud.gov]; CDN domain abc.cloudfront.net") 130 | s.Nil(err) 131 | } 132 | -------------------------------------------------------------------------------- /models/mocks/RouteManagerIface.go: -------------------------------------------------------------------------------- 1 | package mocks 2 | 3 | import mock "github.com/stretchr/testify/mock" 4 | import models "github.com/cloud-gov/cf-cdn-service-broker/models" 5 | import utils "github.com/cloud-gov/cf-cdn-service-broker/utils" 6 | 7 | // RouteManagerIface is an autogenerated mock type for the RouteManagerIface type 8 | type RouteManagerIface struct { 9 | mock.Mock 10 | } 11 | 12 | // Create provides a mock function with given fields: instanceId, domain, origin, path, insecureOrigin, forwardedHeaders, forwardCookies, tags 13 | func (_m *RouteManagerIface) Create(instanceId string, domain string, origin string, path string, insecureOrigin bool, forwardedHeaders utils.Headers, forwardCookies bool, tags map[string]string) (*models.Route, error) { 14 | ret := _m.Called(instanceId, domain, origin, path, insecureOrigin, forwardedHeaders, forwardCookies, tags) 15 | 16 | var r0 *models.Route 17 | if rf, ok := ret.Get(0).(func(string, string, string, string, bool, utils.Headers, bool, map[string]string) *models.Route); ok { 18 | r0 = rf(instanceId, domain, origin, path, insecureOrigin, forwardedHeaders, forwardCookies, tags) 19 | } else { 20 | if ret.Get(0) != nil { 21 | r0 = ret.Get(0).(*models.Route) 22 | } 23 | } 24 | 25 | var r1 error 26 | if rf, ok := ret.Get(1).(func(string, string, string, string, bool, utils.Headers, bool, map[string]string) error); ok { 27 | r1 = rf(instanceId, domain, origin, path, insecureOrigin, forwardedHeaders, forwardCookies, tags) 28 | } else { 29 | r1 = ret.Error(1) 30 | } 31 | 32 | return r0, r1 33 | } 34 | 35 | // DeleteOrphanedCerts provides a mock function with given fields: 36 | func (_m *RouteManagerIface) DeleteOrphanedCerts() { 37 | _m.Called() 38 | } 39 | 40 | // Disable provides a mock function with given fields: route 41 | func (_m *RouteManagerIface) Disable(route *models.Route) error { 42 | ret := _m.Called(route) 43 | 44 | var r0 error 45 | if rf, ok := ret.Get(0).(func(*models.Route) error); ok { 46 | r0 = rf(route) 47 | } else { 48 | r0 = ret.Error(0) 49 | } 50 | 51 | return r0 52 | } 53 | 54 | // Get provides a mock function with given fields: instanceId 55 | func (_m *RouteManagerIface) Get(instanceId string) (*models.Route, error) { 56 | ret := _m.Called(instanceId) 57 | 58 | var r0 *models.Route 59 | if rf, ok := ret.Get(0).(func(string) *models.Route); ok { 60 | r0 = rf(instanceId) 61 | } else { 62 | if ret.Get(0) != nil { 63 | r0 = ret.Get(0).(*models.Route) 64 | } 65 | } 66 | 67 | var r1 error 68 | if rf, ok := ret.Get(1).(func(string) error); ok { 69 | r1 = rf(instanceId) 70 | } else { 71 | r1 = ret.Error(1) 72 | } 73 | 74 | return r0, r1 75 | } 76 | 77 | // GetDNSInstructions provides a mock function with given fields: route 78 | func (_m *RouteManagerIface) GetDNSInstructions(route *models.Route) ([]string, error) { 79 | ret := _m.Called(route) 80 | 81 | var r0 []string 82 | if rf, ok := ret.Get(0).(func(*models.Route) []string); ok { 83 | r0 = rf(route) 84 | } else { 85 | if ret.Get(0) != nil { 86 | r0 = ret.Get(0).([]string) 87 | } 88 | } 89 | 90 | var r1 error 91 | if rf, ok := ret.Get(1).(func(*models.Route) error); ok { 92 | r1 = rf(route) 93 | } else { 94 | r1 = ret.Error(1) 95 | } 96 | 97 | return r0, r1 98 | } 99 | 100 | // Poll provides a mock function with given fields: route 101 | func (_m *RouteManagerIface) Poll(route *models.Route) error { 102 | ret := _m.Called(route) 103 | 104 | var r0 error 105 | if rf, ok := ret.Get(0).(func(*models.Route) error); ok { 106 | r0 = rf(route) 107 | } else { 108 | r0 = ret.Error(0) 109 | } 110 | 111 | return r0 112 | } 113 | 114 | // Renew provides a mock function with given fields: route 115 | func (_m *RouteManagerIface) Renew(route *models.Route) error { 116 | ret := _m.Called(route) 117 | 118 | var r0 error 119 | if rf, ok := ret.Get(0).(func(*models.Route) error); ok { 120 | r0 = rf(route) 121 | } else { 122 | r0 = ret.Error(0) 123 | } 124 | 125 | return r0 126 | } 127 | 128 | // RenewAll provides a mock function with given fields: 129 | func (_m *RouteManagerIface) RenewAll() { 130 | _m.Called() 131 | } 132 | 133 | // Update provides a mock function with given fields: instanceId, domain, origin, path, insecureOrigin, forwardedHeaders, forwardCookies 134 | func (_m *RouteManagerIface) Update(instanceId string, domain string, origin string, path string, insecureOrigin bool, forwardedHeaders utils.Headers, forwardCookies bool) error { 135 | ret := _m.Called(instanceId, domain, origin, path, insecureOrigin, forwardedHeaders, forwardCookies) 136 | 137 | var r0 error 138 | if rf, ok := ret.Get(0).(func(string, string, string, string, bool, utils.Headers, bool) error); ok { 139 | r0 = rf(instanceId, domain, origin, path, insecureOrigin, forwardedHeaders, forwardCookies) 140 | } else { 141 | r0 = ret.Error(0) 142 | } 143 | 144 | return r0 145 | } 146 | 147 | var _ models.RouteManagerIface = (*RouteManagerIface)(nil) 148 | -------------------------------------------------------------------------------- /models/models_test.go: -------------------------------------------------------------------------------- 1 | package models_test 2 | 3 | import ( 4 | "os" 5 | "testing" 6 | "time" 7 | 8 | "code.cloudfoundry.org/lager" 9 | "github.com/jinzhu/gorm" 10 | "github.com/xenolf/lego/acme" 11 | 12 | "github.com/aws/aws-sdk-go/aws" 13 | "github.com/aws/aws-sdk-go/aws/request" 14 | "github.com/aws/aws-sdk-go/aws/session" 15 | "github.com/aws/aws-sdk-go/service/cloudfront" 16 | "github.com/aws/aws-sdk-go/service/iam" 17 | 18 | "github.com/cloud-gov/cf-cdn-service-broker/config" 19 | "github.com/cloud-gov/cf-cdn-service-broker/models" 20 | "github.com/cloud-gov/cf-cdn-service-broker/utils" 21 | 22 | "github.com/stretchr/testify/mock" 23 | ) 24 | 25 | type MockUtilsIam struct { 26 | mock.Mock 27 | 28 | Settings config.Settings 29 | Service *iam.IAM 30 | } 31 | 32 | // test doesn't execute this method 33 | func (_f MockUtilsIam) UploadCertificate(name string, cert acme.CertificateResource) (string, error) { 34 | return "", nil 35 | } 36 | 37 | // don't mock this method 38 | func (_f MockUtilsIam) ListCertificates(callback func(iam.ServerCertificateMetadata) bool) error { 39 | orig := &utils.Iam{Settings: _f.Settings, Service: _f.Service} 40 | return orig.ListCertificates(callback) 41 | } 42 | 43 | func (_f MockUtilsIam) DeleteCertificate(certName string) error { 44 | args := _f.Called(certName) 45 | return args.Error(0) 46 | } 47 | 48 | func TestDeleteOrphanedCerts(t *testing.T) { 49 | logger := lager.NewLogger("cdn-cron-test") 50 | logger.RegisterSink(lager.NewWriterSink(os.Stderr, lager.INFO)) 51 | 52 | settings, _ := config.NewSettings() 53 | session := session.New(nil) 54 | 55 | //mock out the aws call to return a fixed list of certs, two of which should be deleted 56 | fakeiam := iam.New(session) 57 | fakeiam.Handlers.Clear() 58 | fakeiam.Handlers.Send.PushBack(func(r *request.Request) { 59 | //t.Log(r.Operation.Name) 60 | switch r.Operation.Name { 61 | case "ListServerCertificates": 62 | old := time.Now().AddDate(0, 0, -2) 63 | current := time.Now().AddDate(0, 0, 0) 64 | 65 | list := []*iam.ServerCertificateMetadata{ 66 | &iam.ServerCertificateMetadata{ 67 | Arn: aws.String("an-active-certificate"), 68 | ServerCertificateName: aws.String("an-active-certificate"), 69 | ServerCertificateId: aws.String("an-active-certificate"), 70 | UploadDate: &old, 71 | }, 72 | &iam.ServerCertificateMetadata{ 73 | Arn: aws.String("some-other-active-certificate"), 74 | ServerCertificateName: aws.String("some-other-active-certificate"), 75 | ServerCertificateId: aws.String("some-other-active-certificate"), 76 | UploadDate: &old, 77 | }, 78 | &iam.ServerCertificateMetadata{ 79 | Arn: aws.String("orphaned-but-not-old-enough"), 80 | ServerCertificateName: aws.String("orphaned-but-not-old-enough"), 81 | ServerCertificateId: aws.String("this-cert-should-not-be-deleted"), 82 | UploadDate: ¤t, 83 | }, 84 | &iam.ServerCertificateMetadata{ 85 | Arn: aws.String("some-orphaned-cert"), 86 | ServerCertificateName: aws.String("some-orphaned-cert"), 87 | ServerCertificateId: aws.String("this-cert-should-be-deleted"), 88 | UploadDate: &old, 89 | }, 90 | &iam.ServerCertificateMetadata{ 91 | Arn: aws.String("some-other-orphaned-cert"), 92 | ServerCertificateName: aws.String("some-other-orphaned-cert"), 93 | ServerCertificateId: aws.String("this-cert-should-also-be-deleted"), 94 | UploadDate: &old, 95 | }, 96 | } 97 | data := r.Data.(*iam.ListServerCertificatesOutput) 98 | data.IsTruncated = aws.Bool(false) 99 | data.ServerCertificateMetadataList = list 100 | } 101 | }) 102 | 103 | //mock out the aws call to return a fixed list of distributions 104 | fakecf := cloudfront.New(session) 105 | fakecf.Handlers.Clear() 106 | fakecf.Handlers.Send.PushBack(func(r *request.Request) { 107 | switch r.Operation.Name { 108 | case "ListDistributions2020_05_31": 109 | list := []*cloudfront.DistributionSummary{ 110 | &cloudfront.DistributionSummary{ 111 | ARN: aws.String("some-distribution"), 112 | ViewerCertificate: &cloudfront.ViewerCertificate{ 113 | IAMCertificateId: aws.String("an-active-certificate"), 114 | }, 115 | }, 116 | &cloudfront.DistributionSummary{ 117 | ARN: aws.String("some-other-distribution"), 118 | ViewerCertificate: &cloudfront.ViewerCertificate{ 119 | IAMCertificateId: aws.String("some-other-active-certificate"), 120 | }, 121 | }, 122 | } 123 | 124 | data := r.Data.(*cloudfront.ListDistributionsOutput) 125 | data.DistributionList = &cloudfront.DistributionList{ 126 | IsTruncated: aws.Bool(false), 127 | Items: list, 128 | } 129 | } 130 | }) 131 | 132 | mui := new(MockUtilsIam) 133 | mui.Settings = settings 134 | mui.Service = fakeiam 135 | 136 | // expect the orphaned certs to be deleted 137 | mui.On("DeleteCertificate", "some-orphaned-cert").Return(nil) 138 | mui.On("DeleteCertificate", "some-other-orphaned-cert").Return(nil) 139 | 140 | m := models.NewManager( 141 | logger, 142 | mui, 143 | &utils.Distribution{settings, fakecf}, 144 | settings, 145 | &gorm.DB{}, 146 | ) 147 | 148 | //run the test 149 | m.DeleteOrphanedCerts() 150 | 151 | //check our expectations 152 | mui.AssertExpectations(t) 153 | 154 | } 155 | -------------------------------------------------------------------------------- /ci/acceptance-tests.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -eux 4 | 5 | # Set defaults 6 | TTL="${TTL:-60}" 7 | CDN_TIMEOUT="${CDN_TIMEOUT:-7200}" 8 | 9 | suffix="${RANDOM}" 10 | DOMAIN=$(printf "${DOMAIN}" "${suffix}") 11 | SERVICE_INSTANCE_NAME=$(printf "${SERVICE_INSTANCE_NAME}" "${suffix}") 12 | 13 | curl_args="" 14 | if [ -n "${CA_CERT:-}" ]; then 15 | echo "${CA_CERT}" > ca.pem 16 | curl_args="--cacert ca.pem" 17 | fi 18 | 19 | path="$(dirname $0)" 20 | 21 | # Authenticate 22 | cf api "${CF_API_URL}" 23 | (set +x; cf auth "${CF_USERNAME}" "${CF_PASSWORD}") 24 | 25 | # Target 26 | cf target -o "${CF_ORGANIZATION}" -s "${CF_SPACE}" 27 | 28 | # Create private domain 29 | cf create-domain "${CF_ORGANIZATION}" "${DOMAIN}" 30 | 31 | # sleep a little to let the domain create 32 | sleep 5 33 | 34 | # Create service 35 | opts=$(jq -n --arg domain "${DOMAIN}" '{domain: $domain}') 36 | cf create-service "${SERVICE_NAME}" "${PLAN_NAME}" "${SERVICE_INSTANCE_NAME}" -c "${opts}" 37 | service_guid=$(cf service "${SERVICE_INSTANCE_NAME}" --guid) 38 | 39 | http_regex="CNAME or ALIAS domain (.*) to (.*) or" 40 | dns_regex="name: (.*), value: (.*), ttl: (.*)" 41 | 42 | elapsed=300 43 | until [ "${elapsed}" -le 0 ]; do 44 | status=$(cf curl "/v3/service_instances/${service_guid}") 45 | description=$(echo "${status}" | jq -r '.last_operation.description') 46 | if [[ "${description}" =~ ${http_regex} ]]; then 47 | domain_external="${BASH_REMATCH[1]}" 48 | domain_internal="${BASH_REMATCH[2]}" 49 | fi 50 | if [[ "${description}" =~ ${dns_regex} ]]; then 51 | txt_name="${BASH_REMATCH[1]}" 52 | txt_value="${BASH_REMATCH[2]}" 53 | txt_ttl="${BASH_REMATCH[3]}" 54 | fi 55 | if [ -n "${domain_external:-}" ] && [ -n "${txt_name:-}" ]; then 56 | break 57 | fi 58 | let elapsed-=5 59 | sleep 5 60 | done 61 | if [ -z "${domain_internal:-}" ] || [ -z "${txt_name:-}" ]; then 62 | echo "Failed to parse description: ${description}" 63 | exit 1 64 | fi 65 | 66 | # Create DNS record(s) 67 | cat << EOF > ./create-cname.json 68 | { 69 | "Changes": [ 70 | { 71 | "Action": "CREATE", 72 | "ResourceRecordSet": { 73 | "Name": "${domain_external}.", 74 | "Type": "CNAME", 75 | "TTL": ${TTL}, 76 | "ResourceRecords": [ 77 | {"Value": "${domain_internal}"} 78 | ] 79 | } 80 | } 81 | ] 82 | } 83 | EOF 84 | 85 | if [ "${CHALLENGE_TYPE}" = "DNS-01" ]; then 86 | cat << EOF > ./create-txt.json 87 | { 88 | "Changes": [ 89 | { 90 | "Action": "CREATE", 91 | "ResourceRecordSet": { 92 | "Name": "${txt_name}", 93 | "Type": "TXT", 94 | "TTL": ${txt_ttl}, 95 | "ResourceRecords": [ 96 | {"Value": "\"${txt_value}\""} 97 | ] 98 | } 99 | } 100 | ] 101 | } 102 | EOF 103 | fi 104 | 105 | if [ "${CHALLENGE_TYPE}" = "HTTP-01" ]; then 106 | aws route53 change-resource-record-sets \ 107 | --hosted-zone-id "${HOSTED_ZONE_ID}" \ 108 | --change-batch file://./create-cname.json 109 | elif [ "${CHALLENGE_TYPE}" = "DNS-01" ]; then 110 | aws route53 change-resource-record-sets \ 111 | --hosted-zone-id "${HOSTED_ZONE_ID}" \ 112 | --change-batch file://./create-txt.json 113 | fi 114 | 115 | # Wait for provision to complete 116 | elapsed="${CDN_TIMEOUT}" 117 | until [ "${elapsed}" -le 0 ]; do 118 | status=$(cf curl "/v3/service_instances/${service_guid}") 119 | state=$(echo "${status}" | jq -r '.last_operation.state') 120 | if [[ "${state}" == "succeeded" ]]; then 121 | updated="true" 122 | break 123 | elif [[ "${state}" == "failed" ]]; then 124 | echo "Failed to create service" 125 | exit 1 126 | fi 127 | let elapsed-=60 128 | sleep 60 129 | done 130 | if [ "${updated}" != "true" ]; then 131 | echo "Failed to update service ${SERVICE_NAME}" 132 | exit 1 133 | fi 134 | 135 | # Create CNAME after provisioning if using DNS-01 challenge 136 | if [ "${CHALLENGE_TYPE}" = "DNS-01" ]; then 137 | aws route53 change-resource-record-sets \ 138 | --hosted-zone-id "${HOSTED_ZONE_ID}" \ 139 | --change-batch file://./create-cname.json 140 | fi 141 | 142 | # Push test app 143 | cat << EOF > "${path}/app/manifest.yml" 144 | --- 145 | applications: 146 | - name: cdn-broker-test-${CHALLENGE_TYPE} 147 | buildpack: staticfile_buildpack 148 | domain: ${DOMAIN} 149 | no-hostname: true 150 | EOF 151 | 152 | cf push -f "${path}/app/manifest.yml" -p "${path}/app" 153 | 154 | # Assert expected response from cdn 155 | elapsed="${CDN_TIMEOUT}" 156 | until [ "${elapsed}" -le 0 ]; do 157 | if curl ${curl_args} "https://${DOMAIN}" | grep "CDN Broker Test"; then 158 | break 159 | fi 160 | let elapsed-=60 161 | sleep 60 162 | done 163 | if [ -z "${elapsed}" ]; then 164 | echo "Failed to load ${DOMAIN}" 165 | exit 1 166 | fi 167 | 168 | # Delete private domain 169 | cf delete-domain -f "${DOMAIN}" 170 | 171 | # Delete DNS record(s) 172 | cat << EOF > ./delete-cname.json 173 | { 174 | "Changes": [ 175 | { 176 | "Action": "DELETE", 177 | "ResourceRecordSet": { 178 | "Name": "${domain_external}.", 179 | "Type": "CNAME", 180 | "TTL": ${TTL}, 181 | "ResourceRecords": [ 182 | {"Value": "${domain_internal}"} 183 | ] 184 | } 185 | } 186 | ] 187 | } 188 | EOF 189 | if [ "${CHALLENGE_TYPE}" = "DNS-01" ]; then 190 | cat << EOF > ./delete-txt.json 191 | { 192 | "Changes": [ 193 | { 194 | "Action": "DELETE", 195 | "ResourceRecordSet": { 196 | "Name": "${txt_name}.", 197 | "Type": "TXT", 198 | "TTL": ${txt_ttl}, 199 | "ResourceRecords": [ 200 | {"Value": "${txt_value}"} 201 | ] 202 | } 203 | } 204 | ] 205 | } 206 | EOF 207 | 208 | aws route53 change-resource-record-sets \ 209 | --hosted-zone-id "${HOSTED_ZONE_ID}" \ 210 | --change-batch file://./delete-cname.json 211 | elif [ "${CHALLENGE_TYPE}" = "DNS-01" ]; then 212 | aws route53 change-resource-record-sets \ 213 | --hosted-zone-id "${HOSTED_ZONE_ID}" \ 214 | --change-batch file://./delete-txt.json 215 | fi 216 | 217 | # Delete service 218 | cf delete-service -f "${SERVICE_INSTANCE_NAME}" 219 | 220 | # Wait for deprovision to complete 221 | elapsed="${CDN_TIMEOUT}" 222 | until [ "${elapsed}" -le 0 ]; do 223 | if ! cf service "${SERVICE_INSTANCE_NAME}"; then 224 | deleted="true" 225 | break 226 | fi 227 | let elapsed-=60 228 | sleep 60 229 | done 230 | if [ "${deleted}" != "true" ]; then 231 | echo "Failed to delete service ${SERVICE_NAME}" 232 | exit 1 233 | fi 234 | -------------------------------------------------------------------------------- /ci/pipeline.yml: -------------------------------------------------------------------------------- 1 | --- 2 | meta: 3 | cf-creds-staging: &cf-creds-staging 4 | CF_API_URL: ((cf-api-url-staging)) 5 | CF_USERNAME: ((cf-deploy-username-staging)) 6 | CF_PASSWORD: ((cf-deploy-password-staging)) 7 | CF_ORGANIZATION: ((cf-organization-staging)) 8 | CF_SPACE: ((cf-space-staging)) 9 | cf-creds-production: &cf-creds-production 10 | CF_API_URL: ((cf-api-url-production)) 11 | CF_USERNAME: ((cf-deploy-username-production)) 12 | CF_PASSWORD: ((cf-deploy-password-production)) 13 | CF_ORGANIZATION: ((cf-organization-production)) 14 | CF_SPACE: ((cf-space-production)) 15 | 16 | jobs: 17 | - name: set-self 18 | plan: 19 | - get: broker-src 20 | trigger: true 21 | - set_pipeline: self 22 | file: broker-src/ci/pipeline.yml 23 | 24 | - name: test-cdn-broker 25 | plan: 26 | - get: broker-src 27 | trigger: true 28 | passed: [set-self] 29 | - task: run-tests 30 | file: broker-src/ci/run-tests.yml 31 | 32 | - name: push-cf-cdn-service-broker-staging 33 | plan: 34 | - in_parallel: 35 | - get: broker-src 36 | passed: [test-cdn-broker] 37 | trigger: true 38 | - get: pipeline-tasks 39 | - task: create-db 40 | file: broker-src/ci/create-db.yml 41 | params: 42 | # Note: Name must match service name in manifest 43 | SERVICE_TYPE: aws-rds 44 | SERVICE_NAME: rds-cdn-broker 45 | SERVICE_PLAN: shared-psql 46 | <<: *cf-creds-staging 47 | - in_parallel: 48 | - put: broker-deploy-staging 49 | params: 50 | path: broker-src 51 | manifest: broker-src/manifest-broker.yml 52 | environment_variables: &cfenv-staging 53 | BROKER_USERNAME: ((cdn-broker-user-staging)) 54 | BROKER_PASSWORD: ((cdn-broker-pass-staging)) 55 | EMAIL: ((cdn-broker-email-staging)) 56 | ACME_URL: ((cdn-broker-acme-url-staging)) 57 | BUCKET: ((cdn-broker-bucket-staging)) 58 | IAM_PATH_PREFIX: ((cdn-broker-iam-path-prefix-staging)) 59 | AWS_ACCESS_KEY_ID: ((cdn-broker-access-key-id-staging)) 60 | AWS_SECRET_ACCESS_KEY: ((cdn-broker-secret-access-key-staging)) 61 | AWS_DEFAULT_REGION: ((cdn-broker-region-staging)) 62 | SERVER_SIDE_ENCRYPTION: AES256 63 | API_ADDRESS: ((cf-api-url-staging)) 64 | CLIENT_ID: ((cdn-broker-client-id-staging)) 65 | CLIENT_SECRET: ((cdn-broker-client-secret-staging)) 66 | DEFAULT_ORIGIN: ((cdn-broker-default-origin-staging)) 67 | USER_ID_POOL: ((cdn-broker-user-id-pool-staging)) 68 | - put: broker-deploy-staging 69 | params: 70 | path: broker-src 71 | manifest: broker-src/manifest-cron.yml 72 | environment_variables: *cfenv-staging 73 | on_failure: 74 | put: slack 75 | params: 76 | text: | 77 | :x: FAILED to deploy cf-cdn-service-broker on "((cf-api-url-staging))" 78 | <$ATC_EXTERNAL_URL/teams/$BUILD_TEAM_NAME/pipelines/$BUILD_PIPELINE_NAME/jobs/$BUILD_JOB_NAME/builds/$BUILD_NAME|View build details> 79 | channel: ((slack-channel)) 80 | username: ((slack-username)) 81 | icon_url: ((slack-icon-url)) 82 | 83 | - name: push-cf-cdn-service-broker-production 84 | plan: 85 | - in_parallel: 86 | - get: broker-src 87 | passed: [push-cf-cdn-service-broker-staging] 88 | trigger: true 89 | - get: pipeline-tasks 90 | - task: create-db 91 | file: broker-src/ci/create-db.yml 92 | params: 93 | # Note: Name must match service name in manifest 94 | SERVICE_TYPE: aws-rds 95 | SERVICE_NAME: rds-cdn-broker 96 | SERVICE_PLAN: shared-psql 97 | <<: *cf-creds-production 98 | - in_parallel: 99 | - put: broker-deploy-production 100 | params: 101 | path: broker-src 102 | manifest: broker-src/manifest-broker.yml 103 | environment_variables: &cfenv-production 104 | BROKER_USERNAME: ((cdn-broker-user-production)) 105 | BROKER_PASSWORD: ((cdn-broker-pass-production)) 106 | EMAIL: ((cdn-broker-email-production)) 107 | ACME_URL: ((cdn-broker-acme-url-production)) 108 | BUCKET: ((cdn-broker-bucket-production)) 109 | IAM_PATH_PREFIX: ((cdn-broker-iam-path-prefix-production)) 110 | AWS_ACCESS_KEY_ID: ((cdn-broker-access-key-id-production)) 111 | AWS_SECRET_ACCESS_KEY: ((cdn-broker-secret-access-key-production)) 112 | AWS_DEFAULT_REGION: ((cdn-broker-region-production)) 113 | SERVER_SIDE_ENCRYPTION: AES256 114 | API_ADDRESS: ((cf-api-url-production)) 115 | CLIENT_ID: ((cdn-broker-client-id-production)) 116 | CLIENT_SECRET: ((cdn-broker-client-secret-production)) 117 | DEFAULT_ORIGIN: ((cdn-broker-default-origin-production)) 118 | USER_ID_POOL: ((cdn-broker-user-id-pool-production)) 119 | - put: broker-deploy-production 120 | params: 121 | path: broker-src 122 | manifest: broker-src/manifest-cron.yml 123 | environment_variables: *cfenv-production 124 | on_failure: 125 | put: slack 126 | params: 127 | text: | 128 | :x: FAILED to deploy cf-cdn-service-broker on "((cf-api-url-production))" 129 | <$ATC_EXTERNAL_URL/teams/$BUILD_TEAM_NAME/pipelines/$BUILD_PIPELINE_NAME/jobs/$BUILD_JOB_NAME/builds/$BUILD_NAME|View build details> 130 | channel: ((slack-failure-channel)) 131 | username: ((slack-username)) 132 | icon_url: ((slack-icon-url)) 133 | 134 | resources: 135 | - name: broker-src 136 | type: git 137 | source: 138 | uri: https://github.com/cloud-gov/cf-cdn-service-broker.git 139 | branch: main 140 | commit_verification_keys: ((cloud-gov-pgp-keys)) 141 | 142 | - name: pipeline-tasks 143 | type: git 144 | source: 145 | uri: https://github.com/cloud-gov/cg-pipeline-tasks 146 | branch: main 147 | commit_verification_keys: ((cloud-gov-pgp-keys)) 148 | 149 | - name: broker-deploy-staging 150 | type: cf 151 | source: 152 | api: ((cf-api-url-staging)) 153 | username: ((cf-deploy-username-staging)) 154 | password: ((cf-deploy-password-staging)) 155 | organization: ((cf-organization-staging)) 156 | space: ((cf-space-staging)) 157 | 158 | - name: broker-deploy-production 159 | type: cf 160 | source: 161 | api: ((cf-api-url-production)) 162 | username: ((cf-deploy-username-production)) 163 | password: ((cf-deploy-password-production)) 164 | organization: ((cf-organization-production)) 165 | space: ((cf-space-production)) 166 | 167 | - name: slack 168 | type: slack-notification 169 | source: 170 | url: ((slack-webhook-url)) 171 | 172 | resource_types: 173 | - name: slack-notification 174 | type: registry-image 175 | source: 176 | aws_access_key_id: ((ecr_aws_key)) 177 | aws_secret_access_key: ((ecr_aws_secret)) 178 | repository: slack-notification-resource 179 | aws_region: us-gov-west-1 180 | tag: latest 181 | 182 | 183 | - name: git 184 | type: registry-image 185 | source: 186 | aws_access_key_id: ((ecr_aws_key)) 187 | aws_secret_access_key: ((ecr_aws_secret)) 188 | repository: git-resource 189 | aws_region: us-gov-west-1 190 | tag: latest 191 | 192 | - name: cf 193 | type: registry-image 194 | source: 195 | aws_access_key_id: ((ecr_aws_key)) 196 | aws_secret_access_key: ((ecr_aws_secret)) 197 | repository: cf-resource 198 | aws_region: us-gov-west-1 199 | tag: latest 200 | 201 | - name: registry-image 202 | type: registry-image 203 | source: 204 | aws_access_key_id: ((ecr_aws_key)) 205 | aws_secret_access_key: ((ecr_aws_secret)) 206 | repository: registry-image-resource 207 | aws_region: us-gov-west-1 208 | tag: latest 209 | -------------------------------------------------------------------------------- /broker/broker_update_test.go: -------------------------------------------------------------------------------- 1 | package broker_test 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "errors" 7 | "testing" 8 | 9 | "github.com/stretchr/testify/suite" 10 | 11 | "code.cloudfoundry.org/lager" 12 | "github.com/cloudfoundry-community/go-cfclient" 13 | "github.com/pivotal-cf/brokerapi" 14 | 15 | "github.com/cloud-gov/cf-cdn-service-broker/broker" 16 | cfmock "github.com/cloud-gov/cf-cdn-service-broker/cf/mocks" 17 | "github.com/cloud-gov/cf-cdn-service-broker/config" 18 | "github.com/cloud-gov/cf-cdn-service-broker/models/mocks" 19 | "github.com/cloud-gov/cf-cdn-service-broker/utils" 20 | ) 21 | 22 | func TestUpdating(t *testing.T) { 23 | suite.Run(t, new(UpdateSuite)) 24 | } 25 | 26 | type UpdateSuite struct { 27 | suite.Suite 28 | Manager mocks.RouteManagerIface 29 | Broker *broker.CdnServiceBroker 30 | cfclient cfmock.Client 31 | settings config.Settings 32 | logger lager.Logger 33 | ctx context.Context 34 | } 35 | 36 | func (s *UpdateSuite) SetupTest() { 37 | s.Manager = mocks.RouteManagerIface{} 38 | s.cfclient = cfmock.Client{} 39 | s.logger = lager.NewLogger("broker.provision.test") 40 | s.settings = config.Settings{ 41 | DefaultOrigin: "origin.cloud.gov", 42 | } 43 | s.Broker = broker.New( 44 | &s.Manager, 45 | &s.cfclient, 46 | s.settings, 47 | s.logger, 48 | ) 49 | s.ctx = context.Background() 50 | } 51 | 52 | func (s *UpdateSuite) TestUpdateWithoutOptions() { 53 | details := brokerapi.UpdateDetails{ 54 | RawParameters: json.RawMessage(`{"origin": ""}`), 55 | } 56 | _, err := s.Broker.Update(s.ctx, "", details, true) 57 | s.NotNil(err) 58 | s.Equal(err.Error(), "must pass non-empty `domain` or `origin`") 59 | } 60 | 61 | func (s *UpdateSuite) TestUpdateSuccessOnlyDomain() { 62 | details := brokerapi.UpdateDetails{ 63 | RawParameters: json.RawMessage(`{"domain": "domain.gov"}`), 64 | } 65 | s.Manager.On("Update", "", "domain.gov", "origin.cloud.gov", "", false, utils.Headers{"Host": true}, true).Return(nil) 66 | s.cfclient.On("GetDomainByName", "domain.gov").Return(cfclient.Domain{}, nil) 67 | _, err := s.Broker.Update(s.ctx, "", details, true) 68 | s.Nil(err) 69 | } 70 | 71 | func (s *UpdateSuite) TestUpdateSuccessOnlyOrigin() { 72 | details := brokerapi.UpdateDetails{ 73 | RawParameters: json.RawMessage(`{"origin": "origin.gov"}`), 74 | } 75 | s.Manager.On("Update", "", "", "origin.gov", "", false, utils.Headers{}, true).Return(nil) 76 | s.cfclient.On("GetDomainByName", "domain.gov").Return(cfclient.Domain{}, nil) 77 | _, err := s.Broker.Update(s.ctx, "", details, true) 78 | s.Nil(err) 79 | } 80 | 81 | func (s *UpdateSuite) TestUpdateSuccess() { 82 | details := brokerapi.UpdateDetails{ 83 | RawParameters: json.RawMessage(`{ 84 | "insecure_origin": true, 85 | "domain": "domain.gov", 86 | "path": "." 87 | }`), 88 | } 89 | s.Manager.On("Update", "", "domain.gov", "origin.cloud.gov", ".", true, utils.Headers{"Host": true}, true).Return(nil) 90 | s.cfclient.On("GetDomainByName", "domain.gov").Return(cfclient.Domain{}, nil) 91 | _, err := s.Broker.Update(s.ctx, "", details, true) 92 | s.Nil(err) 93 | } 94 | 95 | func (s *UpdateSuite) TestDomainNotExists() { 96 | details := brokerapi.UpdateDetails{ 97 | PreviousValues: brokerapi.PreviousValues{ 98 | OrgID: "dfb39134-ab7d-489e-ae59-4ed5c6f42fb5", 99 | }, 100 | RawParameters: json.RawMessage(`{"domain": "domain.gov"}`), 101 | } 102 | s.Manager.On("Update", "", "domain.gov", "origin.cloud.gov", ".", true, utils.Headers{"Host": true}, true).Return(nil) 103 | s.cfclient.On("GetOrgByGuid", "dfb39134-ab7d-489e-ae59-4ed5c6f42fb5").Return(cfclient.Org{Name: "my-org"}, nil) 104 | s.cfclient.On("GetDomainByName", "domain.gov").Return(cfclient.Domain{}, errors.New("bad")) 105 | _, err := s.Broker.Update(s.ctx, "", details, true) 106 | s.NotNil(err) 107 | s.Contains(err.Error(), "cf create-domain") 108 | } 109 | 110 | func (s *UpdateSuite) setupTestOfHeaderForwarding() { 111 | s.cfclient.On("GetDomainByName", "domain.gov").Return(cfclient.Domain{}, nil) 112 | } 113 | 114 | func (s *UpdateSuite) allowUpdateWithExpectedHeaders(expectedHeaders utils.Headers) { 115 | s.Manager.On("Update", "", "domain.gov", "origin.cloud.gov", ".", true, expectedHeaders, true).Return(nil) 116 | } 117 | 118 | func (s *UpdateSuite) failOnUpdateWithExpectedHeaders(expectedHeaders utils.Headers) { 119 | s.Manager.On("Update", "", "domain.gov", "origin.cloud.gov", ".", true, expectedHeaders, true).Return(errors.New("fail")) 120 | } 121 | 122 | func (s *UpdateSuite) TestSuccessForwardingDuplicatedHostHeader() { 123 | s.setupTestOfHeaderForwarding() 124 | s.allowUpdateWithExpectedHeaders(utils.Headers{"Host": true}) 125 | 126 | details := brokerapi.UpdateDetails{ 127 | RawParameters: json.RawMessage(`{ 128 | "insecure_origin": true, 129 | "domain": "domain.gov", 130 | "path": ".", 131 | "headers": ["Host"] 132 | }`), 133 | } 134 | _, err := s.Broker.Update(s.ctx, "", details, true) 135 | s.Nil(err) 136 | } 137 | 138 | func (s *UpdateSuite) TestSuccessForwardedSingleHeader() { 139 | s.setupTestOfHeaderForwarding() 140 | s.allowUpdateWithExpectedHeaders(utils.Headers{"User-Agent": true, "Host": true}) 141 | 142 | details := brokerapi.UpdateDetails{ 143 | RawParameters: json.RawMessage(`{ 144 | "insecure_origin": true, 145 | "domain": "domain.gov", 146 | "path": ".", 147 | "headers": ["User-Agent"] 148 | }`), 149 | } 150 | _, err := s.Broker.Update(s.ctx, "", details, true) 151 | s.Nil(err) 152 | } 153 | 154 | func (s *UpdateSuite) TestSuccessForwardingWildcardHeader() { 155 | s.setupTestOfHeaderForwarding() 156 | s.allowUpdateWithExpectedHeaders(utils.Headers{"*": true}) 157 | 158 | details := brokerapi.UpdateDetails{ 159 | RawParameters: json.RawMessage(`{ 160 | "insecure_origin": true, 161 | "domain": "domain.gov", 162 | "path": ".", 163 | "headers": ["*"] 164 | }`), 165 | } 166 | _, err := s.Broker.Update(s.ctx, "", details, true) 167 | s.Nil(err) 168 | } 169 | 170 | func (s *UpdateSuite) TestSuccessNineForwardedHeaders() { 171 | s.setupTestOfHeaderForwarding() 172 | s.allowUpdateWithExpectedHeaders(utils.Headers{"One": true, "Two": true, "Three": true, "Four": true, "Five": true, "Six": true, "Seven": true, "Eight": true, "Nine": true, "Host": true}) 173 | 174 | details := brokerapi.UpdateDetails{ 175 | RawParameters: json.RawMessage(`{ 176 | "insecure_origin": true, 177 | "domain": "domain.gov", 178 | "path": ".", 179 | "headers": ["One", "Two", "Three", "Four", "Five", "Six", "Seven", "Eight", "Nine"] 180 | }`), 181 | } 182 | _, err := s.Broker.Update(s.ctx, "", details, true) 183 | s.Nil(err) 184 | } 185 | 186 | func (s *UpdateSuite) TestForwardedHeadersWhitelistAndWildcard() { 187 | s.setupTestOfHeaderForwarding() 188 | s.failOnUpdateWithExpectedHeaders(utils.Headers{"*": true}) 189 | 190 | details := brokerapi.UpdateDetails{ 191 | RawParameters: json.RawMessage(`{ 192 | "insecure_origin": true, 193 | "domain": "domain.gov", 194 | "path": ".", 195 | "headers": ["*", "User-Agent"] 196 | }`), 197 | } 198 | _, err := s.Broker.Update(s.ctx, "", details, true) 199 | s.NotNil(err) 200 | s.Contains(err.Error(), "must not pass whitelisted headers alongside wildcard") 201 | } 202 | 203 | func (s *UpdateSuite) TestForwardedHeadersMoreThanTen() { 204 | s.setupTestOfHeaderForwarding() 205 | s.failOnUpdateWithExpectedHeaders(utils.Headers{"One": true, "Two": true, "Three": true, "Four": true, "Five": true, "Six": true, "Seven": true, "Eight": true, "Nine": true, "Ten": true, "Host": true}) 206 | 207 | details := brokerapi.UpdateDetails{ 208 | RawParameters: json.RawMessage(`{ 209 | "insecure_origin": true, 210 | "domain": "domain.gov", 211 | "path": ".", 212 | "headers": ["One", "Two", "Three", "Four", "Five", "Six", "Seven", "Eight", "Nine", "Ten"] 213 | }`), 214 | } 215 | _, err := s.Broker.Update(s.ctx, "", details, true) 216 | s.NotNil(err) 217 | s.Contains(err.Error(), "must not set more than 10 headers; got 11") 218 | } 219 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Cloud Foundry CDN Service Broker [![Build Status](https://travis-ci.org/cloud-gov/cf-cdn-service-broker.svg?branch=master)](https://travis-ci.org/cloud-gov/cf-cdn-service-broker) 2 | 3 | A [Cloud Foundry](https://www.cloudfoundry.org/) [service broker](https://docs.cloudfoundry.org/services/) that uses [AWS CloudFront](https://aws.amazon.com/cloudfront/) to proxy traffic from a domain that the user controls (the domain) to an existing Cloud Foundry application or external URL. Traffic is encrypted using an SSL certificate generated by [Let's Encrypt](https://letsencrypt.org/). 4 | 5 | 6 | ## Let's Encrypt V1 End of Life 7 | 8 | The Let's Encrypt V1 endpoint is reaching end of life in June of 2020. In November of 2019, Let's Encrypt shutdown the creation of new users via the V1 API. https://community.letsencrypt.org/t/end-of-life-plan-for-acmev1/88430 9 | 10 | In response to disabling new user creation, this broker has been changed to use an existing user's credentials. This is implemented in `LoadRandomUser` in `models/models.go`. The pool of user ids to select from is configured via an environment variable `USER_ID_POOL`. This environment variable is injected via the pipeline using the `cdn-broker-user-id-pool-staging` and `cdn-broker-user-id-pool-production` variables in the `cg-deploy-cdn-broker.yml` vars file. These values should be set as a comma separated list in double quotes. 11 | 12 | `LoadRandomUser` will select a user from the pool, use the Let's Encrypt `reg` and `key` and create a new user entry in the broker database. Effectively, the user is the same in the eyes of Let's Encrypt but a different user in the broker database. This maintains the one user to one domain relationship in the broker database. 13 | 14 | The random selection of users from a pool aims to minimize the impact of the following rate limits: 15 | * - 300 Pending Authorizations per account 16 | * - Failed Validation limit of 5 failures per account, per hostname, per hour. 17 | 18 | ## Deployment 19 | 20 | ### Automated 21 | 22 | The easiest/recommended way to deploy the broker is via the [Concourse](http://concourse.ci/) pipeline. 23 | 24 | 1. Create a `ci/credentials.yml` file, and fill in the templated values from [the pipeline](ci/pipeline.yml). 25 | 1. Deploy the pipeline. 26 | 27 | ```bash 28 | fly -t lite set-pipeline -n -c ci/pipeline.yml -p deploy-cdn-broker -l ci/credentials.yml 29 | ``` 30 | 31 | ### Manual 32 | 33 | 1. Clone this repository, and `cd` into it. 34 | 1. Target the space you want to deploy the broker to. 35 | 36 | ```bash 37 | $ cf target -o -s 38 | ``` 39 | 40 | 1. Set the `environment_variables` listed in [the deploy pipeline](ci/pipeline.yml). 41 | 1. Deploy the broker as an application. 42 | 43 | ```bash 44 | $ cf push 45 | ``` 46 | 47 | 1. [Register the broker](http://docs.cloudfoundry.org/services/managing-service-brokers.html#register-broker). 48 | 49 | ```bash 50 | $ cf create-service-broker cdn-route [username] [password] [app-url] --space-scoped 51 | ``` 52 | 53 | ## Usage 54 | 55 | 1. Target the space your application is running in. 56 | 57 | ```bash 58 | $ cf target -o -s 59 | ``` 60 | 61 | 1. Add your domain to your Cloud Foundry organization: 62 | 63 | ````bash 64 | $ cf create-domain my.domain.gov 65 | ``` 66 | 67 | 1. Create a service instance. 68 | 69 | ```bash 70 | $ cf create-service cdn-route cdn-route my-cdn-route -c '{"domain": "my.domain.gov"}' 71 | 72 | Create in progress. Use 'cf services' or 'cf service my-cdn-route' to check operation status. 73 | ``` 74 | 75 | If you have more than one domain you can pass a comma-delimited list to the `domain` parameter, just keep in mind that the broker will wait until all domains are CNAME'd: 76 | 77 | ```bash 78 | $ cf create-service cdn-route cdn-route my-cdn-route -c '{"domain": "my.domain.gov,www.my.domain.gov"}' 79 | 80 | Create in progress. Use 'cf services' or 'cf service my-cdn-route' to check operation status. 81 | ``` 82 | 83 | 1. Get the DNS instructions. 84 | 85 | ```bash 86 | $ cf service my-cdn-route 87 | 88 | Last Operation 89 | Status: create in progress 90 | Message: Provisioning in progress; CNAME domain "my.domain.gov" to "d3kajwa62y9xrp.cloudfront.net." 91 | ``` 92 | 93 | 1. Create/update your DNS configuration. 94 | 95 | 1. Wait up to 30 minutes for the CloudFront distribution to be provisioned and the DNS changes to propagate. 96 | 97 | 1. Visit `my.domain.gov`, and see that you have a valid certificate (i.e. that visiting your site in a modern browser doesn't give you a certificate warning). 98 | 99 | 1. Add your domain to a Cloud Foundry application: 100 | 101 | ```bash 102 | $ cf map-route my.domain.gov 103 | ``` 104 | 105 | ## Custom origins 106 | 107 | If you are pointing your domain to a non-Cloud Foundry application, such as a public S3 bucket, you can pass a custom origin to the broker: 108 | 109 | ```bash 110 | $ cf create-service cdn-route cdn-route my-cdn-route \ 111 | -c '{"domain": "my.domain.gov", "origin": "my-app.apps.cloud.gov"}' 112 | 113 | Create in progress. Use 'cf services' or 'cf service my-cdn-route' to check operation status. 114 | ``` 115 | 116 | If you need to add a path to your origin, you can pass it in as a parameter: 117 | 118 | ```bash 119 | $ cf create-service cdn-route cdn-route my-cdn-route \ 120 | -c '{"domain": "my.domain.gov", "origin": "my-app.apps.cloud.gov", "path": "/myfolder"}' 121 | 122 | Create in progress. Use 'cf services' or 'cf service my-cdn-route' to check operation status. 123 | ``` 124 | 125 | If your origin is non-HTTPS, you'll need to add another parameter: 126 | 127 | ```bash 128 | $ cf create-service cdn-route cdn-route my-cdn-route \ 129 | -c '{"domain": "my.domain.gov", "origin": "my-app.apps.cloud.gov", "insecure_origin": true}' 130 | 131 | Create in progress. Use 'cf services' or 'cf service my-cdn-route' to check operation status. 132 | ``` 133 | 134 | ## Cookie Forwarding 135 | 136 | If you do not want cookies forwarded to your origin, you'll need to add another parameter: 137 | 138 | ```bash 139 | $ cf create-service cdn-route cdn-route my-cdn-route \ 140 | -c '{"domain": "my.domain.gov", "cookies": false}' 141 | 142 | Create in progress. Use 'cf services' or 'cf service my-cdn-route' to check operation status. 143 | ``` 144 | 145 | ## Header Forwarding 146 | 147 | CloudFront forwards a [limited set of headers](http://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/RequestAndResponseBehaviorCustomOrigin.html#request-custom-headers-behavior) by default. If you want extra headers forwarded to your origin, you'll want to add another parameter. Here we forward both the `User-Agent` and `Referer` headers: 148 | 149 | ```bash 150 | $ cf create-service cdn-route cdn-route my-cdn-route \ 151 | -c '{"domain": "my.domain.gov", "headers": ["User-Agent", "Referer"]}' 152 | 153 | Create in progress. Use 'cf services' or 'cf service my-cdn-route' to check operation status. 154 | ``` 155 | 156 | CloudFront can forward up to 10 custom headers. Because this broker automatically forwards the `Host` header when not using a [custom origin](#custom-origins), you can whitelist up to nine headers by default; if using a custom origin, you can whitelist up to 10 headers. If you want to exceed this limit or forward all headers, you can use a wildcard: 157 | 158 | ```bash 159 | $ cf create-service cdn-route cdn-route my-cdn-route \ 160 | -c '{"domain": "my.domain.gov", "headers": ["*"]}' 161 | 162 | Create in progress. Use 'cf services' or 'cf service my-cdn-route' to check operation status. 163 | ``` 164 | 165 | When making requests to the origin, CloudFront's caching mechanism associates HTTP requests with their response. The more variation within the forwarded request, the fewer cache hits and the less effective the cache. Limiting the headers forwarded is therefore key to cache performance. Caching is disabled altogether when using a wildcard. 166 | 167 | ## Debugging 168 | 169 | By default, Cloud Controller will expire asynchronous service instances that have been pending for over one week. If your instance expires, run a dummy update 170 | to restore it to the pending state so that Cloud Controller will continue to check for updates: 171 | 172 | ```bash 173 | cf update-service my-cdn-route -c '{"timestamp": 20161001}' 174 | ``` 175 | 176 | ## Tests 177 | 178 | ```bash 179 | go test -v $(go list ./... | grep -v /vendor/) 180 | ``` 181 | 182 | ## Contributing 183 | 184 | See [CONTRIBUTING](CONTRIBUTING.md) for additional information. 185 | 186 | ## Public domain 187 | 188 | This project is in the worldwide [public domain](LICENSE.md). As stated in [CONTRIBUTING](CONTRIBUTING.md): 189 | 190 | > This project is in the public domain within the United States, and copyright and related rights in the work worldwide are waived through the [CC0 1.0 Universal public domain dedication](https://creativecommons.org/publicdomain/zero/1.0/). 191 | > 192 | > All contributions to this project will be released under the CC0 dedication. By submitting a pull request, you are agreeing to comply with this waiver of copyright interest. 193 | -------------------------------------------------------------------------------- /broker/broker_provision_test.go: -------------------------------------------------------------------------------- 1 | package broker_test 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "testing" 7 | 8 | "github.com/stretchr/testify/suite" 9 | 10 | "code.cloudfoundry.org/lager" 11 | "github.com/cloudfoundry-community/go-cfclient" 12 | "github.com/pivotal-cf/brokerapi" 13 | 14 | "github.com/cloud-gov/cf-cdn-service-broker/broker" 15 | cfmock "github.com/cloud-gov/cf-cdn-service-broker/cf/mocks" 16 | "github.com/cloud-gov/cf-cdn-service-broker/config" 17 | "github.com/cloud-gov/cf-cdn-service-broker/models" 18 | "github.com/cloud-gov/cf-cdn-service-broker/models/mocks" 19 | "github.com/cloud-gov/cf-cdn-service-broker/utils" 20 | ) 21 | 22 | func TestProvisioning(t *testing.T) { 23 | suite.Run(t, new(ProvisionSuite)) 24 | } 25 | 26 | type ProvisionSuite struct { 27 | suite.Suite 28 | Manager mocks.RouteManagerIface 29 | Broker *broker.CdnServiceBroker 30 | cfclient cfmock.Client 31 | settings config.Settings 32 | logger lager.Logger 33 | ctx context.Context 34 | } 35 | 36 | func (s *ProvisionSuite) SetupTest() { 37 | s.Manager = mocks.RouteManagerIface{} 38 | s.cfclient = cfmock.Client{} 39 | s.logger = lager.NewLogger("broker.provision.test") 40 | s.settings = config.Settings{ 41 | DefaultOrigin: "origin.cloud.gov", 42 | } 43 | s.Broker = broker.New( 44 | &s.Manager, 45 | &s.cfclient, 46 | s.settings, 47 | s.logger, 48 | ) 49 | s.ctx = context.Background() 50 | 51 | s.cfclient.On("GetOrgByGuid", "dfb39134-ab7d-489e-ae59-4ed5c6f42fb5").Return(cfclient.Org{Name: "my-org"}, nil) 52 | 53 | } 54 | 55 | func (s *ProvisionSuite) TestSync() { 56 | _, err := s.Broker.Provision(s.ctx, "", brokerapi.ProvisionDetails{}, false) 57 | s.Equal(err, brokerapi.ErrAsyncRequired) 58 | } 59 | 60 | func (s *ProvisionSuite) TestWithoutDetails() { 61 | _, err := s.Broker.Provision(s.ctx, "", brokerapi.ProvisionDetails{}, true) 62 | s.NotNil(err) 63 | s.Equal(err.Error(), "must be invoked with configuration parameters") 64 | } 65 | 66 | func (s *ProvisionSuite) TestWithoutOptions() { 67 | details := brokerapi.ProvisionDetails{ 68 | RawParameters: []byte(`{}`), 69 | } 70 | _, err := s.Broker.Provision(s.ctx, "", details, true) 71 | s.NotNil(err) 72 | s.Equal(err.Error(), "must pass non-empty `domain`") 73 | } 74 | 75 | func (s *ProvisionSuite) TestInstanceExists() { 76 | route := &models.Route{ 77 | State: models.Provisioned, 78 | } 79 | s.cfclient.On("GetDomainByName", "domain.gov").Return(cfclient.Domain{}, nil) 80 | s.Manager.On("Get", "123").Return(route, nil) 81 | 82 | details := brokerapi.ProvisionDetails{ 83 | RawParameters: []byte(`{"domain": "domain.gov"}`), 84 | } 85 | _, err := s.Broker.Provision(s.ctx, "123", details, true) 86 | s.Equal(err, brokerapi.ErrInstanceAlreadyExists) 87 | } 88 | 89 | func (s *ProvisionSuite) TestSuccess() { 90 | s.Manager.On("Get", "123").Return(&models.Route{}, errors.New("not found")) 91 | route := &models.Route{State: models.Provisioning} 92 | s.cfclient.On("GetDomainByName", "domain.gov").Return(cfclient.Domain{}, nil) 93 | s.Manager.On("Create", "123", "domain.gov", "origin.cloud.gov", "", false, utils.Headers{"Host": true}, true, 94 | map[string]string{"Organization": "", "Space": "", "Service": "", "Plan": ""}).Return(route, nil) 95 | 96 | details := brokerapi.ProvisionDetails{ 97 | RawParameters: []byte(`{"domain": "domain.gov"}`), 98 | } 99 | _, err := s.Broker.Provision(s.ctx, "123", details, true) 100 | s.Nil(err) 101 | } 102 | 103 | func (s *ProvisionSuite) TestSuccessCustomOrigin() { 104 | s.Manager.On("Get", "123").Return(&models.Route{}, errors.New("not found")) 105 | route := &models.Route{State: models.Provisioning} 106 | s.Manager.On("Create", "123", "domain.gov", "custom.cloud.gov", "", false, utils.Headers{}, true, 107 | map[string]string{"Organization": "", "Space": "", "Service": "", "Plan": ""}).Return(route, nil) 108 | 109 | details := brokerapi.ProvisionDetails{ 110 | RawParameters: []byte(`{"domain": "domain.gov", "origin": "custom.cloud.gov"}`), 111 | } 112 | _, err := s.Broker.Provision(s.ctx, "123", details, true) 113 | s.Nil(err) 114 | } 115 | 116 | func (s *ProvisionSuite) TestDomainNotExists() { 117 | s.cfclient.On("GetDomainByName", "domain.gov").Return(cfclient.Domain{}, errors.New("fail")) 118 | details := brokerapi.ProvisionDetails{ 119 | OrganizationGUID: "dfb39134-ab7d-489e-ae59-4ed5c6f42fb5", 120 | RawParameters: []byte(`{"domain": "domain.gov"}`), 121 | } 122 | _, err := s.Broker.Provision(s.ctx, "123", details, true) 123 | s.NotNil(err) 124 | s.Contains(err.Error(), "cf create-domain") 125 | } 126 | 127 | func (s *ProvisionSuite) TestMultipleDomainsOneNotExists() { 128 | s.cfclient.On("GetDomainByName", "domain.gov").Return(cfclient.Domain{}, nil) 129 | s.cfclient.On("GetDomainByName", "domain2.gov").Return(cfclient.Domain{}, errors.New("fail")) 130 | details := brokerapi.ProvisionDetails{ 131 | OrganizationGUID: "dfb39134-ab7d-489e-ae59-4ed5c6f42fb5", 132 | RawParameters: []byte(`{"domain": "domain.gov,domain2.gov"}`), 133 | } 134 | _, err := s.Broker.Provision(s.ctx, "123", details, true) 135 | s.NotNil(err) 136 | s.Contains(err.Error(), "Domain does not exist") 137 | s.NotContains(err.Error(), "domain.gov") 138 | s.Contains(err.Error(), "domain2.gov") 139 | } 140 | 141 | func (s *ProvisionSuite) TestMultipleDomainsMoreThanOneNotExists() { 142 | s.cfclient.On("GetDomainByName", "domain.gov").Return(cfclient.Domain{}, nil) 143 | s.cfclient.On("GetDomainByName", "domain2.gov").Return(cfclient.Domain{}, errors.New("fail")) 144 | s.cfclient.On("GetDomainByName", "domain3.gov").Return(cfclient.Domain{}, errors.New("fail")) 145 | details := brokerapi.ProvisionDetails{ 146 | OrganizationGUID: "dfb39134-ab7d-489e-ae59-4ed5c6f42fb5", 147 | RawParameters: []byte(`{"domain": "domain.gov,domain2.gov,domain3.gov"}`), 148 | } 149 | _, err := s.Broker.Provision(s.ctx, "123", details, true) 150 | s.NotNil(err) 151 | s.Contains(err.Error(), "Multiple domains do not exist") 152 | s.NotContains(err.Error(), "domain.gov") 153 | s.Contains(err.Error(), "domain2.gov") 154 | s.Contains(err.Error(), "domain3.gov") 155 | } 156 | 157 | func (s *ProvisionSuite) setupTestOfHeaderForwarding() { 158 | s.Manager.On("Get", "123").Return(&models.Route{}, errors.New("not found")) 159 | s.cfclient.On("GetDomainByName", "domain.gov").Return(cfclient.Domain{}, nil) 160 | } 161 | 162 | func (s *ProvisionSuite) allowCreateWithExpectedHeaders(expectedHeaders utils.Headers) { 163 | route := &models.Route{State: models.Provisioning} 164 | s.Manager.On("Create", "123", "domain.gov", "origin.cloud.gov", "", false, expectedHeaders, true, 165 | map[string]string{"Organization": "", "Space": "", "Service": "", "Plan": ""}).Return(route, nil) 166 | } 167 | 168 | func (s *ProvisionSuite) failCreateWithExpectedHeaders(expectedHeaders utils.Headers) { 169 | s.Manager.On("Create", "123", "domain.gov", "origin.cloud.gov", "", false, expectedHeaders, true, 170 | map[string]string{"Organization": "", "Space": "", "Service": "", "Plan": ""}).Return(nil, errors.New("fail")) 171 | } 172 | 173 | func (s *ProvisionSuite) TestSuccessForwardingDuplicatedHostHeader() { 174 | s.setupTestOfHeaderForwarding() 175 | s.allowCreateWithExpectedHeaders(utils.Headers{"Host": true}) 176 | 177 | details := brokerapi.ProvisionDetails{ 178 | RawParameters: []byte(`{"domain": "domain.gov", "headers": ["Host"]}`), 179 | } 180 | _, err := s.Broker.Provision(s.ctx, "123", details, true) 181 | s.Nil(err) 182 | } 183 | 184 | func (s *ProvisionSuite) TestSuccessForwardedSingleHeader() { 185 | s.setupTestOfHeaderForwarding() 186 | s.allowCreateWithExpectedHeaders(utils.Headers{"User-Agent": true, "Host": true}) 187 | 188 | details := brokerapi.ProvisionDetails{ 189 | RawParameters: []byte(`{"domain": "domain.gov", "headers": ["User-Agent"]}`), 190 | } 191 | _, err := s.Broker.Provision(s.ctx, "123", details, true) 192 | s.Nil(err) 193 | } 194 | 195 | func (s *ProvisionSuite) TestSuccessForwardingWildcardHeader() { 196 | s.setupTestOfHeaderForwarding() 197 | s.allowCreateWithExpectedHeaders(utils.Headers{"*": true}) 198 | 199 | details := brokerapi.ProvisionDetails{ 200 | RawParameters: []byte(`{"domain": "domain.gov", "headers": ["*"]}`), 201 | } 202 | _, err := s.Broker.Provision(s.ctx, "123", details, true) 203 | s.Nil(err) 204 | } 205 | 206 | func (s *ProvisionSuite) TestSuccessNineForwardedHeaders() { 207 | s.setupTestOfHeaderForwarding() 208 | s.allowCreateWithExpectedHeaders(utils.Headers{"One": true, "Two": true, "Three": true, "Four": true, "Five": true, "Six": true, "Seven": true, "Eight": true, "Nine": true, "Host": true}) 209 | 210 | details := brokerapi.ProvisionDetails{ 211 | RawParameters: []byte(`{"domain": "domain.gov", "headers": ["One", "Two", "Three", "Four", "Five", "Six", "Seven", "Eight", "Nine"]}`), 212 | } 213 | _, err := s.Broker.Provision(s.ctx, "123", details, true) 214 | s.Nil(err) 215 | } 216 | 217 | func (s *ProvisionSuite) TestForwardedHeadersDuplicates() { 218 | s.setupTestOfHeaderForwarding() 219 | s.failCreateWithExpectedHeaders(utils.Headers{"User-Agent": true, "Host": true}) 220 | 221 | details := brokerapi.ProvisionDetails{ 222 | RawParameters: []byte(`{"domain": "domain.gov", "headers": ["User-Agent", "Host", "User-Agent"]}`), 223 | } 224 | _, err := s.Broker.Provision(s.ctx, "123", details, true) 225 | s.NotNil(err) 226 | s.Contains(err.Error(), "must not pass duplicated header 'User-Agent'") 227 | } 228 | 229 | func (s *ProvisionSuite) TestForwardedHeadersWhitelistAndWildcard() { 230 | s.setupTestOfHeaderForwarding() 231 | s.failCreateWithExpectedHeaders(utils.Headers{"*": true}) 232 | 233 | details := brokerapi.ProvisionDetails{ 234 | RawParameters: []byte(`{"domain": "domain.gov", "headers": ["*", "User-Agent"]}`), 235 | } 236 | _, err := s.Broker.Provision(s.ctx, "123", details, true) 237 | s.NotNil(err) 238 | s.Contains(err.Error(), "must not pass whitelisted headers alongside wildcard") 239 | } 240 | 241 | func (s *ProvisionSuite) TestForwardedHeadersMoreThanTen() { 242 | s.setupTestOfHeaderForwarding() 243 | s.failCreateWithExpectedHeaders(utils.Headers{"One": true, "Two": true, "Three": true, "Four": true, "Five": true, "Six": true, "Seven": true, "Eight": true, "Nine": true, "Ten": true, "Host": true}) 244 | 245 | details := brokerapi.ProvisionDetails{ 246 | RawParameters: []byte(`{"domain": "domain.gov", "headers": ["One", "Two", "Three", "Four", "Five", "Six", "Seven", "Eight", "Nine", "Ten"]}`), 247 | } 248 | _, err := s.Broker.Provision(s.ctx, "123", details, true) 249 | s.NotNil(err) 250 | s.Contains(err.Error(), "must not set more than 10 headers; got 11") 251 | } 252 | -------------------------------------------------------------------------------- /broker/broker.go: -------------------------------------------------------------------------------- 1 | package broker 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "errors" 7 | "fmt" 8 | "io/ioutil" 9 | "strings" 10 | 11 | "code.cloudfoundry.org/lager" 12 | "github.com/pivotal-cf/brokerapi" 13 | 14 | "github.com/cloud-gov/cf-cdn-service-broker/cf" 15 | "github.com/cloud-gov/cf-cdn-service-broker/config" 16 | "github.com/cloud-gov/cf-cdn-service-broker/models" 17 | "github.com/cloud-gov/cf-cdn-service-broker/utils" 18 | ) 19 | 20 | type Options struct { 21 | Domain string `json:"domain"` 22 | Origin string `json:"origin"` 23 | Path string `json:"path"` 24 | InsecureOrigin bool `json:"insecure_origin"` 25 | Cookies bool `json:"cookies"` 26 | Headers []string `json:"headers"` 27 | } 28 | 29 | type CdnServiceBroker struct { 30 | manager models.RouteManagerIface 31 | cfclient cf.Client 32 | settings config.Settings 33 | logger lager.Logger 34 | } 35 | 36 | func New( 37 | manager models.RouteManagerIface, 38 | cfclient cf.Client, 39 | settings config.Settings, 40 | logger lager.Logger, 41 | ) *CdnServiceBroker { 42 | return &CdnServiceBroker{ 43 | manager: manager, 44 | cfclient: cfclient, 45 | settings: settings, 46 | logger: logger, 47 | } 48 | } 49 | 50 | var ( 51 | MAX_HEADER_COUNT = 10 52 | ) 53 | 54 | func (*CdnServiceBroker) Services(context context.Context) ([]brokerapi.Service, error) { 55 | var service brokerapi.Service 56 | buf, err := ioutil.ReadFile("./catalog.json") 57 | if err != nil { 58 | return []brokerapi.Service{}, err 59 | } 60 | err = json.Unmarshal(buf, &service) 61 | if err != nil { 62 | return []brokerapi.Service{}, err 63 | } 64 | return []brokerapi.Service{service}, nil 65 | } 66 | 67 | func (b *CdnServiceBroker) Provision( 68 | context context.Context, 69 | instanceID string, 70 | details brokerapi.ProvisionDetails, 71 | asyncAllowed bool, 72 | ) (brokerapi.ProvisionedServiceSpec, error) { 73 | spec := brokerapi.ProvisionedServiceSpec{} 74 | 75 | if !asyncAllowed { 76 | return spec, brokerapi.ErrAsyncRequired 77 | } 78 | 79 | options, err := b.parseProvisionDetails(details) 80 | if err != nil { 81 | return spec, err 82 | } 83 | 84 | _, err = b.manager.Get(instanceID) 85 | if err == nil { 86 | return spec, brokerapi.ErrInstanceAlreadyExists 87 | } 88 | 89 | headers, err := b.getHeaders(options) 90 | if err != nil { 91 | return spec, err 92 | } 93 | 94 | tags := map[string]string{ 95 | "Organization": details.OrganizationGUID, 96 | "Space": details.SpaceGUID, 97 | "Service": details.ServiceID, 98 | "Plan": details.PlanID, 99 | } 100 | 101 | _, err = b.manager.Create(instanceID, options.Domain, options.Origin, options.Path, options.InsecureOrigin, headers, options.Cookies, tags) 102 | if err != nil { 103 | return spec, err 104 | } 105 | 106 | return brokerapi.ProvisionedServiceSpec{IsAsync: true}, nil 107 | } 108 | 109 | func (b *CdnServiceBroker) LastOperation( 110 | context context.Context, 111 | instanceID, operationData string, 112 | ) (brokerapi.LastOperation, error) { 113 | route, err := b.manager.Get(instanceID) 114 | if err != nil { 115 | return brokerapi.LastOperation{ 116 | State: brokerapi.Failed, 117 | Description: "Service instance not found", 118 | }, nil 119 | } 120 | 121 | err = b.manager.Poll(route) 122 | if err != nil { 123 | b.logger.Error("Error during update", err, lager.Data{ 124 | "domain": route.DomainExternal, 125 | "state": route.State, 126 | }) 127 | } 128 | 129 | switch route.State { 130 | case models.Provisioning: 131 | instructions, err := b.manager.GetDNSInstructions(route) 132 | if err != nil { 133 | return brokerapi.LastOperation{}, err 134 | } 135 | description := fmt.Sprintf( 136 | "Provisioning in progress [%s => %s]; CNAME or ALIAS domain %s to %s or create TXT record(s): \n%s", 137 | route.DomainExternal, route.Origin, route.DomainExternal, route.DomainInternal, 138 | strings.Join(instructions, "\n"), 139 | ) 140 | return brokerapi.LastOperation{ 141 | State: brokerapi.InProgress, 142 | Description: description, 143 | }, nil 144 | case models.Deprovisioning: 145 | return brokerapi.LastOperation{ 146 | State: brokerapi.InProgress, 147 | Description: fmt.Sprintf( 148 | "Deprovisioning in progress [%s => %s]; CDN domain %s", 149 | route.DomainExternal, route.Origin, route.DomainInternal, 150 | ), 151 | }, nil 152 | case models.Failed: 153 | return brokerapi.LastOperation{ 154 | State: brokerapi.Failed, 155 | Description: "Failure while provisioning instance", 156 | }, nil 157 | default: 158 | return brokerapi.LastOperation{ 159 | State: brokerapi.Succeeded, 160 | Description: fmt.Sprintf( 161 | "Service instance provisioned [%s => %s]; CDN domain %s", 162 | route.DomainExternal, route.Origin, route.DomainInternal, 163 | ), 164 | }, nil 165 | } 166 | } 167 | 168 | func (b *CdnServiceBroker) Deprovision( 169 | context context.Context, 170 | instanceID string, 171 | details brokerapi.DeprovisionDetails, 172 | asyncAllowed bool, 173 | ) (brokerapi.DeprovisionServiceSpec, error) { 174 | if !asyncAllowed { 175 | return brokerapi.DeprovisionServiceSpec{}, brokerapi.ErrAsyncRequired 176 | } 177 | 178 | route, err := b.manager.Get(instanceID) 179 | if err != nil { 180 | return brokerapi.DeprovisionServiceSpec{}, err 181 | } 182 | 183 | err = b.manager.Disable(route) 184 | if err != nil { 185 | return brokerapi.DeprovisionServiceSpec{}, nil 186 | } 187 | 188 | return brokerapi.DeprovisionServiceSpec{IsAsync: true}, nil 189 | } 190 | 191 | func (b *CdnServiceBroker) Bind( 192 | context context.Context, 193 | instanceID, bindingID string, 194 | details brokerapi.BindDetails, 195 | ) (brokerapi.Binding, error) { 196 | return brokerapi.Binding{}, errors.New("service does not support bind") 197 | } 198 | 199 | func (b *CdnServiceBroker) Unbind( 200 | context context.Context, 201 | instanceID, bindingID string, 202 | details brokerapi.UnbindDetails, 203 | ) error { 204 | return errors.New("service does not support bind") 205 | } 206 | 207 | func (b *CdnServiceBroker) Update( 208 | context context.Context, 209 | instanceID string, 210 | details brokerapi.UpdateDetails, 211 | asyncAllowed bool, 212 | ) (brokerapi.UpdateServiceSpec, error) { 213 | if !asyncAllowed { 214 | return brokerapi.UpdateServiceSpec{}, brokerapi.ErrAsyncRequired 215 | } 216 | 217 | options, err := b.parseUpdateDetails(details) 218 | if err != nil { 219 | return brokerapi.UpdateServiceSpec{}, err 220 | } 221 | 222 | headers, err := b.getHeaders(options) 223 | if err != nil { 224 | return brokerapi.UpdateServiceSpec{}, err 225 | } 226 | 227 | err = b.manager.Update(instanceID, options.Domain, options.Origin, options.Path, options.InsecureOrigin, headers, options.Cookies) 228 | if err != nil { 229 | return brokerapi.UpdateServiceSpec{}, err 230 | } 231 | 232 | return brokerapi.UpdateServiceSpec{IsAsync: true}, nil 233 | } 234 | 235 | // createBrokerOptions will attempt to take raw json and convert it into the "Options" struct. 236 | func (b *CdnServiceBroker) createBrokerOptions(details []byte) (options Options, err error) { 237 | if len(details) == 0 { 238 | err = errors.New("must be invoked with configuration parameters") 239 | return 240 | } 241 | options = Options{ 242 | Origin: b.settings.DefaultOrigin, 243 | Cookies: true, 244 | Headers: []string{}, 245 | } 246 | err = json.Unmarshal(details, &options) 247 | if err != nil { 248 | return 249 | } 250 | return 251 | } 252 | 253 | // parseProvisionDetails will attempt to parse the update details and then verify that BOTH least "domain" and "origin" 254 | // are provided. 255 | func (b *CdnServiceBroker) parseProvisionDetails(details brokerapi.ProvisionDetails) (options Options, err error) { 256 | options, err = b.createBrokerOptions(details.RawParameters) 257 | if err != nil { 258 | return 259 | } 260 | if options.Domain == "" { 261 | err = errors.New("must pass non-empty `domain`") 262 | return 263 | } 264 | if options.Origin == b.settings.DefaultOrigin { 265 | err = b.checkDomain(options.Domain, details.OrganizationGUID) 266 | if err != nil { 267 | return 268 | } 269 | } 270 | return 271 | } 272 | 273 | // parseUpdateDetails will attempt to parse the update details and then verify that at least "domain" or "origin" 274 | // are provided. 275 | func (b *CdnServiceBroker) parseUpdateDetails(details brokerapi.UpdateDetails) (options Options, err error) { 276 | options, err = b.createBrokerOptions(details.RawParameters) 277 | if err != nil { 278 | return 279 | } 280 | if options.Domain == "" && options.Origin == "" { 281 | err = errors.New("must pass non-empty `domain` or `origin`") 282 | return 283 | } 284 | if options.Domain != "" && options.Origin == b.settings.DefaultOrigin { 285 | err = b.checkDomain(options.Domain, details.PreviousValues.OrgID) 286 | if err != nil { 287 | return 288 | } 289 | } 290 | return 291 | } 292 | 293 | func (b *CdnServiceBroker) checkDomain(domain, orgGUID string) error { 294 | // domain can be a comma separated list so we need to check each one individually 295 | domains := strings.Split(domain, ",") 296 | var errorlist []string 297 | 298 | orgName := "" 299 | 300 | for _, domain := range domains { 301 | if _, err := b.cfclient.GetDomainByName(domain); err != nil { 302 | b.logger.Error("Error checking domain", err, lager.Data{ 303 | "domain": domain, 304 | "orgGUID": orgGUID, 305 | }) 306 | if orgName == "" { 307 | org, err := b.cfclient.GetOrgByGuid(orgGUID) 308 | if err == nil { 309 | orgName = org.Name 310 | } 311 | } 312 | errorlist = append(errorlist, fmt.Sprintf("`cf create-domain %s %s`", orgName, domain)) 313 | } 314 | } 315 | 316 | if len(errorlist) > 0 { 317 | if len(errorlist) > 1 { 318 | return fmt.Errorf("Multiple domains do not exist; create them with:\n%s", strings.Join(errorlist, "\n")) 319 | } 320 | return fmt.Errorf("Domain does not exist; create it with %s", errorlist[0]) 321 | } 322 | 323 | return nil 324 | } 325 | 326 | func (b *CdnServiceBroker) getHeaders(options Options) (headers utils.Headers, err error) { 327 | headers = utils.Headers{} 328 | for _, header := range options.Headers { 329 | if headers.Contains(header) { 330 | err = fmt.Errorf("must not pass duplicated header '%s'", header) 331 | return 332 | } 333 | headers.Add(header) 334 | } 335 | 336 | // Forbid accompanying a wildcard with specific headers. 337 | if headers.Contains("*") && len(headers) > 1 { 338 | err = errors.New("must not pass whitelisted headers alongside wildcard") 339 | return 340 | } 341 | 342 | // Ensure the Host header is forwarded if using a CloudFoundry origin. 343 | if options.Origin == b.settings.DefaultOrigin && !headers.Contains("*") { 344 | headers.Add("Host") 345 | } 346 | 347 | if len(headers) > MAX_HEADER_COUNT { 348 | err = fmt.Errorf("must not set more than %d headers; got %d", MAX_HEADER_COUNT, len(headers)) 349 | return 350 | } 351 | 352 | return 353 | } 354 | -------------------------------------------------------------------------------- /utils/cloudfront.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/aws/aws-sdk-go/aws" 7 | "github.com/aws/aws-sdk-go/service/cloudfront" 8 | 9 | "github.com/cloud-gov/cf-cdn-service-broker/config" 10 | ) 11 | 12 | type DistributionIface interface { 13 | Create(callerReference string, domains []string, origin, path string, insecureOrigin bool, forwardedHeaders Headers, forwardCookies bool, tags map[string]string) (*cloudfront.Distribution, error) 14 | Update(distId string, domains []string, origin, path string, insecureOrigin bool, forwardedHeaders Headers, forwardCookies bool) (*cloudfront.Distribution, error) 15 | Get(distId string) (*cloudfront.Distribution, error) 16 | SetCertificate(distId, certId string) error 17 | SetCertificateAndCname(distId, certId string, domains []string) error 18 | Disable(distId string) error 19 | Delete(distId string) (bool, error) 20 | ListDistributions(callback func(cloudfront.DistributionSummary) bool) error 21 | } 22 | 23 | type Distribution struct { 24 | Settings config.Settings 25 | Service *cloudfront.CloudFront 26 | } 27 | 28 | func (d *Distribution) getAliases(domains []string) *cloudfront.Aliases { 29 | var items []*string 30 | for _, d := range domains { 31 | items = append(items, aws.String(d)) 32 | } 33 | return &cloudfront.Aliases{ 34 | Quantity: aws.Int64(int64(len(domains))), 35 | Items: items, 36 | } 37 | } 38 | 39 | func (d *Distribution) getTags(tags map[string]string) *cloudfront.Tags { 40 | items := []*cloudfront.Tag{} 41 | for key, value := range tags { 42 | items = append(items, &cloudfront.Tag{ 43 | Key: aws.String(key), 44 | Value: aws.String(value), 45 | }) 46 | } 47 | return &cloudfront.Tags{Items: items} 48 | } 49 | 50 | func (d *Distribution) getHeaders(headers []string) *cloudfront.Headers { 51 | items := make([]*string, len(headers)) 52 | for idx, header := range headers { 53 | items[idx] = aws.String(header) 54 | } 55 | return &cloudfront.Headers{ 56 | Quantity: aws.Int64(int64(len(headers))), 57 | Items: items, 58 | } 59 | } 60 | 61 | // fillDistributionConfig is a wrapper function that will get all the common config settings for 62 | // "cloudfront.DistributionConfig". This function is shared between "Create" and "Update". 63 | // In order to maintain backwards compatibility with older versions of the code where the callerReference was derived 64 | // from the domain(s), the callerReference has to be explicitly passed in. This is necessary because whenever we do an 65 | // update, the domains could change but we need to treat the CallerReference like an ID because 66 | // it can't be changed like the domains and instead the callerReference which was composed of the original domains must 67 | // be passed in. 68 | func (d *Distribution) fillDistributionConfig(config *cloudfront.DistributionConfig, origin, path string, 69 | insecureOrigin bool, callerReference *string, domains []string, forwardedHeaders []string, forwardCookies bool) { 70 | config.CallerReference = callerReference 71 | config.Comment = aws.String("cdn route service") 72 | config.Enabled = aws.Bool(true) 73 | config.IsIPV6Enabled = aws.Bool(true) 74 | 75 | cookies := aws.String("all") 76 | if forwardCookies == false { 77 | cookies = aws.String("none") 78 | } 79 | 80 | config.DefaultCacheBehavior = &cloudfront.DefaultCacheBehavior{ 81 | TargetOriginId: aws.String(*callerReference), 82 | ForwardedValues: &cloudfront.ForwardedValues{ 83 | Headers: d.getHeaders(forwardedHeaders), 84 | Cookies: &cloudfront.CookiePreference{ 85 | Forward: cookies, 86 | }, 87 | QueryString: aws.Bool(true), 88 | QueryStringCacheKeys: &cloudfront.QueryStringCacheKeys{ 89 | Quantity: aws.Int64(0), 90 | }, 91 | }, 92 | SmoothStreaming: aws.Bool(false), 93 | DefaultTTL: aws.Int64(86400), 94 | MinTTL: aws.Int64(0), 95 | MaxTTL: aws.Int64(31536000), 96 | LambdaFunctionAssociations: &cloudfront.LambdaFunctionAssociations{ 97 | Quantity: aws.Int64(0), 98 | }, 99 | TrustedSigners: &cloudfront.TrustedSigners{ 100 | Enabled: aws.Bool(false), 101 | Quantity: aws.Int64(0), 102 | }, 103 | ViewerProtocolPolicy: aws.String("redirect-to-https"), 104 | AllowedMethods: &cloudfront.AllowedMethods{ 105 | CachedMethods: &cloudfront.CachedMethods{ 106 | Quantity: aws.Int64(2), 107 | Items: []*string{ 108 | aws.String("HEAD"), 109 | aws.String("GET"), 110 | }, 111 | }, 112 | Quantity: aws.Int64(7), 113 | Items: []*string{ 114 | aws.String("HEAD"), 115 | aws.String("GET"), 116 | aws.String("OPTIONS"), 117 | aws.String("PUT"), 118 | aws.String("POST"), 119 | aws.String("PATCH"), 120 | aws.String("DELETE"), 121 | }, 122 | }, 123 | Compress: aws.Bool(false), 124 | } 125 | config.Origins = &cloudfront.Origins{ 126 | Quantity: aws.Int64(2), 127 | Items: []*cloudfront.Origin{ 128 | { 129 | DomainName: aws.String(origin), 130 | Id: aws.String(*callerReference), 131 | OriginPath: aws.String(path), 132 | CustomHeaders: &cloudfront.CustomHeaders{ 133 | Quantity: aws.Int64(0), 134 | }, 135 | CustomOriginConfig: &cloudfront.CustomOriginConfig{ 136 | HTTPPort: aws.Int64(80), 137 | HTTPSPort: aws.Int64(443), 138 | OriginReadTimeout: aws.Int64(30), 139 | OriginKeepaliveTimeout: aws.Int64(5), 140 | OriginProtocolPolicy: getOriginProtocolPolicy(insecureOrigin), 141 | OriginSslProtocols: &cloudfront.OriginSslProtocols{ 142 | Quantity: aws.Int64(1), 143 | Items: []*string{ 144 | aws.String("TLSv1.2"), 145 | }, 146 | }, 147 | }, 148 | }, 149 | { 150 | DomainName: aws.String(fmt.Sprintf("%s.s3.amazonaws.com", d.Settings.Bucket)), 151 | Id: aws.String(fmt.Sprintf("s3-%s-%s", d.Settings.Bucket, *callerReference)), 152 | OriginPath: aws.String(""), 153 | CustomHeaders: &cloudfront.CustomHeaders{ 154 | Quantity: aws.Int64(0), 155 | }, 156 | S3OriginConfig: &cloudfront.S3OriginConfig{ 157 | OriginAccessIdentity: aws.String(""), 158 | }, 159 | }, 160 | }, 161 | } 162 | config.CacheBehaviors = &cloudfront.CacheBehaviors{ 163 | Quantity: aws.Int64(1), 164 | Items: []*cloudfront.CacheBehavior{ 165 | { 166 | AllowedMethods: &cloudfront.AllowedMethods{ 167 | CachedMethods: &cloudfront.CachedMethods{ 168 | Quantity: aws.Int64(2), 169 | Items: []*string{ 170 | aws.String("HEAD"), 171 | aws.String("GET"), 172 | }, 173 | }, 174 | Items: []*string{ 175 | aws.String("HEAD"), 176 | aws.String("GET"), 177 | }, 178 | Quantity: aws.Int64(2), 179 | }, 180 | Compress: aws.Bool(false), 181 | PathPattern: aws.String("/.well-known/acme-challenge/*"), 182 | TargetOriginId: aws.String(fmt.Sprintf("s3-%s-%s", d.Settings.Bucket, *callerReference)), 183 | ForwardedValues: &cloudfront.ForwardedValues{ 184 | Headers: &cloudfront.Headers{ 185 | Quantity: aws.Int64(0), 186 | }, 187 | QueryString: aws.Bool(false), 188 | Cookies: &cloudfront.CookiePreference{ 189 | Forward: aws.String("none"), 190 | }, 191 | QueryStringCacheKeys: &cloudfront.QueryStringCacheKeys{ 192 | Quantity: aws.Int64(0), 193 | }, 194 | }, 195 | SmoothStreaming: aws.Bool(false), 196 | DefaultTTL: aws.Int64(86400), 197 | MinTTL: aws.Int64(0), 198 | MaxTTL: aws.Int64(31536000), 199 | LambdaFunctionAssociations: &cloudfront.LambdaFunctionAssociations{ 200 | Quantity: aws.Int64(0), 201 | }, 202 | TrustedSigners: &cloudfront.TrustedSigners{ 203 | Enabled: aws.Bool(false), 204 | Quantity: aws.Int64(0), 205 | }, 206 | ViewerProtocolPolicy: aws.String("allow-all"), 207 | }, 208 | }, 209 | } 210 | config.Aliases = d.getAliases(domains) 211 | config.PriceClass = aws.String("PriceClass_100") 212 | } 213 | 214 | func (d *Distribution) Create(callerReference string, domains []string, origin, path string, insecureOrigin bool, forwardedHeaders Headers, forwardCookies bool, tags map[string]string) (*cloudfront.Distribution, error) { 215 | distConfig := new(cloudfront.DistributionConfig) 216 | d.fillDistributionConfig(distConfig, origin, path, insecureOrigin, 217 | aws.String(callerReference), domains, forwardedHeaders.Strings(), forwardCookies) 218 | resp, err := d.Service.CreateDistributionWithTags(&cloudfront.CreateDistributionWithTagsInput{ 219 | DistributionConfigWithTags: &cloudfront.DistributionConfigWithTags{ 220 | DistributionConfig: distConfig, 221 | Tags: d.getTags(tags), 222 | }, 223 | }) 224 | 225 | if err != nil { 226 | return &cloudfront.Distribution{}, err 227 | } 228 | 229 | return resp.Distribution, nil 230 | } 231 | 232 | func (d *Distribution) Update(distId string, domains []string, origin, path string, insecureOrigin bool, forwardedHeaders Headers, forwardCookies bool) (*cloudfront.Distribution, error) { 233 | // Get the current distribution 234 | dist, err := d.Service.GetDistributionConfig(&cloudfront.GetDistributionConfigInput{ 235 | Id: aws.String(distId), 236 | }) 237 | if err != nil { 238 | return nil, err 239 | } 240 | d.fillDistributionConfig(dist.DistributionConfig, origin, path, insecureOrigin, 241 | dist.DistributionConfig.CallerReference, domains, forwardedHeaders.Strings(), forwardCookies) 242 | 243 | // Call the UpdateDistribution function 244 | resp, err := d.Service.UpdateDistribution(&cloudfront.UpdateDistributionInput{ 245 | Id: aws.String(distId), 246 | IfMatch: dist.ETag, 247 | DistributionConfig: dist.DistributionConfig, 248 | }) 249 | if err != nil { 250 | return &cloudfront.Distribution{}, err 251 | } 252 | return resp.Distribution, nil 253 | } 254 | 255 | func (d *Distribution) Get(distId string) (*cloudfront.Distribution, error) { 256 | resp, err := d.Service.GetDistribution(&cloudfront.GetDistributionInput{ 257 | Id: aws.String(distId), 258 | }) 259 | if err != nil { 260 | return &cloudfront.Distribution{}, err 261 | } 262 | return resp.Distribution, nil 263 | } 264 | 265 | func (d *Distribution) SetCertificateAndCname(distId, certId string, domains []string) error { 266 | resp, err := d.Service.GetDistributionConfig(&cloudfront.GetDistributionConfigInput{ 267 | Id: aws.String(distId), 268 | }) 269 | if err != nil { 270 | return err 271 | } 272 | 273 | aliases := d.getAliases(domains) 274 | DistributionConfig, ETag := resp.DistributionConfig, resp.ETag 275 | DistributionConfig.Aliases = aliases 276 | 277 | DistributionConfig.ViewerCertificate.Certificate = aws.String(certId) 278 | DistributionConfig.ViewerCertificate.IAMCertificateId = aws.String(certId) 279 | DistributionConfig.ViewerCertificate.CertificateSource = aws.String("iam") 280 | DistributionConfig.ViewerCertificate.SSLSupportMethod = aws.String("sni-only") 281 | DistributionConfig.ViewerCertificate.MinimumProtocolVersion = aws.String("TLSv1.2_2018") 282 | DistributionConfig.ViewerCertificate.CloudFrontDefaultCertificate = aws.Bool(false) 283 | 284 | _, err = d.Service.UpdateDistribution(&cloudfront.UpdateDistributionInput{ 285 | Id: aws.String(distId), 286 | IfMatch: ETag, 287 | DistributionConfig: DistributionConfig, 288 | }) 289 | 290 | return err 291 | } 292 | func (d *Distribution) SetCertificate(distId, certId string) error { 293 | resp, err := d.Service.GetDistributionConfig(&cloudfront.GetDistributionConfigInput{ 294 | Id: aws.String(distId), 295 | }) 296 | if err != nil { 297 | return err 298 | } 299 | 300 | DistributionConfig, ETag := resp.DistributionConfig, resp.ETag 301 | 302 | DistributionConfig.ViewerCertificate.Certificate = aws.String(certId) 303 | DistributionConfig.ViewerCertificate.IAMCertificateId = aws.String(certId) 304 | DistributionConfig.ViewerCertificate.CertificateSource = aws.String("iam") 305 | DistributionConfig.ViewerCertificate.SSLSupportMethod = aws.String("sni-only") 306 | DistributionConfig.ViewerCertificate.MinimumProtocolVersion = aws.String("TLSv1.2_2018") 307 | DistributionConfig.ViewerCertificate.CloudFrontDefaultCertificate = aws.Bool(false) 308 | 309 | _, err = d.Service.UpdateDistribution(&cloudfront.UpdateDistributionInput{ 310 | Id: aws.String(distId), 311 | IfMatch: ETag, 312 | DistributionConfig: DistributionConfig, 313 | }) 314 | 315 | return err 316 | } 317 | 318 | func (d *Distribution) Disable(distId string) error { 319 | resp, err := d.Service.GetDistributionConfig(&cloudfront.GetDistributionConfigInput{ 320 | Id: aws.String(distId), 321 | }) 322 | if err != nil { 323 | return err 324 | } 325 | 326 | DistributionConfig, ETag := resp.DistributionConfig, resp.ETag 327 | DistributionConfig.Enabled = aws.Bool(false) 328 | 329 | _, err = d.Service.UpdateDistribution(&cloudfront.UpdateDistributionInput{ 330 | Id: aws.String(distId), 331 | IfMatch: ETag, 332 | DistributionConfig: DistributionConfig, 333 | }) 334 | 335 | return err 336 | } 337 | 338 | func (d *Distribution) Delete(distId string) (bool, error) { 339 | resp, err := d.Service.GetDistribution(&cloudfront.GetDistributionInput{ 340 | Id: aws.String(distId), 341 | }) 342 | if err != nil { 343 | return false, err 344 | } 345 | 346 | if *resp.Distribution.Status != "Deployed" { 347 | return false, nil 348 | } 349 | 350 | _, err = d.Service.DeleteDistribution(&cloudfront.DeleteDistributionInput{ 351 | Id: aws.String(distId), 352 | IfMatch: resp.ETag, 353 | }) 354 | 355 | return err == nil, err 356 | } 357 | 358 | func (d *Distribution) ListDistributions(callback func(cloudfront.DistributionSummary) bool) error { 359 | return d.Service.ListDistributionsPages( 360 | &cloudfront.ListDistributionsInput{}, 361 | func(page *cloudfront.ListDistributionsOutput, lastPage bool) bool { 362 | for _, v := range page.DistributionList.Items { 363 | // stop iteration if the callback tells us to 364 | if callback(*v) == false { 365 | return false 366 | } 367 | } 368 | return true 369 | }, 370 | ) 371 | } 372 | 373 | func getOriginProtocolPolicy(insecure bool) *string { 374 | if insecure { 375 | return aws.String("http-only") 376 | } 377 | return aws.String("https-only") 378 | } 379 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | code.cloudfoundry.org/gofileutils v0.0.0-20170111115228-4d0c80011a0f h1:UrKzEwTgeiff9vxdrfdqxibzpWjxLnuXDI5m6z3GJAk= 2 | code.cloudfoundry.org/gofileutils v0.0.0-20170111115228-4d0c80011a0f/go.mod h1:sk5LnIjB/nIEU7yP5sDQExVm62wu0pBh3yrElngUisI= 3 | code.cloudfoundry.org/lager v1.0.1-0.20180322215153-25ee72f227fe h1:ZTTFXskoxGQm9q8h8LvTHxhHZBd2tgjMduXqPyvwz58= 4 | code.cloudfoundry.org/lager v1.0.1-0.20180322215153-25ee72f227fe/go.mod h1:O2sS7gKP3HM2iemG+EnwvyNQK7pTSC6Foi4QiMp9sSk= 5 | github.com/Azure/azure-sdk-for-go/sdk/azcore v0.19.0/go.mod h1:h6H6c8enJmmocHUbLiiGY6sx7f9i+X3m1CHdd5c6Rdw= 6 | github.com/Azure/azure-sdk-for-go/sdk/azidentity v0.11.0/go.mod h1:HcM1YX14R7CJcghJGOYCgdezslRSVzqwLf/q+4Y2r/0= 7 | github.com/Azure/azure-sdk-for-go/sdk/internal v0.7.0/go.mod h1:yqy467j36fJxcRV2TzfVZ1pCb5vxm4BtZPUdYWe/Xo8= 8 | github.com/aws/aws-sdk-go v1.34.0 h1:brux2dRrlwCF5JhTL7MUT3WUwo9zfDHZZp3+g3Mvlmo= 9 | github.com/aws/aws-sdk-go v1.34.0/go.mod h1:5zCpMtNQVjRREroY7sYe8lOMRSxkhG6MZveU8YkpAk0= 10 | github.com/bmizerany/assert v0.0.0-20160611221934-b7ed37b82869/go.mod h1:Ekp36dRnpXw/yCqJaO+ZrUyxD+3VXMFFr56k5XYrpB4= 11 | github.com/bmizerany/pat v0.0.0-20170815010413-6226ea591a40/go.mod h1:8rLXio+WjiTceGBHIoTvn60HIbs7Hm7bcHjyrSqYB9c= 12 | github.com/cloudfoundry-community/go-cfclient v0.0.0-20180323021324-b5f0f59f96d6 h1:vGMGy7i30QJNYNM7IE0UR85nOWI0DNYkJl5nQN0yquk= 13 | github.com/cloudfoundry-community/go-cfclient v0.0.0-20180323021324-b5f0f59f96d6/go.mod h1:awqQBZ30j+KR+Zt6pzRZmNVZZ2Q/05LXNQbCM1+frL4= 14 | github.com/cloudfoundry/gofileutils v0.0.0-20170111115228-4d0c80011a0f h1:3WbAZFyGnNjeYKm74CsuxXVlZWYibufTaRjH5H9mNpw= 15 | github.com/cloudfoundry/gofileutils v0.0.0-20170111115228-4d0c80011a0f/go.mod h1:Zv7xtAh/T/tmfZlxpESaWWiWOdiJz2GfbBYxImuI6T4= 16 | github.com/codegangsta/inject v0.0.0-20150114235600-33e0aa1cb7c0 h1:sDMmm+q/3+BukdIpxwO365v/Rbspp2Nt5XntgQRXq8Q= 17 | github.com/codegangsta/inject v0.0.0-20150114235600-33e0aa1cb7c0/go.mod h1:4Zcjuz89kmFXt9morQgcfYZAYZ5n8WHjt81YYWIwtTM= 18 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 19 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 20 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 21 | github.com/denisenkom/go-mssqldb v0.12.3 h1:pBSGx9Tq67pBOTLmxNuirNTeB8Vjmf886Kx+8Y+8shw= 22 | github.com/denisenkom/go-mssqldb v0.12.3/go.mod h1:k0mtMFOnU+AihqFxPMiF05rtiDrorD1Vrm1KEz5hxDo= 23 | github.com/dnaeon/go-vcr v1.2.0/go.mod h1:R4UdLID7HZT3taECzJs4YgbbH6PIGXB6W/sc5OLb6RQ= 24 | github.com/drewolson/testflight v1.0.0 h1:jgA0pHcFIPnXoBmyFzrdoR2ka4UvReMDsjYc7Jcvl80= 25 | github.com/drewolson/testflight v1.0.0/go.mod h1:t9oKuuEohRGLb80SWX+uxJHuhX98B7HnojqtW+Ryq30= 26 | github.com/erikstmartin/go-testdb v0.0.0-20160219214506-8d10e4a1bae5 h1:Yzb9+7DPaBjB8zlTR87/ElzFsnQfuHnVUVqpZZIcV5Y= 27 | github.com/erikstmartin/go-testdb v0.0.0-20160219214506-8d10e4a1bae5/go.mod h1:a2zkGnVExMxdzMo3M0Hi/3sEU+cWnZpSni0O6/Yb/P0= 28 | github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= 29 | github.com/fsnotify/fsnotify v1.4.9 h1:hsms1Qyu0jgnwNXIxa+/V/PDsU6CfLf6CNO8H7IWoS4= 30 | github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ= 31 | github.com/go-martini/martini v0.0.0-20170121215854-22fa46961aab h1:xveKWz2iaueeTaUgdetzel+U7exyigDYBryyVfV/rZk= 32 | github.com/go-martini/martini v0.0.0-20170121215854-22fa46961aab/go.mod h1:/P9AEU963A2AYjv4d1V5eVL1CQbEJq6aCNHDDjibzu8= 33 | github.com/go-sql-driver/mysql v1.5.0/go.mod h1:DCzpHaOWr8IXmIStZouvnhqoel9Qv2LBy8hT2VhHyBg= 34 | github.com/go-sql-driver/mysql v1.7.1 h1:lUIinVbN1DY0xBg0eMOzmmtGoHwWBbvnWubQUrtU8EI= 35 | github.com/go-sql-driver/mysql v1.7.1/go.mod h1:OXbVy3sEdcQ2Doequ6Z5BW6fXNQTmx+9S1MCJN5yJMI= 36 | github.com/go-task/slim-sprig v0.0.0-20210107165309-348f09dbbbc0/go.mod h1:fyg7847qk6SyHyPtNmDHnmrv/HOrqktSC+C9fM+CJOE= 37 | github.com/golang-sql/civil v0.0.0-20190719163853-cb61b32ac6fe h1:lXe2qZdvpiX5WZkZR4hgp4KJVfY3nMkvmwbVkpv1rVY= 38 | github.com/golang-sql/civil v0.0.0-20190719163853-cb61b32ac6fe/go.mod h1:8vg3r2VgvsThLBIFL93Qb5yWzgyZWhEmBwUJWevAkK0= 39 | github.com/golang-sql/sqlexp v0.1.0 h1:ZCD6MBpcuOVfGVqsEmY5/4FtYiKz6tSyUv9LPEDei6A= 40 | github.com/golang-sql/sqlexp v0.1.0/go.mod h1:J4ad9Vo8ZCWQ2GMrC4UCQy1JpCbwU9m3EOqtpKwwwHI= 41 | github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= 42 | github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8= 43 | github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA= 44 | github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs= 45 | github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w= 46 | github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0= 47 | github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= 48 | github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= 49 | github.com/golang/protobuf v1.5.3 h1:KhyjKVUg7Usr/dYsdSqoFveMYd5ko72D+zANwlG1mmg= 50 | github.com/golang/protobuf v1.5.3/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= 51 | github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= 52 | github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= 53 | github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= 54 | github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= 55 | github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38= 56 | github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= 57 | github.com/google/uuid v1.0.0 h1:b4Gk+7WdP/d3HZH8EJsZpvV7EtDOgaZLtnaNGIu1adA= 58 | github.com/google/uuid v1.0.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= 59 | github.com/gopherjs/gopherjs v1.17.2 h1:fQnZVsXk8uxXIStYb0N4bGk7jeyTalG/wsZjQ25dO0g= 60 | github.com/gorilla/context v0.0.0-20160226214623-1ea25387ff6f h1:9oNbS1z4rVpbnkHBdPZU4jo9bSmrLpII768arSyMFgk= 61 | github.com/gorilla/context v0.0.0-20160226214623-1ea25387ff6f/go.mod h1:kBGZzfjB9CEq2AlWe17Uuf7NDRt0dE0s8S51q0aT7Yg= 62 | github.com/gorilla/mux v1.6.1 h1:KOwqsTYZdeuMacU7CxjMNYEKeBvLbxW+psodrbcEa3A= 63 | github.com/gorilla/mux v1.6.1/go.mod h1:1lud6UwP+6orDFRuTfBEV8e9/aOM/c4fVVCaMa2zaAs= 64 | github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU= 65 | github.com/jinzhu/gorm v1.9.1 h1:lDSDtsCt5AGGSKTs8AHlSDbbgif4G4+CKJ8ETBDVHTA= 66 | github.com/jinzhu/gorm v1.9.1/go.mod h1:Vla75njaFJ8clLU1W44h34PjIkijhjHIYnZxMqCdxqo= 67 | github.com/jinzhu/inflection v0.0.0-20180308033659-04140366298a h1:eeaG9XMUvRBYXJi4pg1ZKM7nxc5AfXfojeLLW7O5J3k= 68 | github.com/jinzhu/inflection v0.0.0-20180308033659-04140366298a/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc= 69 | github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ= 70 | github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= 71 | github.com/jmcarp/lego v0.3.2-0.20170424160445-b4deb96f1082 h1:o6TKWNAleEPbbCL/hhtOMdat46AYKTk16jo1iStW6f0= 72 | github.com/jmcarp/lego v0.3.2-0.20170424160445-b4deb96f1082/go.mod h1:cvuscyCJp5Gko4t4iPAR/o3Ao5/mmrDLw6X3YzJ7Ks8= 73 | github.com/jmespath/go-jmespath v0.3.0 h1:OS12ieG61fsCg5+qLJ+SsW9NicxNkg3b25OyT2yCeUc= 74 | github.com/jmespath/go-jmespath v0.3.0/go.mod h1:9QtRXoHjLGCJ5IBSaohpXITPlowMeeYCZ7fLUTSywik= 75 | github.com/jtolds/gls v4.20.0+incompatible h1:xdiiI2gbIgH/gLH7ADydsJ1uDOEzR8yvV7C0MuV77Wo= 76 | github.com/kelseyhightower/envconfig v1.3.0 h1:IvRS4f2VcIQy6j4ORGIf9145T/AsUB+oY8LyvN8BXNM= 77 | github.com/kelseyhightower/envconfig v1.3.0/go.mod h1:cccZRl6mQpaq41TPp5QxidR+Sa3axMbJDNb//FQX6Gg= 78 | github.com/kr/pretty v0.0.0-20160823170715-cfb55aafdaf3/go.mod h1:Bvhd+E3laJ0AVkG0c9rmtZcnhV0HQ3+c3YxxqTvc/gA= 79 | github.com/kr/text v0.0.0-20160504234017-7cafcd837844/go.mod h1:sjUstKUATFIcff4qlB53Kml0wQPtJVc/3fWrmuUmcfA= 80 | github.com/lib/pq v0.0.0-20180325232643-a96442e255fc h1:BkJw+D/mckYibrbu+tj0CLrLahkEKP9dl81zlF6a4xQ= 81 | github.com/lib/pq v0.0.0-20180325232643-a96442e255fc/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo= 82 | github.com/martini-contrib/render v0.0.0-20150707142108-ec18f8345a11 h1:YFh+sjyJTMQSYjKwM4dFKhJPJC/wfo98tPUc17HdoYw= 83 | github.com/martini-contrib/render v0.0.0-20150707142108-ec18f8345a11/go.mod h1:Ah2dBMoxZEqk118as2T4u4fjfXarE0pPnMJaArZQZsI= 84 | github.com/mattn/go-sqlite3 v1.14.16 h1:yOQRA0RpS5PFz/oikGwBEqvAWhWg5ufRz4ETLjwpU1Y= 85 | github.com/mattn/go-sqlite3 v1.14.16/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg= 86 | github.com/miekg/dns v1.0.4 h1:Ec3LTJwwzqT1++63P12fhtdEbQhtPE7TBdD6rlhqrMM= 87 | github.com/miekg/dns v1.0.4/go.mod h1:W1PPwlIAgtquWBMBEV9nkV9Cazfe8ScdGz/Lj7v3Nrg= 88 | github.com/modocache/gover v0.0.0-20171022184752-b58185e213c5/go.mod h1:caMODM3PzxT8aQXRPkAt8xlV/e7d7w8GM5g0fa5F0D8= 89 | github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A= 90 | github.com/nxadm/tail v1.4.8 h1:nPr65rt6Y5JFSKQO7qToXr7pePgD6Gwiw05lkbyAQTE= 91 | github.com/nxadm/tail v1.4.8/go.mod h1:+ncqLTQzXmGhMZNUePPaPqPvBxHAIsmXswZKocGu+AU= 92 | github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= 93 | github.com/onsi/ginkgo v1.12.1/go.mod h1:zj2OWP4+oCPe1qIXoGWkgMRwljMUYCdkwsT2108oapk= 94 | github.com/onsi/ginkgo v1.16.5 h1:8xi0RTUf59SOSfEtZMvwTvXYMzG4gV23XVHOZiXNtnE= 95 | github.com/onsi/ginkgo v1.16.5/go.mod h1:+E8gABHa3K6zRBolWtd+ROzc/U5bkGt0FwiG042wbpU= 96 | github.com/onsi/gomega v1.7.1/go.mod h1:XdKZgCCFLUoM/7CFJVPcG8C1xQ1AJ0vpAezJrB7JYyY= 97 | github.com/onsi/gomega v1.10.1/go.mod h1:iN09h71vgCQne3DLsj+A5owkum+a2tYe+TOCB1ybHNo= 98 | github.com/onsi/gomega v1.27.7 h1:fVih9JD6ogIiHUN6ePK7HJidyEDpWGVB5mzM7cWNXoU= 99 | github.com/onsi/gomega v1.27.7/go.mod h1:1p8OOlwo2iUUDsHnOrjE5UKYJ+e3W8eQ3qSlRahPmr4= 100 | github.com/oxtoacart/bpool v0.0.0-20190530202638-03653db5a59c h1:rp5dCmg/yLR3mgFuSOe4oEnDDmGLROTvMragMUXpTQw= 101 | github.com/oxtoacart/bpool v0.0.0-20190530202638-03653db5a59c/go.mod h1:X07ZCGwUbLaax7L0S3Tw4hpejzu63ZrrQiUe6W0hcy0= 102 | github.com/pborman/uuid v1.2.1 h1:+ZZIw58t/ozdjRaXh/3awHfmWRbzYxJoAdNJxe/3pvw= 103 | github.com/pborman/uuid v1.2.1/go.mod h1:X/NO0urCmaxf9VXbdlT7C2Yzkj2IKimNn4k+gtPdI/k= 104 | github.com/pivotal-cf/brokerapi v1.0.0 h1:VXskMouDTRxtARiZ4ORLIZ6cxlzTSqfGa/yi2JLA+F0= 105 | github.com/pivotal-cf/brokerapi v1.0.0/go.mod h1:P+oA8NvkCTkq2t4DohBiyqQo69Ub15RKGcm/vKNP0gg= 106 | github.com/pkg/browser v0.0.0-20180916011732-0a3d74bf9ce4/go.mod h1:4OwLy04Bl9Ef3GJJCoec+30X3LQs/0/m4HFRt/2LUSA= 107 | github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= 108 | github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= 109 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 110 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 111 | github.com/robfig/cron v1.0.0 h1:slmQxIUH6U9ruw4XoJ7C2pyyx4yYeiHx8S9pNootHsM= 112 | github.com/robfig/cron v1.0.0/go.mod h1:JGuDeoQd7Z6yL4zQhZ3OPEVHB7fL6Ka6skscFHfmt2k= 113 | github.com/smartystreets/assertions v1.13.1 h1:Ef7KhSmjZcK6AVf9YbJdvPYG9avaF0ZxudX+ThRdWfU= 114 | github.com/smartystreets/goconvey v1.8.0 h1:Oi49ha/2MURE0WexF052Z0m+BNSGirfjg5RL+JXWq3w= 115 | github.com/smartystreets/goconvey v1.8.0/go.mod h1:EdX8jtrTIj26jmjCOVNMVSIYAtgexqXKHOXW2Dx9JLg= 116 | github.com/stretchr/objx v0.1.0 h1:4G4v2dO3VZwixGIRoQ5Lfboy6nUhCyYzaqnIAPPhYs4= 117 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 118 | github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= 119 | github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA= 120 | github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY= 121 | github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 122 | github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= 123 | golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= 124 | golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= 125 | golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= 126 | golang.org/x/crypto v0.0.0-20201016220609-9e8e0b390897/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= 127 | golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= 128 | golang.org/x/crypto v0.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U= 129 | golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk= 130 | golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= 131 | golang.org/x/net v0.0.0-20180218175443-cbe0f9307d01/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= 132 | golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= 133 | golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= 134 | golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= 135 | golang.org/x/net v0.0.0-20200202094626-16171245cfb2/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= 136 | golang.org/x/net v0.0.0-20200520004742-59133d7f0dd7/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= 137 | golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= 138 | golang.org/x/net v0.0.0-20210610132358-84b48f89b13b/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= 139 | golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= 140 | golang.org/x/net v0.23.0 h1:7EYJ93RZ9vYSZAIb2x3lnuvqO5zneoD6IvWjuhfxjTs= 141 | golang.org/x/net v0.23.0/go.mod h1:JKghWKKOSdJwpW2GEx0Ja7fmaKnMsbu+MWVZTokSYmg= 142 | golang.org/x/oauth2 v0.0.0-20180314180239-fdc9e635145a h1:vnrksSpEGaRXtItKmKwom9Y/vzKSeiMPjj2C5TOVUdg= 143 | golang.org/x/oauth2 v0.0.0-20180314180239-fdc9e635145a/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= 144 | golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 145 | golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 146 | golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 147 | golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 148 | golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 149 | golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 150 | golang.org/x/sys v0.0.0-20190904154756-749cb33beabd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 151 | golang.org/x/sys v0.0.0-20191005200804-aed5e4c7ecf9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 152 | golang.org/x/sys v0.0.0-20191120155948-bd437916bb0e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 153 | golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 154 | golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 155 | golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 156 | golang.org/x/sys v0.0.0-20210112080510-489259a85091/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 157 | golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 158 | golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 159 | golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA= 160 | golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 161 | golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= 162 | golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 163 | golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= 164 | golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= 165 | golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo= 166 | golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 167 | golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= 168 | golang.org/x/tools v0.0.0-20201224043029-2b0845dc783e/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= 169 | golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 170 | golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 171 | golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 172 | golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 173 | google.golang.org/appengine v1.0.0 h1:dN4LljjBKVChsv0XCSI+zbyzdqrkEwX5LQFUMRSGqOc= 174 | google.golang.org/appengine v1.0.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= 175 | google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8= 176 | google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0= 177 | google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM= 178 | google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE= 179 | google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo= 180 | google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= 181 | google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= 182 | google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= 183 | google.golang.org/protobuf v1.33.0 h1:uNO2rsAINq/JlFpSdYEKIZ0uKD/R9cpdv0T+yoGwGmI= 184 | google.golang.org/protobuf v1.33.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos= 185 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= 186 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 187 | gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys= 188 | gopkg.in/square/go-jose.v1 v1.1.1 h1:pA7KxQLcwADLRJ3lpUC+vIe4LCO8oRBMoq1HJoJhA3U= 189 | gopkg.in/square/go-jose.v1 v1.1.1/go.mod h1:QpYS+a4WhS+DTlyQIi6Ka7MS3SuR9a055rgXNEe6EiA= 190 | gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ= 191 | gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw= 192 | gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 193 | gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 194 | gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 195 | gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 196 | gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= 197 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 198 | gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 199 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 200 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 201 | -------------------------------------------------------------------------------- /models/models.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | import ( 4 | "crypto" 5 | "crypto/ecdsa" 6 | "crypto/rsa" 7 | "crypto/tls" 8 | "crypto/x509" 9 | "database/sql/driver" 10 | "encoding/json" 11 | "encoding/pem" 12 | "errors" 13 | "fmt" 14 | "io/ioutil" 15 | "math/rand" 16 | "net/http" 17 | "path" 18 | "strings" 19 | "time" 20 | 21 | "code.cloudfoundry.org/lager" 22 | "github.com/jinzhu/gorm" 23 | "github.com/pivotal-cf/brokerapi" 24 | "github.com/xenolf/lego/acme" 25 | 26 | "github.com/aws/aws-sdk-go/aws" 27 | "github.com/aws/aws-sdk-go/aws/session" 28 | "github.com/aws/aws-sdk-go/service/cloudfront" 29 | "github.com/aws/aws-sdk-go/service/iam" 30 | "github.com/aws/aws-sdk-go/service/s3" 31 | 32 | "github.com/cloud-gov/cf-cdn-service-broker/config" 33 | "github.com/cloud-gov/cf-cdn-service-broker/utils" 34 | ) 35 | 36 | type State string 37 | 38 | const ( 39 | Provisioning State = "provisioning" 40 | Provisioned = "provisioned" 41 | Deprovisioning = "deprovisioning" 42 | Deprovisioned = "deprovisioned" 43 | Failed = "failed" 44 | ) 45 | 46 | var ( 47 | helperLogger = lager.NewLogger("helper-logger") 48 | ) 49 | 50 | // Value Marshal a `State` to a `string` when saving to the database 51 | func (s State) Value() (driver.Value, error) { 52 | return string(s), nil 53 | } 54 | 55 | // Scan Unmarshal an `interface{}` to a `State` when reading from the database 56 | func (s *State) Scan(value interface{}) error { 57 | switch value.(type) { 58 | case string: 59 | *s = State(value.(string)) 60 | case []byte: 61 | *s = State(value.([]byte)) 62 | default: 63 | err := fmt.Errorf("%s-is-incompatible", value) 64 | helperLogger.Session("state-scan").Error("scan-switch", err) 65 | return err 66 | } 67 | return nil 68 | } 69 | 70 | type UserData struct { 71 | gorm.Model 72 | Email string `gorm:"not null"` 73 | Reg []byte 74 | Key []byte 75 | } 76 | 77 | /* 78 | * LoadRandomUser The Let's Encrypt v1 acme API has shut down user creation to force users to adopt v2. 79 | * In an attempt to contiune using v1 while we develop a v2 compliant broker, we are replacing 80 | * calls to create a new user for each new domain registration with a method that fetches an existing user 81 | * from a pool of ids. The random selection of users from a pool aims to minimize the impact of the following rate limits: 82 | * - 300 Pending Authorizations per account 83 | * - Failed Validation limit of 5 failures per account, per hostname, per hour. 84 | */ 85 | func LoadRandomUser(db *gorm.DB, userIDPool []string) (utils.User, error) { 86 | var user utils.User 87 | defer func() { 88 | if r := recover(); r != nil { 89 | return 90 | } 91 | }() 92 | userID := userIDPool[rand.Intn(len(userIDPool))] 93 | 94 | helperLogger.Session("load-random-user").Info("random-user-id", lager.Data{ 95 | "userID": userID, 96 | }) 97 | 98 | var userData UserData 99 | 100 | if err := db.Where("id = ?", userID).First(&userData).Error; err != nil { 101 | helperLogger.Session("load-random-user").Error("load-user-data", err) 102 | return user, err 103 | } 104 | 105 | user, err := LoadUser(userData) 106 | if err != nil { 107 | helperLogger.Session("load-random-user").Error("load-user", err) 108 | return user, err 109 | } 110 | 111 | return user, nil 112 | } 113 | 114 | func SaveUser(db *gorm.DB, user utils.User) (UserData, error) { 115 | var err error 116 | userData := UserData{Email: user.GetEmail()} 117 | 118 | lsession := helperLogger.Session("save-user") 119 | 120 | userData.Key, err = savePrivateKey(user.GetPrivateKey()) 121 | if err != nil { 122 | lsession.Error("save-private-key", err) 123 | return userData, err 124 | } 125 | userData.Reg, err = json.Marshal(user) 126 | if err != nil { 127 | lsession.Error("json-marshal-user", err) 128 | return userData, err 129 | } 130 | 131 | if err := db.Save(&userData).Error; err != nil { 132 | lsession.Error("db-save-user", err) 133 | return userData, err 134 | } 135 | 136 | return userData, nil 137 | } 138 | 139 | func LoadUser(userData UserData) (utils.User, error) { 140 | var user utils.User 141 | 142 | lsession := helperLogger.Session("load-user") 143 | 144 | if err := json.Unmarshal(userData.Reg, &user); err != nil { 145 | lsession.Error("json-unmarshal-user-data", err) 146 | return user, err 147 | } 148 | key, err := loadPrivateKey(userData.Key) 149 | if err != nil { 150 | lsession.Error("load-private-key", err) 151 | return user, err 152 | } 153 | user.SetPrivateKey(key) 154 | return user, nil 155 | } 156 | 157 | // loadPrivateKey loads a PEM-encoded ECC/RSA private key from an array of bytes. 158 | func loadPrivateKey(keyBytes []byte) (crypto.PrivateKey, error) { 159 | keyBlock, _ := pem.Decode(keyBytes) 160 | 161 | switch keyBlock.Type { 162 | case "RSA PRIVATE KEY": 163 | return x509.ParsePKCS1PrivateKey(keyBlock.Bytes) 164 | case "EC PRIVATE KEY": 165 | return x509.ParseECPrivateKey(keyBlock.Bytes) 166 | } 167 | 168 | return nil, errors.New("unknown private key type") 169 | } 170 | 171 | // savePrivateKey saves a PEM-encoded ECC/RSA private key to an array of bytes. 172 | func savePrivateKey(key crypto.PrivateKey) ([]byte, error) { 173 | var pemType string 174 | var keyBytes []byte 175 | switch key := key.(type) { 176 | case *ecdsa.PrivateKey: 177 | var err error 178 | pemType = "EC" 179 | keyBytes, err = x509.MarshalECPrivateKey(key) 180 | if err != nil { 181 | helperLogger.Session("save-private-key").Error("marshal-ec-private-key", err) 182 | return nil, err 183 | } 184 | case *rsa.PrivateKey: 185 | pemType = "RSA" 186 | keyBytes = x509.MarshalPKCS1PrivateKey(key) 187 | } 188 | 189 | pemKey := pem.Block{Type: pemType + " PRIVATE KEY", Bytes: keyBytes} 190 | return pem.EncodeToMemory(&pemKey), nil 191 | } 192 | 193 | type Route struct { 194 | gorm.Model 195 | InstanceId string `gorm:"not null;unique_index"` 196 | State State `gorm:"not null;index"` 197 | ChallengeJSON []byte 198 | DomainExternal string 199 | DomainInternal string 200 | DistId string 201 | Origin string 202 | Path string 203 | InsecureOrigin bool 204 | Certificate Certificate 205 | UserData UserData 206 | UserDataID int 207 | } 208 | 209 | func (r *Route) GetDomains() []string { 210 | return strings.Split(r.DomainExternal, ",") 211 | } 212 | 213 | func (r *Route) loadUser(db *gorm.DB) (utils.User, error) { 214 | var userData UserData 215 | if err := db.Model(r).Related(&userData).Error; err != nil { 216 | helperLogger.Session("route-load-user").Error("load-user-data", err) 217 | return utils.User{}, err 218 | } 219 | 220 | return LoadUser(userData) 221 | } 222 | 223 | type Certificate struct { 224 | gorm.Model 225 | RouteId uint 226 | Domain string 227 | CertURL string 228 | Certificate []byte 229 | Expires time.Time `gorm:"index"` 230 | } 231 | 232 | type RouteManagerIface interface { 233 | Create(instanceId, domain, origin, path string, insecureOrigin bool, forwardedHeaders utils.Headers, forwardCookies bool, tags map[string]string) (*Route, error) 234 | Update(instanceId string, domain, origin string, path string, insecureOrigin bool, forwardedHeaders utils.Headers, forwardCookies bool) error 235 | Get(instanceId string) (*Route, error) 236 | Poll(route *Route) error 237 | Disable(route *Route) error 238 | Renew(route *Route) error 239 | RenewAll() 240 | DeleteOrphanedCerts() 241 | GetDNSInstructions(route *Route) ([]string, error) 242 | } 243 | 244 | type RouteManager struct { 245 | logger lager.Logger 246 | iam utils.IamIface 247 | cloudFront utils.DistributionIface 248 | settings config.Settings 249 | db *gorm.DB 250 | } 251 | 252 | func NewManager( 253 | logger lager.Logger, 254 | iam utils.IamIface, 255 | cloudFront utils.DistributionIface, 256 | settings config.Settings, 257 | db *gorm.DB, 258 | ) RouteManager { 259 | return RouteManager{ 260 | logger: logger, 261 | iam: iam, 262 | cloudFront: cloudFront, 263 | settings: settings, 264 | db: db, 265 | } 266 | } 267 | 268 | func (m *RouteManager) Create(instanceId, domain, origin, path string, insecureOrigin bool, forwardedHeaders utils.Headers, forwardCookies bool, tags map[string]string) (*Route, error) { 269 | route := &Route{ 270 | InstanceId: instanceId, 271 | State: Provisioning, 272 | DomainExternal: domain, 273 | Origin: origin, 274 | Path: path, 275 | InsecureOrigin: insecureOrigin, 276 | } 277 | 278 | lsession := m.logger.Session("route-manager-create-route", lager.Data{ 279 | "instance-id": instanceId, 280 | }) 281 | 282 | user, err := LoadRandomUser(m.db, m.settings.UserIdPool) 283 | if err != nil { 284 | lsession.Error("load-random-user", err) 285 | return nil, err 286 | } 287 | 288 | clients, err := m.getClients(&user, m.settings) 289 | if err != nil { 290 | lsession.Error("get-clients", err) 291 | return nil, err 292 | } 293 | 294 | userData, err := SaveUser(m.db, user) 295 | if err != nil { 296 | lsession.Error("save-user", err) 297 | return nil, err 298 | } 299 | 300 | route.UserData = userData 301 | 302 | if err := m.ensureChallenges(route, clients[acme.HTTP01], false); err != nil { 303 | lsession.Error("ensure-challenges-http-01", err) 304 | return nil, err 305 | } 306 | 307 | dist, err := m.cloudFront.Create(instanceId, make([]string, 0), origin, path, insecureOrigin, forwardedHeaders, forwardCookies, tags) 308 | if err != nil { 309 | lsession.Error("create-cloudfront-instance", err) 310 | return nil, err 311 | } 312 | 313 | route.DomainInternal = *dist.DomainName 314 | route.DistId = *dist.Id 315 | 316 | if err := m.db.Create(route).Error; err != nil { 317 | lsession.Error("db-create-route", err) 318 | return nil, err 319 | } 320 | 321 | return route, nil 322 | } 323 | 324 | func (m *RouteManager) Get(instanceId string) (*Route, error) { 325 | route := Route{} 326 | 327 | lsession := m.logger.Session("route-manager-get") 328 | 329 | result := m.db.First(&route, Route{InstanceId: instanceId}) 330 | if result.Error == nil { 331 | lsession.Error("db-get-first-route", result.Error) 332 | return &route, nil 333 | } else if result.RecordNotFound() { 334 | lsession.Error("db-record-not-found", brokerapi.ErrInstanceDoesNotExist) 335 | return nil, brokerapi.ErrInstanceDoesNotExist 336 | } else { 337 | lsession.Error("db-generic-error", result.Error) 338 | return nil, result.Error 339 | } 340 | } 341 | 342 | func (m *RouteManager) Update(instanceId, domain, origin string, path string, insecureOrigin bool, forwardedHeaders utils.Headers, forwardCookies bool) error { 343 | lsession := m.logger.Session("route-manager-update", lager.Data{ 344 | "instance-id": instanceId, 345 | }) 346 | 347 | // Get current route 348 | route, err := m.Get(instanceId) 349 | if err != nil { 350 | lsession.Error("get-route", err) 351 | return err 352 | } 353 | 354 | // When we update the CloudFront distribution we should use the old domains 355 | // until we have a valid certificate in IAM. 356 | // CloudFront gets updated when we receive new certificates during Poll 357 | oldDomainsForCloudFront := route.GetDomains() 358 | 359 | // Override any settings that are new or different. 360 | if domain != "" { 361 | route.DomainExternal = domain 362 | } 363 | if origin != "" { 364 | route.Origin = origin 365 | } 366 | if path != route.Path { 367 | route.Path = path 368 | } 369 | if insecureOrigin != route.InsecureOrigin { 370 | route.InsecureOrigin = insecureOrigin 371 | } 372 | 373 | // Update the distribution 374 | dist, err := m.cloudFront.Update(route.DistId, oldDomainsForCloudFront, 375 | route.Origin, route.Path, route.InsecureOrigin, forwardedHeaders, forwardCookies) 376 | if err != nil { 377 | lsession.Error("cloudfront-update", err) 378 | return err 379 | } 380 | route.State = Provisioning 381 | 382 | // Get the updated domain name and dist id. 383 | route.DomainInternal = *dist.DomainName 384 | route.DistId = *dist.Id 385 | 386 | if domain != "" { 387 | user, err := route.loadUser(m.db) 388 | if err != nil { 389 | lsession.Error("load-user", err) 390 | return err 391 | } 392 | 393 | clients, err := m.getClients(&user, m.settings) 394 | if err != nil { 395 | lsession.Error("get-clients", err) 396 | return err 397 | } 398 | 399 | route.ChallengeJSON = []byte("") 400 | if err := m.ensureChallenges(route, clients[acme.HTTP01], false); err != nil { 401 | lsession.Error("ensure-challenges", err) 402 | return err 403 | } 404 | } 405 | 406 | // Save the database. 407 | result := m.db.Save(route) 408 | if result.Error != nil { 409 | lsession.Error("db-save-route", err) 410 | return result.Error 411 | } 412 | return nil 413 | } 414 | 415 | func (m *RouteManager) Poll(r *Route) error { 416 | switch r.State { 417 | case Provisioning: 418 | return m.updateProvisioning(r) 419 | case Deprovisioning: 420 | return m.updateDeprovisioning(r) 421 | default: 422 | return nil 423 | } 424 | } 425 | 426 | func (m *RouteManager) Disable(r *Route) error { 427 | lsession := m.logger.Session("route-manager-disable", lager.Data{ 428 | "instance-id": r.InstanceId, 429 | }) 430 | 431 | if r.DistId != "" { 432 | err := m.cloudFront.Disable(r.DistId) 433 | if err != nil { 434 | lsession.Error("cloudfront-disable", err) 435 | return err 436 | } 437 | } 438 | 439 | r.State = Deprovisioning 440 | if err := m.db.Save(r).Error; err != nil { 441 | lsession.Error("db-save-error", err) 442 | } 443 | 444 | return nil 445 | } 446 | 447 | func (m *RouteManager) stillActive(r *Route) error { 448 | 449 | lsession := m.logger.Session("route-manager-still-active", lager.Data{ 450 | "instance-id": r.InstanceId, 451 | }) 452 | 453 | lsession.Info("starting-canary-check", lager.Data{ 454 | "route": r, 455 | "instance-id": r.InstanceId, 456 | }) 457 | 458 | session := session.New(aws.NewConfig().WithRegion(m.settings.AwsDefaultRegion)) 459 | 460 | s3client := s3.New(session) 461 | 462 | target := path.Join(".well-known", "acme-challenge", "canary", r.InstanceId) 463 | 464 | input := s3.PutObjectInput{ 465 | Bucket: aws.String(m.settings.Bucket), 466 | Key: aws.String(target), 467 | Body: strings.NewReader(r.InstanceId), 468 | } 469 | 470 | if m.settings.ServerSideEncryption != "" { 471 | input.ServerSideEncryption = aws.String(m.settings.ServerSideEncryption) 472 | } 473 | 474 | if _, err := s3client.PutObject(&input); err != nil { 475 | lsession.Error("s3-put-object", err) 476 | return err 477 | } 478 | 479 | insecureClient := &http.Client{ 480 | Transport: &http.Transport{ 481 | TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, 482 | }, 483 | } 484 | 485 | for _, domain := range r.GetDomains() { 486 | resp, err := insecureClient.Get("https://" + path.Join(domain, target)) 487 | if err != nil { 488 | lsession.Error("insecure-client-get", err) 489 | return err 490 | } 491 | 492 | defer resp.Body.Close() 493 | body, err := ioutil.ReadAll(resp.Body) 494 | if err != nil { 495 | lsession.Error("read-response-body", err) 496 | return err 497 | } 498 | 499 | if string(body) != r.InstanceId { 500 | err := fmt.Errorf("Canary check failed for %s; expected %s, got %s", domain, r.InstanceId, string(body)) 501 | lsession.Error("", err) 502 | return err 503 | } 504 | } 505 | 506 | return nil 507 | } 508 | 509 | func (m *RouteManager) Renew(r *Route) error { 510 | lsession := m.logger.Session("route-manager-renew", lager.Data{ 511 | "instance-id": r.InstanceId, 512 | }) 513 | 514 | err := m.stillActive(r) 515 | if err != nil { 516 | err := fmt.Errorf("Route is not active, skipping renewal: %v", err) 517 | lsession.Error("still-active", err) 518 | return err 519 | } 520 | 521 | var certRow Certificate 522 | err = m.db.Model(r).Related(&certRow, "Certificate").Error 523 | if err != nil { 524 | lsession.Error("db-find-related-cert", err) 525 | return err 526 | } 527 | 528 | user, err := r.loadUser(m.db) 529 | if err != nil { 530 | lsession.Error("db-load-user", err) 531 | return err 532 | } 533 | 534 | clients, err := m.getClients(&user, m.settings) 535 | if err != nil { 536 | lsession.Error("get-clients", err) 537 | return err 538 | } 539 | 540 | certResource, errs := clients[acme.HTTP01].ObtainCertificate(r.GetDomains(), true, nil, false) 541 | if len(errs) > 0 { 542 | err := fmt.Errorf("Error(s) obtaining certificate: %v", errs) 543 | lsession.Error("obtain-certificate", err) 544 | return err 545 | } 546 | 547 | expires, err := acme.GetPEMCertExpiration(certResource.Certificate) 548 | if err != nil { 549 | lsession.Error("get-pem-cert-expiry", err) 550 | return err 551 | } 552 | 553 | if err := m.deployCertificate(*r, certResource); err != nil { 554 | lsession.Error("deploy-certificate", err) 555 | return err 556 | } 557 | 558 | certRow.Domain = certResource.Domain 559 | certRow.CertURL = certResource.CertURL 560 | certRow.Certificate = certResource.Certificate 561 | certRow.Expires = expires 562 | if err := m.db.Save(&certRow).Error; err != nil { 563 | lsession.Error("db-save-cert", err) 564 | return err 565 | } 566 | return nil 567 | } 568 | 569 | func (m *RouteManager) DeleteOrphanedCerts() { 570 | // iterate over all distributions and record all certificates in-use by these distributions 571 | activeCerts := make(map[string]string) 572 | 573 | m.cloudFront.ListDistributions(func(distro cloudfront.DistributionSummary) bool { 574 | if distro.ViewerCertificate.IAMCertificateId != nil { 575 | activeCerts[*distro.ViewerCertificate.IAMCertificateId] = *distro.ARN 576 | } 577 | return true 578 | }) 579 | 580 | // iterate over all certificates 581 | m.iam.ListCertificates(func(cert iam.ServerCertificateMetadata) bool { 582 | 583 | // delete any certs not attached to a distribution that are older than 24 hours 584 | _, active := activeCerts[*cert.ServerCertificateId] 585 | if !active && time.Since(*cert.UploadDate).Hours() > 24 { 586 | m.logger.Info("cleaning-orphaned-certificate", lager.Data{ 587 | "cert": cert, 588 | }) 589 | 590 | err := m.iam.DeleteCertificate(*cert.ServerCertificateName) 591 | if err != nil { 592 | m.logger.Error("iam-delete-certificate", err, lager.Data{ 593 | "cert": cert, 594 | }) 595 | } 596 | } 597 | 598 | return true 599 | }) 600 | } 601 | 602 | func (m *RouteManager) RenewAll() { 603 | routes := []Route{} 604 | 605 | lsession := m.logger.Session("route-manager-renew-all") 606 | 607 | m.logger.Info("Looking for routes that are expiring soon") 608 | 609 | m.db.Having( 610 | "max(expires) < now() + interval '70 days'", 611 | ).Group( 612 | "routes.id", 613 | ).Where( 614 | "state = ?", string(Provisioned), 615 | ).Joins( 616 | "join certificates on routes.id = certificates.route_id", 617 | ).Find(&routes) 618 | 619 | m.logger.Info("routes-needing-renewal", lager.Data{ 620 | "num-routes": len(routes), 621 | }) 622 | 623 | for _, route := range routes { 624 | err := m.Renew(&route) 625 | if err != nil { 626 | lsession.Error("renew-error", err, lager.Data{ 627 | "domain": route.DomainExternal, 628 | "origin": route.Origin, 629 | }) 630 | } else { 631 | lsession.Info("renew-success", lager.Data{ 632 | "domain": route.DomainExternal, 633 | "origin": route.Origin, 634 | }) 635 | } 636 | } 637 | } 638 | 639 | func (m *RouteManager) getClients(user *utils.User, settings config.Settings) (map[acme.Challenge]*acme.Client, error) { 640 | session := session.New(aws.NewConfig().WithRegion(settings.AwsDefaultRegion)) 641 | 642 | lsession := m.logger.Session("route-manager-get-clients") 643 | 644 | var err error 645 | 646 | clients := map[acme.Challenge]*acme.Client{} 647 | clients[acme.HTTP01], err = utils.NewClient(settings, user, s3.New(session), []acme.Challenge{acme.TLSSNI01, acme.DNS01}) 648 | if err != nil { 649 | lsession.Error("new-client-http-builder", err) 650 | return clients, err 651 | } 652 | clients[acme.DNS01], err = utils.NewClient(settings, user, s3.New(session), []acme.Challenge{acme.TLSSNI01, acme.HTTP01}) 653 | if err != nil { 654 | lsession.Error("new-client-dns-builder", err) 655 | return clients, err 656 | } 657 | 658 | return clients, nil 659 | } 660 | 661 | func (m *RouteManager) updateProvisioning(r *Route) error { 662 | lsession := m.logger.Session("route-manager-update-provisioning", lager.Data{ 663 | "instance-id": r.InstanceId, 664 | }) 665 | 666 | user, err := r.loadUser(m.db) 667 | if err != nil { 668 | lsession.Error("load-user", err) 669 | return err 670 | } 671 | 672 | clients, err := m.getClients(&user, m.settings) 673 | if err != nil { 674 | lsession.Error("get-clients", err) 675 | return err 676 | } 677 | 678 | // Handle provisioning instances created before DNS challenge 679 | if err := m.ensureChallenges(r, clients[acme.HTTP01], true); err != nil { 680 | lsession.Error("ensure-challenges", err) 681 | return err 682 | } 683 | 684 | if m.checkDistribution(r) { 685 | var challenges []acme.AuthorizationResource 686 | if err := json.Unmarshal(r.ChallengeJSON, &challenges); err != nil { 687 | lsession.Error("challenge-unmarshall", err) 688 | return err 689 | } 690 | if errs := m.solveChallenges(clients, challenges); len(errs) > 0 { 691 | errstr := fmt.Errorf("Error(s) solving challenges: %v", errs) 692 | lsession.Error("solve-challenges", errstr) 693 | return errstr 694 | } 695 | 696 | cert, err := clients[acme.HTTP01].RequestCertificate(challenges, true, nil, false) 697 | if err != nil { 698 | lsession.Error("request-certificate-http-01", err) 699 | return err 700 | } 701 | 702 | expires, err := acme.GetPEMCertExpiration(cert.Certificate) 703 | if err != nil { 704 | lsession.Error("get-cert-expiry", err) 705 | return err 706 | } 707 | if err := m.deployCertificate(*r, cert); err != nil { 708 | lsession.Error("deploy-certificate", err) 709 | r.State = Failed 710 | if dbErr := m.db.Save(r).Error; dbErr != nil { 711 | newErr := fmt.Errorf("error saving state to db: %s while processing error deploying certificate: %s", dbErr, err) 712 | return newErr 713 | } 714 | return err 715 | } 716 | 717 | certRow := Certificate{ 718 | Domain: cert.Domain, 719 | CertURL: cert.CertURL, 720 | Certificate: cert.Certificate, 721 | Expires: expires, 722 | } 723 | if err := m.db.Create(&certRow).Error; err != nil { 724 | lsession.Error("db-create-cert", err) 725 | return err 726 | } 727 | 728 | r.State = Provisioned 729 | r.Certificate = certRow 730 | if err := m.db.Save(r).Error; err != nil { 731 | lsession.Error("db-save-cert", err) 732 | return err 733 | } 734 | return nil 735 | } 736 | 737 | lsession.Info("distribution-provisioning") 738 | return nil 739 | } 740 | 741 | func (m *RouteManager) updateDeprovisioning(r *Route) error { 742 | lsession := m.logger.Session("route-manager-update-deprovisioning") 743 | 744 | if r.DistId != "" { 745 | deleted, err := m.cloudFront.Delete(r.DistId) 746 | if err != nil { 747 | lsession.Error("cloudfront-delete", err) 748 | return err 749 | } 750 | 751 | if deleted { 752 | r.State = Deprovisioned 753 | if err := m.db.Save(r).Error; err != nil { 754 | lsession.Error("db-save-delete-state", err) 755 | } 756 | } 757 | } 758 | 759 | r.State = Deprovisioned 760 | if err := m.db.Save(r).Error; err != nil { 761 | lsession.Error("db-save-delete-state", err) 762 | } 763 | 764 | return nil 765 | } 766 | 767 | func (m *RouteManager) checkDistribution(r *Route) bool { 768 | dist, err := m.cloudFront.Get(r.DistId) 769 | if err != nil { 770 | m.logger.Session("check-distribution").Error("cloudfront-get", err) 771 | return false 772 | } 773 | 774 | return *dist.Status == "Deployed" && *dist.DistributionConfig.Enabled 775 | } 776 | 777 | func (m *RouteManager) solveChallenges(clients map[acme.Challenge]*acme.Client, challenges []acme.AuthorizationResource) map[string]error { 778 | errs := make(chan map[string]error) 779 | 780 | for _, client := range clients { 781 | go func(client *acme.Client) { 782 | errs <- client.SolveChallenges(challenges) 783 | }(client) 784 | } 785 | 786 | var failures map[string]error 787 | for challenge := range clients { 788 | failures = <-errs 789 | m.logger.Info("solve-challenges", lager.Data{ 790 | "challenge": challenge, 791 | "failures": failures, 792 | }) 793 | if len(failures) == 0 { 794 | return failures 795 | } 796 | } 797 | 798 | return failures 799 | } 800 | 801 | func (m *RouteManager) deployCertificate(route Route, cert acme.CertificateResource) error { 802 | lsession := m.logger.Session("deploy-certificate", lager.Data{ 803 | "instance-id": route.InstanceId, 804 | }) 805 | 806 | expires, err := acme.GetPEMCertExpiration(cert.Certificate) 807 | if err != nil { 808 | lsession.Error("get-cert-expiry", err) 809 | return err 810 | } 811 | 812 | name := fmt.Sprintf("cdn-route-%s-%s", route.InstanceId, expires.Format("2006-01-02_15-04-05")) 813 | 814 | m.logger.Info("Uploading certificate to IAM", lager.Data{"name": name}) 815 | 816 | certId, err := m.iam.UploadCertificate(name, cert) 817 | if err != nil { 818 | lsession.Error("iam-upload-certificate", err) 819 | return err 820 | } 821 | 822 | return m.cloudFront.SetCertificateAndCname(route.DistId, certId, route.GetDomains()) 823 | } 824 | 825 | func (m *RouteManager) ensureChallenges(route *Route, client *acme.Client, update bool) error { 826 | lsession := m.logger.Session("ensure-challenges", lager.Data{ 827 | "instance-id": route.InstanceId, 828 | }) 829 | 830 | if len(route.ChallengeJSON) == 0 { 831 | challenges, errs := client.GetChallenges(route.GetDomains()) 832 | if len(errs) > 0 { 833 | err := fmt.Errorf("Error(s) getting challenges: %v", errs) 834 | lsession.Error("get-challenges", err) 835 | return err 836 | } 837 | 838 | var err error 839 | route.ChallengeJSON, err = json.Marshal(challenges) 840 | if err != nil { 841 | lsession.Error("json-marshal-challenge", err) 842 | return err 843 | } 844 | 845 | if update { 846 | err := m.db.Save(route).Error 847 | lsession.Error("db-save-route-challenge", err) 848 | return err 849 | } 850 | return nil 851 | } 852 | 853 | return nil 854 | } 855 | 856 | func (m *RouteManager) GetDNSInstructions(route *Route) ([]string, error) { 857 | var instructions []string 858 | var challenges []acme.AuthorizationResource 859 | 860 | lsession := m.logger.Session("get-dns-instructions", lager.Data{ 861 | "instance-id": route.InstanceId, 862 | }) 863 | 864 | user, err := route.loadUser(m.db) 865 | if err != nil { 866 | lsession.Error("load-user", err) 867 | return instructions, err 868 | } 869 | 870 | if err := json.Unmarshal(route.ChallengeJSON, &challenges); err != nil { 871 | lsession.Error("json-unmarshal-challenge", err) 872 | return instructions, err 873 | } 874 | for _, auth := range challenges { 875 | for _, challenge := range auth.Body.Challenges { 876 | if challenge.Type == acme.DNS01 { 877 | keyAuth, err := acme.GetKeyAuthorization(challenge.Token, user.GetPrivateKey()) 878 | if err != nil { 879 | lsession.Error("get-key-authorization", err) 880 | return instructions, err 881 | } 882 | fqdn, value, ttl := acme.DNS01Record(auth.Domain, keyAuth) 883 | instructions = append(instructions, fmt.Sprintf("name: %s, value: %s, ttl: %d", fqdn, value, ttl)) 884 | } 885 | } 886 | } 887 | return instructions, nil 888 | } 889 | --------------------------------------------------------------------------------