├── .env.dev ├── .gitignore ├── .travis.yml ├── Dockerfile ├── Dockerfile.tester ├── LICENSE ├── README.md ├── cmd └── certificator │ └── main.go ├── docker-compose.yml ├── domains.yml ├── fixtures ├── pebble.minica.pem └── update-dns.sh ├── go.mod ├── go.sum ├── pkg ├── acme │ └── acme.go ├── certificate │ ├── certificate.go │ └── certificate_test.go ├── config │ ├── config.go │ └── config_test.go └── vault │ ├── vault.go │ └── vault_test.go └── test └── integration_test.go /.env.dev: -------------------------------------------------------------------------------- 1 | ACME_ACCOUNT_EMAIL=test@test.com 2 | ACME_DNS_CHALLENGE_PROVIDER=exec 3 | ACME_DNS_PROPAGATION_REQUIREMENT=false 4 | ACME_REREGISTER_ACCOUNT=true 5 | ACME_SERVER_URL=https://pebble:14000/dir 6 | DNS_ADDRESS=challtestsrv:8053 7 | CERTIFICATOR_DOMAINS=mydomain.com,example.com 8 | CERTIFICATOR_RENEW_BEFORE_DAYS=30 9 | CERTIFICATOR_DOMAINS_FILE=/app/fixtures/domains.yml 10 | ENVIRONMENT=dev 11 | EXEC_PATH=./fixtures/update-dns.sh 12 | LEGO_CA_CERTIFICATES=./fixtures/pebble.minica.pem 13 | LOG_FORMAT=JSON 14 | LOG_LEVEL=DEBUG 15 | VAULT_ADDR=http://vault:8200 16 | VAULT_DEV_ROOT_TOKEN_ID=supersecret 17 | VAULT_KV_STORAGE_PATH=secret/data/certificator/ 18 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Binaries for programs and plugins 2 | *.exe 3 | *.exe~ 4 | *.dll 5 | *.so 6 | *.dylib 7 | 8 | # Test binary, built with `go test -c` 9 | *.test 10 | 11 | # Output of the go coverage tool, specifically when used with LiteIDE 12 | *.out 13 | 14 | # Dependency directories (remove the comment below to include it) 15 | # vendor/ 16 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: go 2 | go: 3 | - "1.16.x" 4 | 5 | services: 6 | - docker 7 | 8 | install: true 9 | 10 | before_install: 11 | - curl -sSfL https://raw.githubusercontent.com/golangci/golangci-lint/master/install.sh | sh -s -- -b $(go env GOPATH)/bin v1.43.0 12 | script: 13 | - golangci-lint run -v --timeout 5m0s 14 | - docker-compose build tester && docker-compose run --rm tester go test ./...; docker-compose down 15 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # =========== 2 | # Build stage 3 | # =========== 4 | FROM golang:1.16.3-alpine3.13 AS builder 5 | 6 | WORKDIR /code 7 | 8 | # Pre-install dependencies to cache them as a separate image layer 9 | COPY go.mod go.sum ./ 10 | RUN go mod download 11 | 12 | # Build 13 | COPY . /code 14 | RUN go build -o certificator ./cmd/certificator 15 | 16 | # =========== 17 | # Final stage 18 | # =========== 19 | FROM alpine:3.13.0 20 | 21 | WORKDIR /app 22 | RUN apk --no-cache add curl 23 | 24 | COPY ./fixtures /app/fixtures 25 | COPY ./domains.yml /app/fixtures/domains.yml 26 | 27 | COPY --from=builder /code/certificator . 28 | 29 | CMD [ "./certificator" ] 30 | -------------------------------------------------------------------------------- /Dockerfile.tester: -------------------------------------------------------------------------------- 1 | FROM golang:1.16.3-alpine3.13 2 | 3 | WORKDIR /code 4 | 5 | ENV CGO_ENABLED 0 6 | 7 | # This is necessary to execute fixtures/update-dns.sh script 8 | RUN apk --no-cache add curl 9 | 10 | # Pre-install dependencies to cache them as a separate image layer 11 | COPY go.mod go.sum ./ 12 | RUN go mod download 13 | 14 | # Build 15 | COPY . /code 16 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 vinted 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # certificator 2 | 3 | The tool that requests certificates from ACME supporting CA, solves DNS challenges, and stores certificates in Vault. 4 | 5 | ## Usage 6 | 7 | 1. Add domains that need certificates to domains.yml file 8 | 1. Set necessary environment variables (see [configuration](#Configuration)) 9 | 1. Run certificator 10 | 1. Find certificates in Vault 11 | 12 | ## Configuration 13 | 14 | Certificator reads most configuration parameters from environment variables. 15 | They are defined in [pkg/config/config.go](pkg/config/config.go) Config struct 16 | 17 | Configuration variables: 18 | - `ACME_ACCOUNT_EMAIL` - email used in certificate retrieval process. **Required** 19 | - `ACME_DNS_CHALLENGE_PROVIDER` - DNS challenge provider. Available providers can be found [here](https://go-acme.github.io/lego/dns/#dns-providers). **Required** 20 | - `ACME_DNS_PROPAGATION_REQUIREMENT` - if set to true, requires complete DNS record propagation before stating that challenge is solved. Default: true 21 | - `ACME_REREGISTER_ACCOUNT` - if set to true, allows registering an account with CA. This should be set to true for the first use. When credentials are stored in Vault, you can set this to false to avoid accidental registrations. Default: false 22 | - `ACME_SERVER_URL` - ACME directory location. Default: https://acme-staging-v02.api.letsencrypt.org/directory 23 | - `VAULT_APPROLE_ROLE_ID` - role ID for Vault approle authentication method. **Required in prod env** 24 | - `VAULT_APPROLE_SECRET_ID` - secret ID for Vault approle authentication method. **Required in prod env** 25 | - `VAULT_KV_STORAGE_PATH` - path in Vault KV storage where certificator stores certificates and account data. Default: secret/data/certificator/ 26 | - `VAULT_ADDR` sets vault address, example: "http://localhost:8200". **Required** 27 | - `LOG_FORMAT` - logging format, supported formats - JSON and LOGFMT. Default: JSON 28 | - `LOG_LEVEL` - logging level, supported levels - DEBUG, INFO, WARN, ERROR, FATAL. Default: INFO. 29 | - `DNS_ADDRESS` - DNS server address that is used to check challenge DNS record propagation. Default: 127.0.0.1:53 30 | - `ENVIRONMENT` - sets an environment where the certificator is running. If the environment is dev it uses token set in `VAULT_DEV_ROOT_TOKEN_ID` env variable to authenticate in Vault. If the environment is prod it uses an approle authentication method. Default: prod 31 | - `CERTIFICATOR_DOMAINS_FILE` - path to a file where domains are defined. Default: /code/domains.yml 32 | - `CERTIFICATOR_RENEW_BEFORE_DAYS` - set how many validity days should certificate have remaining before renewal. Default: 30 33 | 34 | #### CNAME 35 | 36 | - `LEGO_EXPERIMENTAL_CNAME_SUPPORT` boolean value which enables CNAME support. When `true`, it tries to resolve `_acme-challenge.` and if it finds a CNAME record for that request it solves the challenge for the CNAME record value. Example: 37 | 38 | ``` 39 | If it finds this record: 40 | CNAME _acme_challenge.test.com -> test.com.challenges.test.com 41 | it creates TXT record in challenges.test.com zone: 42 | TXT test.com.challenges.test.com -> 43 | CA will verify domain ownership following the same scheme 44 | ``` 45 | 46 | This allows giving this tool a token with access rights limited to a single DNS zone. 47 | 48 | #### Domains file 49 | 50 | Domains that the certificator should retrieve certificates for should be defined in this file in YAML format. An example file is in [domains.yml](domains.yml). 51 | 52 | Every item in the array under the `domains` key results in a certificate. The first domain in an array item is used for the CommonName field of the certificate, all other domains are added using the Subject Alternate Names extension. Domains in a single array item are separated by commas. The first domain is also used as a key in the Vault KV store. 53 | 54 | ## Tests 55 | 56 | This project contains unit and integration tests. To run them follow the instructions 57 | 58 | #### Integration tests 59 | 60 | Files related to integration tests lie in directory `test`. 61 | It relies on several components: pebble, vault, challtestsrv. 62 | 63 | Steps to run it: 64 | 65 | 1. Build container that runs tests: 66 | `docker-compose build tester` 67 | 1. Run tests: 68 | - only integration tests: 69 | `docker-compose run --rm tester go test ./test/...` 70 | - all tests: 71 | `docker-compose run --rm tester go test ./...` 72 | 1. Check results 73 | 1. Bring down testing infrastructure 74 | `docker-compose down` 75 | 76 | #### Unit tests 77 | 78 | Unit tests can be run without any dependencies, simply execute: 79 | `go test ./pkg/...` 80 | -------------------------------------------------------------------------------- /cmd/certificator/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "strings" 5 | 6 | legoLog "github.com/go-acme/lego/v4/log" 7 | "github.com/sirupsen/logrus" 8 | "github.com/vinted/certificator/pkg/acme" 9 | "github.com/vinted/certificator/pkg/certificate" 10 | "github.com/vinted/certificator/pkg/config" 11 | "github.com/vinted/certificator/pkg/vault" 12 | ) 13 | 14 | func main() { 15 | logger := logrus.New() 16 | legoLog.Logger = logger 17 | 18 | cfg, err := config.LoadConfig() 19 | if err != nil { 20 | logger.Fatal(err) 21 | } 22 | 23 | switch cfg.Log.Format { 24 | case "JSON": 25 | logger.SetFormatter(&logrus.JSONFormatter{}) 26 | case "LOGFMT": 27 | logger.SetFormatter(&logrus.TextFormatter{}) 28 | } 29 | 30 | switch cfg.Log.Level { 31 | case "DEBUG": 32 | logger.SetLevel(logrus.DebugLevel) 33 | case "INFO": 34 | logger.SetLevel(logrus.InfoLevel) 35 | case "WARN": 36 | logger.SetLevel(logrus.WarnLevel) 37 | case "ERROR": 38 | logger.SetLevel(logrus.ErrorLevel) 39 | case "FATAL": 40 | logger.SetLevel(logrus.FatalLevel) 41 | } 42 | 43 | vaultClient, err := vault.NewVaultClient(cfg.Vault.ApproleRoleID, 44 | cfg.Vault.ApproleSecretID, cfg.Environment, cfg.Vault.KVStoragePath, logger) 45 | if err != nil { 46 | logger.Fatal(err) 47 | } 48 | 49 | acmeClient, err := acme.NewClient(cfg.Acme.AccountEmail, cfg.Acme.ServerURL, 50 | cfg.Acme.ReregisterAccount, vaultClient, logger) 51 | if err != nil { 52 | logger.Fatal(err) 53 | } 54 | 55 | var failedDomains []string 56 | 57 | for _, dom := range cfg.Domains { 58 | allDomains := strings.Split(dom, ",") 59 | mainDomain := allDomains[0] 60 | cert, err := certificate.GetCertificate(mainDomain, vaultClient) 61 | if err != nil { 62 | failedDomains = append(failedDomains, mainDomain) 63 | logger.Error(err) 64 | continue 65 | } 66 | logger.Infof("checking certificate for %s", mainDomain) 67 | 68 | needsReissuing, err := certificate.NeedsReissuing(cert, allDomains, cfg.RenewBeforeDays, logger) 69 | if err != nil { 70 | failedDomains = append(failedDomains, mainDomain) 71 | logger.Error(err) 72 | continue 73 | } 74 | 75 | if needsReissuing { 76 | logger.Infof("obtaining certificate for %s", mainDomain) 77 | err := certificate.ObtainCertificate(acmeClient, vaultClient, allDomains, 78 | cfg.DNSAddress, cfg.Acme.DNSChallengeProvider, cfg.Acme.DNSPropagationRequirement) 79 | if err != nil { 80 | failedDomains = append(failedDomains, mainDomain) 81 | logger.Error(err) 82 | continue 83 | } 84 | } else { 85 | logger.Infof("certificate for %s is up to date, skipping renewal", mainDomain) 86 | } 87 | } 88 | 89 | if len(failedDomains) > 0 { 90 | logger.Fatalf("Failed to renew certificates for: %v", failedDomains) 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3' 2 | services: 3 | pebble: 4 | image: letsencrypt/pebble:latest 5 | command: pebble -config /test/config/pebble-config.json -strict -dnsserver challtestsrv:8053 6 | # ports: 7 | # - "14000:14000" # HTTPS ACME API 8 | # - "15000:15000" # HTTPS Management API 9 | challtestsrv: 10 | image: letsencrypt/pebble-challtestsrv:latest 11 | depends_on: 12 | - pebble 13 | command: pebble-challtestsrv -http01 "" -tlsalpn01 "" -dns01 ":8053" 14 | ports: 15 | - "8055:8055" # HTTP Management API 16 | - "8053:8053/tcp" # DNS API 17 | - "8053:8053/udp" # DNS API 18 | vault: 19 | image: vault:1.6.2 20 | # ports: 21 | # - 8200:8200 22 | environment: 23 | - VAULT_DEV_ROOT_TOKEN_ID=supersecret 24 | app: 25 | build: . 26 | depends_on: 27 | - pebble 28 | - challtestsrv 29 | - vault 30 | env_file: 31 | - .env.dev 32 | 33 | tester: 34 | build: 35 | context: . 36 | dockerfile: Dockerfile.tester 37 | depends_on: 38 | - pebble 39 | - challtestsrv 40 | - vault 41 | command: ["true"] # do not start the container when `docker-compose up` is executed 42 | -------------------------------------------------------------------------------- /domains.yml: -------------------------------------------------------------------------------- 1 | domains: 2 | - 'mydomain.com,www.mydomain.com' 3 | - 'example.com' 4 | -------------------------------------------------------------------------------- /fixtures/pebble.minica.pem: -------------------------------------------------------------------------------- 1 | -----BEGIN CERTIFICATE----- 2 | MIIDCTCCAfGgAwIBAgIIJOLbes8sTr4wDQYJKoZIhvcNAQELBQAwIDEeMBwGA1UE 3 | AxMVbWluaWNhIHJvb3QgY2EgMjRlMmRiMCAXDTE3MTIwNjE5NDIxMFoYDzIxMTcx 4 | MjA2MTk0MjEwWjAgMR4wHAYDVQQDExVtaW5pY2Egcm9vdCBjYSAyNGUyZGIwggEi 5 | MA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQC5WgZNoVJandj43kkLyU50vzCZ 6 | alozvdRo3OFiKoDtmqKPNWRNO2hC9AUNxTDJco51Yc42u/WV3fPbbhSznTiOOVtn 7 | Ajm6iq4I5nZYltGGZetGDOQWr78y2gWY+SG078MuOO2hyDIiKtVc3xiXYA+8Hluu 8 | 9F8KbqSS1h55yxZ9b87eKR+B0zu2ahzBCIHKmKWgc6N13l7aDxxY3D6uq8gtJRU0 9 | toumyLbdzGcupVvjbjDP11nl07RESDWBLG1/g3ktJvqIa4BWgU2HMh4rND6y8OD3 10 | Hy3H8MY6CElL+MOCbFJjWqhtOxeFyZZV9q3kYnk9CAuQJKMEGuN4GU6tzhW1AgMB 11 | AAGjRTBDMA4GA1UdDwEB/wQEAwIChDAdBgNVHSUEFjAUBggrBgEFBQcDAQYIKwYB 12 | BQUHAwIwEgYDVR0TAQH/BAgwBgEB/wIBADANBgkqhkiG9w0BAQsFAAOCAQEAF85v 13 | d40HK1ouDAtWeO1PbnWfGEmC5Xa478s9ddOd9Clvp2McYzNlAFfM7kdcj6xeiNhF 14 | WPIfaGAi/QdURSL/6C1KsVDqlFBlTs9zYfh2g0UXGvJtj1maeih7zxFLvet+fqll 15 | xseM4P9EVJaQxwuK/F78YBt0tCNfivC6JNZMgxKF59h0FBpH70ytUSHXdz7FKwix 16 | Mfn3qEb9BXSk0Q3prNV5sOV3vgjEtB4THfDxSz9z3+DepVnW3vbbqwEbkXdk3j82 17 | 2muVldgOUgTwK8eT+XdofVdntzU/kzygSAtAQwLJfn51fS1GvEcYGBc1bDryIqmF 18 | p9BI7gVKtWSZYegicA== 19 | -----END CERTIFICATE----- 20 | -------------------------------------------------------------------------------- /fixtures/update-dns.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | ACTION=$1 4 | DOMAIN=$2 5 | CHALLENGE_VALUE=$3 6 | 7 | if test "$ACTION" = "present"; 8 | then 9 | curl -s -X POST -d "{\"host\":\"$DOMAIN\", \"value\": \"$CHALLENGE_VALUE\"}" http://challtestsrv:8055/set-txt 10 | elif test "$ACTION" = "cleanup"; 11 | then 12 | curl -s -X POST -d "{\"host\":\"$DOMAIN\", \"value\": \"$CHALLENGE_VALUE\"}" http://challtestsrv:8055/clear-txt 13 | fi 14 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/vinted/certificator 2 | 3 | go 1.16 4 | 5 | require ( 6 | github.com/go-acme/lego v2.7.2+incompatible 7 | github.com/go-acme/lego/v4 v4.5.3 8 | github.com/go-test/deep v1.0.8 // indirect 9 | github.com/gorilla/mux v1.8.0 10 | github.com/hashicorp/hcl v1.0.1-vault-3 // indirect 11 | github.com/hashicorp/vault/api v1.3.1 12 | github.com/kelseyhightower/envconfig v1.4.0 13 | github.com/pkg/errors v0.9.1 14 | github.com/sirupsen/logrus v1.8.1 15 | github.com/thanos-io/thanos v0.24.0 16 | gopkg.in/yaml.v2 v2.4.0 17 | ) 18 | 19 | replace k8s.io/client-go => k8s.io/client-go v0.20.4 20 | -------------------------------------------------------------------------------- /pkg/acme/acme.go: -------------------------------------------------------------------------------- 1 | package acme 2 | 3 | import ( 4 | "crypto" 5 | "crypto/ecdsa" 6 | "crypto/elliptic" 7 | "crypto/rand" 8 | "encoding/json" 9 | "errors" 10 | 11 | "github.com/go-acme/lego/v4/certcrypto" 12 | "github.com/go-acme/lego/v4/lego" 13 | "github.com/go-acme/lego/v4/registration" 14 | "github.com/sirupsen/logrus" 15 | "github.com/vinted/certificator/pkg/vault" 16 | ) 17 | 18 | // User represents a users local saved credentials. 19 | // Implements registration.User interface 20 | type User struct { 21 | Email string 22 | Registration *registration.Resource 23 | key crypto.PrivateKey 24 | } 25 | 26 | // GetEmail returns the email address for the account. 27 | func (u *User) GetEmail() string { 28 | return u.Email 29 | } 30 | 31 | // GetRegistration returns the server registration. 32 | func (u User) GetRegistration() *registration.Resource { 33 | return u.Registration 34 | } 35 | 36 | // GetPrivateKey returns the private account key. 37 | func (u *User) GetPrivateKey() crypto.PrivateKey { 38 | return u.key 39 | } 40 | 41 | // NewClient initializes acme client and returns 42 | func NewClient( 43 | email, serverURL string, 44 | reregister bool, 45 | vault *vault.VaultClient, 46 | logger *logrus.Logger) (*lego.Client, error) { 47 | 48 | acc, err := setupAccount(email, reregister, vault, logger) 49 | if err != nil { 50 | return nil, err 51 | } 52 | 53 | client, err := setupClient(acc, serverURL, logger) 54 | if err != nil { 55 | return nil, err 56 | } 57 | 58 | return registerAccount(acc, client, vault, serverURL, reregister, logger) 59 | } 60 | 61 | func setupClient( 62 | acc *User, 63 | serverURL string, 64 | logger *logrus.Logger) (*lego.Client, error) { 65 | 66 | logger.Debug("setting up client") 67 | 68 | clientConfig := lego.NewConfig(acc) 69 | clientConfig.CADirURL = serverURL 70 | 71 | client, err := lego.NewClient(clientConfig) 72 | if err != nil { 73 | return nil, err 74 | } 75 | 76 | return client, nil 77 | } 78 | 79 | func setupAccount( 80 | email string, 81 | reregister bool, 82 | vault *vault.VaultClient, 83 | logger *logrus.Logger) (*User, error) { 84 | 85 | var acc *User 86 | 87 | secrets, err := vault.KVRead("account") 88 | if err != nil { 89 | return nil, err 90 | } 91 | 92 | if secrets == nil { 93 | acc, err = newAccount(email, reregister, vault, logger) 94 | if err != nil { 95 | return nil, err 96 | } 97 | 98 | return acc, nil 99 | } 100 | 101 | if accountInfo, ok := secrets["account"].(string); ok { 102 | err := json.Unmarshal([]byte(accountInfo), &acc) 103 | if err != nil { 104 | return nil, err 105 | } 106 | 107 | acc.key, err = getAccountKey(reregister, vault, logger) 108 | if err != nil { 109 | return nil, err 110 | } 111 | return acc, nil 112 | } 113 | 114 | return nil, errors.New("failed reading account from vault") 115 | } 116 | 117 | func newAccount(email string, reregister bool, vault *vault.VaultClient, logger *logrus.Logger) (*User, error) { 118 | key, err := getAccountKey(reregister, vault, logger) 119 | if err != nil { 120 | return nil, err 121 | } 122 | 123 | return &User{ 124 | Email: email, 125 | key: key, 126 | }, nil 127 | } 128 | 129 | func getAccountKey(reregister bool, vault *vault.VaultClient, logger *logrus.Logger) (crypto.PrivateKey, error) { 130 | var ( 131 | keyDecoded crypto.PrivateKey 132 | err error 133 | ) 134 | 135 | secrets, err := vault.KVRead("key") 136 | if err != nil { 137 | return nil, err 138 | } 139 | 140 | if secrets != nil { 141 | if key, ok := secrets["pem"].(string); ok { 142 | return certcrypto.ParsePEMPrivateKey([]byte(key)) 143 | } else { 144 | return nil, errors.New("key read from vault cannot be used") 145 | } 146 | } else if reregister { 147 | keyDecoded, err = ecdsa.GenerateKey(elliptic.P256(), rand.Reader) 148 | if err != nil { 149 | return nil, err 150 | } 151 | keyEncoded := certcrypto.PEMEncode(keyDecoded) 152 | return keyDecoded, saveKey(keyEncoded, vault, logger) 153 | } else { 154 | return nil, errors.New("key not found and re-registering is disabled") 155 | } 156 | } 157 | 158 | func registerAccount(acc *User, client *lego.Client, vault *vault.VaultClient, 159 | serverURL string, reregister bool, logger *logrus.Logger) (*lego.Client, error) { 160 | logger.Debug("checking client registration") 161 | _, err := client.Registration.QueryRegistration() 162 | if err != nil { 163 | logger.Warn("registration not found") 164 | 165 | client, err = recoverAccount(acc, client, vault, serverURL, reregister, logger) 166 | if err != nil { 167 | return nil, err 168 | } 169 | } else { 170 | logger.Debug("account is registered correctly") 171 | } 172 | 173 | return client, nil 174 | } 175 | 176 | func recoverAccount(acc *User, client *lego.Client, vault *vault.VaultClient, 177 | serverURL string, reregister bool, logger *logrus.Logger) (*lego.Client, error) { 178 | // Try to resolve registration by private key 179 | reg, err := client.Registration.ResolveAccountByKey() 180 | 181 | if err != nil { 182 | logger.Warn("could not resolve account by key") 183 | 184 | if reregister { 185 | // Reset local registration data and reregister 186 | logger.Info("reregistering account") 187 | acc.Registration = nil 188 | client, err = setupClient(acc, serverURL, logger) 189 | if err != nil { 190 | return nil, err 191 | } 192 | 193 | reg, err = client.Registration.Register(registration.RegisterOptions{TermsOfServiceAgreed: true}) 194 | if err != nil { 195 | return nil, err 196 | } 197 | acc.Registration = reg 198 | } else { 199 | return nil, errors.New("account registration not found and re-registering is disabled") 200 | } 201 | } else { 202 | logger.Info("account resolved by key") 203 | acc.Registration = reg 204 | } 205 | 206 | // Save new account registration 207 | return client, saveAccount(acc, vault, logger) 208 | } 209 | 210 | func saveAccount(account *User, vault *vault.VaultClient, logger *logrus.Logger) error { 211 | logger.Info("saving ACME account") 212 | jsonAccount, err := json.Marshal(account) 213 | if err != nil { 214 | return err 215 | } 216 | 217 | return vault.KVWrite("account", map[string]string{"account": string(jsonAccount)}) 218 | } 219 | 220 | func saveKey(key []byte, vault *vault.VaultClient, logger *logrus.Logger) error { 221 | logger.Info("saving ACME account key") 222 | 223 | return vault.KVWrite("key", map[string]string{"pem": string(key)}) 224 | } 225 | -------------------------------------------------------------------------------- /pkg/certificate/certificate.go: -------------------------------------------------------------------------------- 1 | package certificate 2 | 3 | import ( 4 | "crypto/x509" 5 | "fmt" 6 | "time" 7 | 8 | "github.com/go-acme/lego/certcrypto" 9 | "github.com/go-acme/lego/v4/certificate" 10 | "github.com/go-acme/lego/v4/challenge/dns01" 11 | "github.com/go-acme/lego/v4/lego" 12 | "github.com/go-acme/lego/v4/providers/dns" 13 | "github.com/sirupsen/logrus" 14 | "github.com/vinted/certificator/pkg/vault" 15 | ) 16 | 17 | // ObtainCertificate gets certificate and stores it in Vault KV store 18 | func ObtainCertificate(client *lego.Client, vault *vault.VaultClient, domains []string, 19 | dnsAddr, challengeProvider string, propagationReq bool) error { 20 | provider, err := dns.NewDNSChallengeProviderByName(challengeProvider) 21 | if err != nil { 22 | return err 23 | } 24 | 25 | if propagationReq { 26 | err = client.Challenge.SetDNS01Provider(provider, 27 | dns01.AddRecursiveNameservers([]string{dnsAddr})) 28 | } else { 29 | err = client.Challenge.SetDNS01Provider(provider, 30 | dns01.AddRecursiveNameservers([]string{dnsAddr}), 31 | dns01.DisableCompletePropagationRequirement()) 32 | } 33 | if err != nil { 34 | return err 35 | } 36 | 37 | request := certificate.ObtainRequest{ 38 | Domains: domains, 39 | Bundle: true, 40 | } 41 | certificate, err := client.Certificate.Obtain(request) 42 | if err != nil { 43 | return err 44 | } 45 | 46 | return storeCertificateInVault(domains[0], certificate, vault) 47 | } 48 | 49 | // GetCertificate reads certificate from Vault KV store and parses it 50 | func GetCertificate(domain string, vault *vault.VaultClient) (*x509.Certificate, error) { 51 | secrets, err := vault.KVRead(vaultCertLocation(domain)) 52 | if err != nil { 53 | return nil, err 54 | } 55 | if cert, ok := secrets["certificate"].(string); ok { 56 | parsedCert, err := certcrypto.ParsePEMBundle([]byte(cert)) 57 | if err != nil { 58 | return nil, err 59 | } 60 | return parsedCert[0], nil 61 | } 62 | 63 | return nil, nil 64 | } 65 | 66 | // NeedsReissuing checks if certificate domains and required domains match 67 | // and if certificate expiration date is earlier than configured in config.Cfg.RenewBeforeDays 68 | func NeedsReissuing(certificate *x509.Certificate, domains []string, days int, logger *logrus.Logger) (bool, error) { 69 | if certificate == nil { 70 | return true, nil 71 | } 72 | 73 | if certificate.IsCA { 74 | return true, fmt.Errorf("certificate bundle for %s starts with a CA certificate", domains[0]) 75 | } 76 | 77 | // Check if all domains are in certificate DNS names 78 | if !arraysEqual(domains, certificate.DNSNames) { 79 | logger.Printf("certificate %s domains changed, it needs reissuing", domains[0]) 80 | logger.Printf("certificate domains: %v", certificate.DNSNames) 81 | logger.Printf("required domains: %v", domains) 82 | return true, nil 83 | } 84 | 85 | notAfter := int(time.Until(certificate.NotAfter).Hours() / 24.0) 86 | logger.Printf("certificate is valid for %v more days", notAfter) 87 | if notAfter > days { 88 | logger.Printf("certificate for %s does not need renewing", domains[0]) 89 | 90 | return false, nil 91 | } 92 | 93 | return true, nil 94 | } 95 | 96 | func arraysEqual(array1 []string, array2 []string) bool { 97 | if len(array1) != len(array2) { 98 | return false 99 | } 100 | 101 | for _, v := range array1 { 102 | if !arrayContains(array2, v) { 103 | return false 104 | } 105 | } 106 | 107 | return true 108 | } 109 | 110 | func arrayContains(array []string, element string) bool { 111 | for _, a := range array { 112 | if a == element { 113 | return true 114 | } 115 | } 116 | return false 117 | } 118 | 119 | func vaultCertLocation(domain string) string { 120 | return "certificates/" + domain 121 | } 122 | 123 | func storeCertificateInVault(domain string, certs *certificate.Resource, vault *vault.VaultClient) error { 124 | payload := map[string]string{"certificate": string(certs.Certificate), 125 | "private_key": string(certs.PrivateKey), 126 | "issuer_certificate": string(certs.IssuerCertificate)} 127 | 128 | return vault.KVWrite(vaultCertLocation(domain), payload) 129 | } 130 | -------------------------------------------------------------------------------- /pkg/certificate/certificate_test.go: -------------------------------------------------------------------------------- 1 | package certificate 2 | 3 | import ( 4 | "crypto/rand" 5 | "crypto/rsa" 6 | "crypto/x509" 7 | "math/big" 8 | "testing" 9 | "time" 10 | 11 | "github.com/sirupsen/logrus" 12 | "github.com/thanos-io/thanos/pkg/testutil" 13 | ) 14 | 15 | func TestNeedsReissuing(t *testing.T) { 16 | template := &x509.Certificate{ 17 | IsCA: false, 18 | SerialNumber: big.NewInt(1234), 19 | NotBefore: time.Now(), 20 | DNSNames: []string{"test.com", "www.test.com", "*.test.com"}, 21 | NotAfter: time.Now().AddDate(0 /* years */, 3 /* months */, 0 /* days */), 22 | } 23 | logger := logrus.New() 24 | 25 | certificate := generateCert(t, template) 26 | 27 | for _, tcase := range []struct { 28 | tcaseName string 29 | requiredDomains []string 30 | certificate *x509.Certificate 31 | renewDays int 32 | expectedResult bool 33 | }{ 34 | { 35 | tcaseName: "certificate expires after three months (90 days), renewDays = 30, required domains correct", 36 | requiredDomains: []string{"test.com", "www.test.com", "*.test.com"}, 37 | certificate: certificate, 38 | renewDays: 30, 39 | expectedResult: false, 40 | }, 41 | { 42 | tcaseName: "certificate expires after three months (90 days), renewDays = 100, required domains correct", 43 | requiredDomains: []string{"test.com", "www.test.com", "*.test.com"}, 44 | certificate: certificate, 45 | renewDays: 100, 46 | expectedResult: true, 47 | }, 48 | { 49 | tcaseName: "nil certificate, renew days 30, required domains correct", 50 | requiredDomains: []string{"test.com", "www.test.com", "*.test.com"}, 51 | certificate: nil, 52 | renewDays: 30, 53 | expectedResult: true, 54 | }, 55 | { 56 | tcaseName: "certificate expires after three months (90 days), renewDays = 30, fewer required domains than certificate has", 57 | requiredDomains: []string{"www.test.com", "*.test.com"}, 58 | certificate: certificate, 59 | renewDays: 30, 60 | expectedResult: true, 61 | }, 62 | { 63 | tcaseName: "certificate expires after three months (90 days), renewDays = 30, more required domains than certificate has", 64 | requiredDomains: []string{"test.com", "www.test.com", "*.test.com", "additional.test.com"}, 65 | certificate: certificate, 66 | renewDays: 30, 67 | expectedResult: true, 68 | }, 69 | { 70 | tcaseName: "certificate expires after three months (90 days), renewDays = 30, different required domains than certificate has", 71 | requiredDomains: []string{"test.com", "www.test.com", "different.test.com"}, 72 | certificate: certificate, 73 | renewDays: 30, 74 | expectedResult: true, 75 | }, 76 | } { 77 | t.Run(tcase.tcaseName, func(t *testing.T) { 78 | result, err := NeedsReissuing(tcase.certificate, tcase.requiredDomains, tcase.renewDays, logger) 79 | testutil.Ok(t, err) 80 | testutil.Equals(t, tcase.expectedResult, result) 81 | }) 82 | } 83 | } 84 | 85 | func generateCert(t *testing.T, template *x509.Certificate) *x509.Certificate { 86 | privatekey, err := rsa.GenerateKey(rand.Reader, 512) 87 | testutil.Ok(t, err) 88 | 89 | publickey := &privatekey.PublicKey 90 | 91 | // create a self-signed certificate. template = parent 92 | var parent = template 93 | cert, err := x509.CreateCertificate(rand.Reader, template, parent, publickey, privatekey) 94 | testutil.Ok(t, err) 95 | 96 | parsedCert, _ := x509.ParseCertificate(cert) 97 | 98 | return parsedCert 99 | } 100 | -------------------------------------------------------------------------------- /pkg/config/config.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "io/ioutil" 5 | "os" 6 | 7 | "github.com/kelseyhightower/envconfig" 8 | "github.com/pkg/errors" 9 | "gopkg.in/yaml.v2" 10 | ) 11 | 12 | // Acme contains acme related configuration parameters 13 | type Acme struct { 14 | AccountEmail string `envconfig:"ACME_ACCOUNT_EMAIL" required:"true"` 15 | DNSChallengeProvider string `envconfig:"ACME_DNS_CHALLENGE_PROVIDER" required:"true"` 16 | DNSPropagationRequirement bool `envconfig:"ACME_DNS_PROPAGATION_REQUIREMENT" default:"true"` 17 | ReregisterAccount bool `envconfig:"ACME_REREGISTER_ACCOUNT" default:"false"` 18 | ServerURL string `envconfig:"ACME_SERVER_URL" default:"https://acme-staging-v02.api.letsencrypt.org/directory"` 19 | } 20 | 21 | // Vault contains vault related configuration parameters 22 | type Vault struct { 23 | ApproleRoleID string `envconfig:"VAULT_APPROLE_ROLE_ID"` 24 | ApproleSecretID string `envconfig:"VAULT_APPROLE_SECRET_ID"` 25 | KVStoragePath string `envconfig:"VAULT_KV_STORAGE_PATH" default:"secret/data/certificator/"` 26 | } 27 | 28 | type Log struct { 29 | Format string `envconfig:"LOG_FORMAT" default:"JSON"` 30 | Level string `envconfig:"LOG_LEVEL" default:"INFO"` 31 | } 32 | 33 | // Config contains all configuration parameters 34 | type Config struct { 35 | Acme Acme 36 | Vault Vault 37 | Log Log 38 | DNSAddress string `envconfig:"DNS_ADDRESS" default:"127.0.0.1:53"` 39 | Environment string `envconfig:"ENVIRONMENT" default:"prod"` 40 | DomainsFile string `envconfig:"CERTIFICATOR_DOMAINS_FILE" default:"/code/domains.yml"` 41 | RenewBeforeDays int `envconfig:"CERTIFICATOR_RENEW_BEFORE_DAYS" default:"30"` 42 | Domains []string `yaml:"domains"` 43 | } 44 | 45 | // LoadConfig loads configuration options to variable 46 | func LoadConfig() (Config, error) { 47 | var cfg Config 48 | err := envconfig.Process("", &cfg) 49 | if err != nil { 50 | return Config{}, errors.Wrapf(err, "failed getting config from env") 51 | } 52 | 53 | f, err := os.Open(cfg.DomainsFile) 54 | if err != nil { 55 | return Config{}, errors.Wrapf(err, "opening %s", cfg.DomainsFile) 56 | } 57 | 58 | content, err := ioutil.ReadAll(f) 59 | if err != nil { 60 | return Config{}, errors.Wrapf(err, "reading content of %s", cfg.DomainsFile) 61 | } 62 | 63 | if err := yaml.Unmarshal(content, &cfg); err != nil { 64 | return Config{}, errors.Wrapf(err, "parsing %s", cfg.DomainsFile) 65 | } 66 | 67 | return cfg, err 68 | } 69 | -------------------------------------------------------------------------------- /pkg/config/config_test.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "os" 5 | "strconv" 6 | "testing" 7 | 8 | "github.com/thanos-io/thanos/pkg/testutil" 9 | ) 10 | 11 | func TestDefaultConfig(t *testing.T) { 12 | resetEnvVars() 13 | 14 | var expectedConf = Config{ 15 | Acme: Acme{ 16 | AccountEmail: "test@test.com", 17 | DNSChallengeProvider: "exec", 18 | DNSPropagationRequirement: true, 19 | ReregisterAccount: false, 20 | ServerURL: "https://acme-staging-v02.api.letsencrypt.org/directory", 21 | }, 22 | Vault: Vault{ 23 | ApproleRoleID: "", 24 | ApproleSecretID: "", 25 | KVStoragePath: "secret/data/certificator/", 26 | }, 27 | Log: Log{ 28 | Format: "JSON", 29 | Level: "INFO", 30 | }, 31 | DNSAddress: "127.0.0.1:53", 32 | Environment: "prod", 33 | DomainsFile: "../../domains.yml", 34 | Domains: []string{"mydomain.com,www.mydomain.com", "example.com"}, 35 | RenewBeforeDays: 30, 36 | } 37 | 38 | conf, err := LoadConfig() 39 | testutil.Ok(t, err) 40 | testutil.Equals(t, expectedConf, conf) 41 | } 42 | 43 | func TestConfig(t *testing.T) { 44 | var ( 45 | reregisterAcc bool = true 46 | acmeServerURL string = "http://someserver" 47 | dnsChallengeProvider string = "other" 48 | dnsPropagationReq bool = false 49 | vaultRoleID string = "role" 50 | vaultSecretID string = "secret" 51 | vaultKVStorePath string = "secret/path" 52 | logFormat string = "LOGFMT" 53 | logLevel string = "DEBUG" 54 | dnsAddress string = "1.1.1.1:53" 55 | environment string = "test" 56 | renewBeforeDays int = 60 57 | 58 | expectedConf = Config{ 59 | Acme: Acme{ 60 | AccountEmail: "test@test.com", 61 | DNSChallengeProvider: dnsChallengeProvider, 62 | DNSPropagationRequirement: dnsPropagationReq, 63 | ReregisterAccount: reregisterAcc, 64 | ServerURL: acmeServerURL, 65 | }, 66 | Vault: Vault{ 67 | ApproleRoleID: vaultRoleID, 68 | ApproleSecretID: vaultSecretID, 69 | KVStoragePath: vaultKVStorePath, 70 | }, 71 | Log: Log{ 72 | Format: logFormat, 73 | Level: logLevel, 74 | }, 75 | DNSAddress: dnsAddress, 76 | Environment: environment, 77 | DomainsFile: "../../domains.yml", 78 | Domains: []string{"mydomain.com,www.mydomain.com", "example.com"}, 79 | RenewBeforeDays: renewBeforeDays, 80 | } 81 | ) 82 | 83 | resetEnvVars() 84 | 85 | os.Setenv("ACME_REREGISTER_ACCOUNT", strconv.FormatBool(reregisterAcc)) 86 | os.Setenv("ACME_SERVER_URL", acmeServerURL) 87 | os.Setenv("ACME_DNS_CHALLENGE_PROVIDER", dnsChallengeProvider) 88 | os.Setenv("ACME_DNS_PROPAGATION_REQUIREMENT", strconv.FormatBool(dnsPropagationReq)) 89 | os.Setenv("VAULT_APPROLE_ROLE_ID", vaultRoleID) 90 | os.Setenv("VAULT_APPROLE_SECRET_ID", vaultSecretID) 91 | os.Setenv("VAULT_KV_STORAGE_PATH", vaultKVStorePath) 92 | os.Setenv("LOG_FORMAT", logFormat) 93 | os.Setenv("LOG_LEVEL", logLevel) 94 | os.Setenv("DNS_ADDRESS", dnsAddress) 95 | os.Setenv("ENVIRONMENT", environment) 96 | os.Setenv("CERTIFICATOR_RENEW_BEFORE_DAYS", strconv.Itoa(renewBeforeDays)) 97 | 98 | conf, err := LoadConfig() 99 | testutil.Ok(t, err) 100 | testutil.Equals(t, expectedConf, conf) 101 | } 102 | 103 | func resetEnvVars() { 104 | // Set required env vars 105 | os.Setenv("ACME_ACCOUNT_EMAIL", "test@test.com") 106 | os.Setenv("ACME_DNS_CHALLENGE_PROVIDER", "exec") 107 | os.Setenv("CERTIFICATOR_DOMAINS_FILE", "../../domains.yml") 108 | 109 | for _, key := range []string{"ACME_REREGISTER_ACCOUNT", 110 | "ACME_SERVER_URL", 111 | "VAULT_APPROLE_ROLE_ID", 112 | "VAULT_APPROLE_SECRET_ID", 113 | "VAULT_KV_STORAGE_PATH", 114 | "LOG_FORMAT", 115 | "LOG_LEVEL", 116 | "DNS_ADDRESS", 117 | "ENVIRONMENT", 118 | "CERTIFICATOR_RENEW_BEFORE_DAYS", 119 | } { 120 | os.Unsetenv(key) 121 | } 122 | } 123 | -------------------------------------------------------------------------------- /pkg/vault/vault.go: -------------------------------------------------------------------------------- 1 | package vault 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | 7 | "github.com/hashicorp/vault/api" 8 | "github.com/sirupsen/logrus" 9 | ) 10 | 11 | type VaultClient struct { 12 | client *api.Client 13 | kvPrefix string 14 | logger *logrus.Logger 15 | } 16 | 17 | // NewClient initializes vault client with default configuration. 18 | // It authenticates using approle method (or uses provided token in dev) and returns. 19 | func NewVaultClient(roleID, secretID, env, kvPrefix string, logger *logrus.Logger) (*VaultClient, error) { 20 | client, err := api.NewClient(api.DefaultConfig()) 21 | if err != nil { 22 | return nil, err 23 | } 24 | 25 | if env == "dev" { 26 | client.SetToken(os.Getenv("VAULT_DEV_ROOT_TOKEN_ID")) 27 | } else { 28 | payload := map[string]interface{}{"role_id": roleID, 29 | "secret_id": secretID} 30 | resp, err := client.Logical().Write("auth/approle/login", payload) 31 | if err != nil { 32 | return nil, err 33 | } 34 | 35 | client.SetToken(resp.Auth.ClientToken) 36 | } 37 | 38 | return &VaultClient{client: client, kvPrefix: kvPrefix, logger: logger}, nil 39 | } 40 | 41 | // KVWrite writes value to vault key value v2 storage 42 | func (cl *VaultClient) KVWrite(path string, value map[string]string) error { 43 | fullPath := vaultFullPath(path, cl.kvPrefix) 44 | cl.logger.Infof("Writing to vault path %s", fullPath) 45 | payload := map[string]interface{}{"data": value} 46 | resp, err := cl.client.Logical().Write(fullPath, payload) 47 | if err != nil { 48 | err = fmt.Errorf("failed storing KV value to Vault, got: %v, error: %s", resp, err) 49 | return err 50 | } 51 | 52 | return nil 53 | } 54 | 55 | // KVRead reads data from vault key value storage 56 | func (cl *VaultClient) KVRead(path string) (map[string]interface{}, error) { 57 | fullPath := vaultFullPath(path, cl.kvPrefix) 58 | cl.logger.Infof("reading Vault path: %s", fullPath) 59 | resp, err := cl.client.Logical().Read(fullPath) 60 | if err != nil { 61 | err = fmt.Errorf("failed reading KV from Vault at path: %s, got: %v, error: %s", 62 | fullPath, resp, err) 63 | return nil, err 64 | } 65 | 66 | if resp == nil { 67 | return nil, nil 68 | } 69 | 70 | if value, ok := resp.Data["data"].(map[string]interface{}); ok { 71 | return value, nil 72 | } 73 | 74 | return nil, nil 75 | } 76 | 77 | func vaultFullPath(path string, prefix string) string { 78 | return prefix + path 79 | } 80 | -------------------------------------------------------------------------------- /pkg/vault/vault_test.go: -------------------------------------------------------------------------------- 1 | package vault 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "fmt" 7 | "io" 8 | "net" 9 | "net/http" 10 | "os" 11 | "testing" 12 | 13 | "github.com/gorilla/mux" 14 | "github.com/sirupsen/logrus" 15 | "github.com/thanos-io/thanos/pkg/testutil" 16 | ) 17 | 18 | type loginApprole struct { 19 | SecretID string `json:"secret_id"` 20 | RoleID string `json:"role_id"` 21 | } 22 | 23 | func TestNewVaultClient(t *testing.T) { 24 | var ( 25 | secretID string = "secretIDexample" 26 | roleID string = "roleIDexample" 27 | prodToken string = "secretProdTokensss" 28 | devToken string = "secretDevToken" 29 | ) 30 | 31 | logger := logrus.New() 32 | srv := &http.Server{} 33 | t.Cleanup(func() { 34 | _ = srv.Shutdown(context.TODO()) 35 | }) 36 | smux := mux.NewRouter() 37 | smux.HandleFunc("/v1/auth/approle/login", func(w http.ResponseWriter, r *http.Request) { 38 | defer r.Body.Close() 39 | body, err := io.ReadAll(r.Body) 40 | if err != nil { 41 | w.WriteHeader(500) 42 | _, _ = w.Write([]byte(fmt.Sprintf("error occurred: %s", err.Error()))) 43 | return 44 | } 45 | 46 | var credentials loginApprole 47 | err = json.Unmarshal(body, &credentials) 48 | if err != nil { 49 | w.WriteHeader(500) 50 | _, _ = w.Write([]byte(fmt.Sprintf("error occurred: %s", err.Error()))) 51 | return 52 | } 53 | 54 | content, err := json.Marshal(map[string]interface{}{"auth": map[string]string{"client_token": prodToken}}) 55 | if err != nil { 56 | w.WriteHeader(500) 57 | _, _ = w.Write([]byte(fmt.Sprintf("error occurred: %s", err.Error()))) 58 | return 59 | } 60 | 61 | w.Header().Set("Content-Type", "application/json") 62 | if credentials.RoleID == roleID && credentials.SecretID == secretID { 63 | _, _ = w.Write([]byte(content)) 64 | } else { 65 | w.WriteHeader(403) 66 | _, _ = w.Write([]byte("access denied")) 67 | } 68 | }) 69 | 70 | listener, err := net.Listen("tcp", "127.0.0.1:0") 71 | testutil.Ok(t, err) 72 | 73 | srv.Handler = smux 74 | 75 | srv.Addr = ":0" 76 | go func() { _ = srv.Serve(listener) }() 77 | 78 | os.Setenv("VAULT_ADDR", "http://"+listener.Addr().String()) 79 | os.Setenv("VAULT_DEV_ROOT_TOKEN_ID", devToken) 80 | 81 | for _, tcase := range []struct { 82 | tcaseName string 83 | env string 84 | expectedToken string 85 | }{ 86 | { 87 | tcaseName: "prod environment, token received by approle auth method", 88 | env: "prod", 89 | expectedToken: prodToken, 90 | }, 91 | { 92 | tcaseName: "dev environment, token from env variable", 93 | env: "dev", 94 | expectedToken: devToken, 95 | }, 96 | } { 97 | t.Run(tcase.tcaseName, func(t *testing.T) { 98 | client, err := NewVaultClient(roleID, secretID, tcase.env, "testPrefix", logger) 99 | testutil.Ok(t, err) 100 | testutil.Equals(t, tcase.expectedToken, client.client.Token()) 101 | }) 102 | } 103 | } 104 | -------------------------------------------------------------------------------- /test/integration_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "encoding/json" 5 | "os" 6 | "testing" 7 | "time" 8 | 9 | "github.com/hashicorp/vault/api" 10 | "github.com/sirupsen/logrus" 11 | "github.com/thanos-io/thanos/pkg/testutil" 12 | "github.com/vinted/certificator/pkg/acme" 13 | "github.com/vinted/certificator/pkg/certificate" 14 | "github.com/vinted/certificator/pkg/vault" 15 | ) 16 | 17 | var ( 18 | // vaultDevToken token should be equal to `VAULT_DEV_ROOT_TOKEN_ID` set in vault container 19 | // It should be defined in docker-compoose.yml 20 | vaultDevToken string = "supersecret" 21 | vaultKVPath string = "/secret/data/integration_test/" 22 | acc *acme.User 23 | keyEncoded string 24 | acmeEmail string = "test@test.com" 25 | acmeURL string = "https://pebble:14000/dir" 26 | ) 27 | 28 | func TestMain(m *testing.M) { 29 | //Set necessary ENV variables 30 | os.Setenv("VAULT_ADDR", "http://vault:8200") 31 | os.Setenv("VAULT_DEV_ROOT_TOKEN_ID", vaultDevToken) 32 | // This makes pebble certificate trusted 33 | os.Setenv("LEGO_CA_CERTIFICATES", "../fixtures/pebble.minica.pem") 34 | // This shows where is the exec challenge provider script 35 | os.Setenv("EXEC_PATH", "../fixtures/update-dns.sh") 36 | 37 | os.Exit(m.Run()) 38 | } 39 | 40 | func TestAcmeClientAndAccountSetup(t *testing.T) { 41 | logger := logrus.New() 42 | logger.SetLevel(logrus.WarnLevel) 43 | 44 | // testVaultClient will be used to delete entries from Vault 45 | testVaultClient, err := api.NewClient(api.DefaultConfig()) 46 | testutil.Ok(t, err) 47 | 48 | // Make sure that this variable provides access to vault KV storage 49 | testVaultClient.SetToken(vaultDevToken) 50 | 51 | vaultClient, err := vault.NewVaultClient("", "", "dev", vaultKVPath, logger) 52 | testutil.Ok(t, err) 53 | 54 | // Make sure we are starting in a clean Vault 55 | deleteAccountFromVault(t, testVaultClient) 56 | deleteKeyFromVault(t, testVaultClient) 57 | 58 | // This populates data in Vault, account and key are both present 59 | _, err = acme.NewClient(acmeEmail, acmeURL, true, vaultClient, logger) 60 | testutil.Ok(t, err) 61 | 62 | // Save account and key data from first registration 63 | account, err := vaultClient.KVRead("account") 64 | testutil.Ok(t, err) 65 | accountInfo, ok := account["account"].(string) 66 | testutil.Equals(t, true, ok) 67 | testutil.Ok(t, json.Unmarshal([]byte(accountInfo), &acc)) 68 | 69 | key, err := vaultClient.KVRead("key") 70 | testutil.Ok(t, err) 71 | keyEncoded, ok = key["pem"].(string) 72 | testutil.Equals(t, true, ok) 73 | 74 | for _, tcase := range []struct { 75 | tcaseName string 76 | accountInVault bool 77 | keyInVault bool 78 | reregisteringEnabled bool 79 | expectedErr bool 80 | }{ 81 | { 82 | tcaseName: "Account and key are in Vault, reregistering enabled", 83 | accountInVault: true, 84 | keyInVault: true, 85 | reregisteringEnabled: true, 86 | expectedErr: false, 87 | }, 88 | { 89 | tcaseName: "Account NOT in Vault, key in Vault, reregistering enabled", 90 | accountInVault: false, 91 | keyInVault: true, 92 | reregisteringEnabled: true, 93 | expectedErr: false, 94 | }, 95 | { 96 | tcaseName: "Account and key are NOT in Vault, reregistering enabled", 97 | accountInVault: false, 98 | keyInVault: false, 99 | reregisteringEnabled: true, 100 | expectedErr: false, 101 | }, 102 | { 103 | tcaseName: "Account in Vault, key NOT in Vault, reregistering enabled", 104 | accountInVault: true, 105 | keyInVault: false, 106 | reregisteringEnabled: true, 107 | expectedErr: false, 108 | }, 109 | { 110 | tcaseName: "Account in Vault, key NOT in Vault, reregistering disabled, expecting an error", 111 | accountInVault: true, 112 | keyInVault: false, 113 | reregisteringEnabled: false, 114 | expectedErr: true, 115 | }, 116 | } { 117 | t.Run(tcase.tcaseName, func(t *testing.T) { 118 | if !tcase.accountInVault { 119 | deleteAccountFromVault(t, testVaultClient) 120 | } else { 121 | jsonAccount, err := json.Marshal(acc) 122 | testutil.Ok(t, err) 123 | 124 | err = vaultClient.KVWrite("account", map[string]string{"account": string(jsonAccount)}) 125 | testutil.Ok(t, err) 126 | } 127 | 128 | if !tcase.keyInVault { 129 | deleteKeyFromVault(t, testVaultClient) 130 | } else { 131 | err = vaultClient.KVWrite("key", map[string]string{"pem": keyEncoded}) 132 | testutil.Ok(t, err) 133 | } 134 | 135 | _, err := acme.NewClient(acmeEmail, acmeURL, tcase.reregisteringEnabled, vaultClient, logger) 136 | if tcase.expectedErr { 137 | testutil.NotOk(t, err) 138 | } else { 139 | testutil.Ok(t, err) 140 | } 141 | }) 142 | } 143 | } 144 | 145 | func TestCertificateObtaining(t *testing.T) { 146 | logger := logrus.New() 147 | logger.SetLevel(logrus.WarnLevel) 148 | 149 | vaultClient, err := vault.NewVaultClient("", "", "dev", vaultKVPath, logger) 150 | testutil.Ok(t, err) 151 | 152 | acmeClient, err := acme.NewClient(acmeEmail, acmeURL, true, vaultClient, logger) 153 | testutil.Ok(t, err) 154 | 155 | for _, domain := range []string{"example.com", "test.com", "mydomain.com"} { 156 | err := certificate.ObtainCertificate(acmeClient, vaultClient, []string{domain}, 157 | "challtestsrv:8053", "exec", false) 158 | testutil.Ok(t, err) 159 | 160 | cert, err := certificate.GetCertificate(domain, vaultClient) 161 | testutil.Ok(t, err) 162 | 163 | // Check if certificate is issued recently 164 | testutil.Assert(t, time.Since(cert.NotBefore).Minutes() < 5) 165 | } 166 | } 167 | 168 | func deleteAccountFromVault(t *testing.T, cl *api.Client) { 169 | t.Log("Deleting account from Vault") 170 | _, err := cl.Logical().Delete(vaultKVPath + "account") 171 | testutil.Ok(t, err) 172 | } 173 | 174 | func deleteKeyFromVault(t *testing.T, cl *api.Client) { 175 | t.Log("Deleting key from Vault") 176 | _, err := cl.Logical().Delete(vaultKVPath + "key") 177 | testutil.Ok(t, err) 178 | } 179 | --------------------------------------------------------------------------------