diff --git a/docs/content/en/docs/configuration/command-line.md b/docs/content/en/docs/configuration/command-line.md index ed51bb46b..8d24d3f51 100644 --- a/docs/content/en/docs/configuration/command-line.md +++ b/docs/content/en/docs/configuration/command-line.md @@ -10,10 +10,18 @@ The following command-line options are supported: | Name | Type | Default | Since | |---------------------------------------------------------|----------------------------|-------------------------|-------| +| [`--acme-check-period`](#acme) | time | `24h` | v0.9 | +| [`--acme-election-id`](#acme) | [namespace]/configmap-name | `acme-leader` | v0.9 | +| [`--acme-fail-initial-duration`](#acme) | time | `5m` | v0.9 | +| [`--acme-fail-max-duration`](#acme) | time | `8h` | v0.9 | +| [`--acme-secret-key-name`](#acme) | [namespace]/secret-name | `acme-private-key` | v0.9 | +| [`--acme-server`](#acme) | [true\|false] | `false` | v0.9 | +| [`--acme-token-configmap-name`](#acme) | [namespace]/configmap-name | `acme-validation-tokens` | v0.9 | +| [`--acme-track-tls-annotation`](#acme) | [true\|false] | `false` | v0.9 | | [`--allow-cross-namespace`](#allow-cross-namespace) | [true\|false] | `false` | | | [`--annotation-prefix`](#annotation-prefix) | prefix without `/` | `ingress.kubernetes.io` | v0.8 | -| [`--default-backend-service`](#default-backend-service) | namespace/servicename | (mandatory) | | -| [`--default-ssl-certificate`](#default-ssl-certificate) | namespace/secretname | (mandatory) | | +| [`--default-backend-service`](#default-backend-service) | namespace/servicename | haproxy's 404 page | | +| [`--default-ssl-certificate`](#default-ssl-certificate) | namespace/secretname | fake, auto generated | | | [`--ingress-class`](#ingress-class) | name | `haproxy` | | | [`--kubeconfig`](#kubeconfig) | /path/to/kubeconfig | in cluster config | | | [`--max-old-config-files`](#max-old-config-files) | num of files | `0` | | @@ -28,6 +36,28 @@ The following command-line options are supported: --- +## Acme + +Configures the acme server and other static options used to authorize and sign certificates +against a server which implements the acme protocol, version 2. + +Supported acme command-line options: + +* `--acme-check-period`: interval between checks for expiring certificates. Defaults to `24h`. +* `--acme-election-id`: prefix of the configmap name used to store the leader election data. Only the leader of a haproxy-ingress cluster should start the authorization and sign certificate process. Defaults to `acme-leader`. +* `--acme-fail-initial-duration`: the starting time to wait and retry after a failed authorization and sign process. Defaults to `5m`. +* `--acme-fail-max-duration`: the time between retries of failed authorization will exponentially grow up to the max duration time. Defaults to `8h`. +* `--acme-secret-key-name`: secret name used to store the client private key. Defaults to `acme-private-key`. A new key, hence a new client, is created if the secret does not exist. +* `--acme-server`: mandatory, starts a local server used to answer challenges from the acme environment. This option should be provided on all haproxy-ingress instances to the certificate signing work properly. +* `--acme-token-configmap-name`: the configmap name used to store temporary tokens generated during the challenge. Defaults to `acme-validation-tokens`. Such tokens need to be stored in k8s because any haproxy-ingress instance might receive the request from the acme environment. +* `--acme-track-tls-annotation`: defines if ingress objects with annotation `kubernetes.io/tls-acme: "true"` should also be tracked. Defaults to `false`. + +See also: + +* [acme configuration keys]({{% relref "keys/#acme" %}}) doc, which has also an overview on how acme works on haproxy-ingress + +--- + ## --allow-cross-namespace `--allow-cross-namespace` argument, if added, will allow reading secrets from one namespace to an diff --git a/docs/content/en/docs/configuration/keys.md b/docs/content/en/docs/configuration/keys.md index f1fc8ada7..d8b7e065e 100644 --- a/docs/content/en/docs/configuration/keys.md +++ b/docs/content/en/docs/configuration/keys.md @@ -87,6 +87,11 @@ The table below describes all supported configuration keys. | Configuration key | Data type | Scope | Default value | |------------------------------------------------------|-----------------------------------------|---------|--------------------| +| [`acme-emails`](#acme) | email1,email2,... | Global | | +| [`acme-endpoint`](#acme) | v2-staging | v2 | endpoint | Global | | +| [`acme-expiring`](#acme) | number of days | Global | `30` | +| [`acme-shared`](#acme) | [true\|false] | Global | `false` | +| [`acme-terms-agreed`](#acme) | [true\|false] | Global | `false` | | [`affinity`](#affinity) | affinity type | Backend | | | [`agent-check-addr`](#agent-check) | address for agent checks | Backend | | | [`agent-check-interval`](#agent-check) | time with suffix | Backend | | @@ -116,6 +121,7 @@ The table below describes all supported configuration keys. | [`blue-green-deploy`](#blue-green) | label=value=weight,... | Backend | | | [`blue-green-header`](#blue-green) | `HeaderName:LabelName` pair | Backend | | | [`blue-green-mode`](#blue-green) | [pod\|deploy] | Backend | | +| [`cert-signer`](#acme) | "acme" | Host | | | [`config-backend`](#configuration-snippet) | multiline HAProxy backend config | Backend | | | [`config-defaults`](#configuration-snippet) | multiline HAProxy config for the defaults section | Global | | | [`config-frontend`](#configuration-snippet) | multiline HAProxy frontend config | Global | | @@ -229,6 +235,92 @@ The table below describes all supported configuration keys. | [`waf-mode`](#waf) | [deny\|detect] | Backend | `deny` (if waf is set) | | `whitelist-source-range` | CIDR | Backend | | +--- + +## Acme + +| Configuration key | Scope | Default | Since | +|---------------------|----------|---------|-------| +| `acme-emails` | `Global` | | v0.9 | +| `acme-endpoint` | `Global` | | v0.9 | +| `acme-expiring` | `Global` | `30` | v0.9 | +| `acme-shared` | `Global` | `false` | v0.9 | +| `acme-terms-agreed` | `Global` | `false` | v0.9 | +| `cert-signer` | `Host` | | v0.9 | + +Configures dynamic options used to authorize and sign certificates against a server +which implements the acme protocol, version 2. + +The popular [Let's Encrypt](https://letsencrypt.org) certificate authority implements +acme-v2. + +Supported acme configuration keys: + +* `acme-emails`: mandatory, a comma-separated list of emails used to configure the client account. The account will be updated if this option is changed. +* `acme-endpoint`: mandatory, endpoint of the acme environment. `v2-staging` and `v02-staging` are alias to `https://acme-staging-v02.api.letsencrypt.org`, while `v2` and `v02` are alias to `https://acme-v02.api.letsencrypt.org`. +* `acme-expiring`: how many days before expiring a certificate should be considered old and should be updated. Defaults to `30` days. +* `acme-shared`: defines if another certificate signer is running in the cluster. If `false`, the default value, any request to `/.well-known/acme-challenge/` is sent to the local acme server despite any ingress object configuration. Otherwise, if `true`, a configured ingress object would take precedence. +* `acme-terms-agreed`: mandatory, it should be defined as `true`, otherwise certificates won't be issued. +* `cert-signer`: defines the certificate signer that should be used to authorize and sign new certificates. The only supported value is `"acme"`. Add this config as an annotation in the ingress object that should have its certificate managed by haproxy-ingress and signed by the configured acme environment. The annotation `kubernetes.io/tls-acme: "true"` is also supported if the command-line option `--acme-track-tls-annotation` is used. + +**Minimum setup** + +The command-line option `--acme-server` need to be declared to start the local +server and the work queue used to authorize and sign new certificates. See other +command-line options [here]({{% relref "command-line/#acme" %}}). + +The following configuration keys are mandatory: `acme-emails`, `acme-endpoint`, +`acme-terms-agreed`. + +A cluster-wide permission to `create` and `update` the `secrets` resources should +also be made. + +{{% alert title="Note" %}} +haproxy-ingress need cluster-wide permissions `create` and `update` on resource +`secrets` to store the client private key (new account) and the generated certificate +and its private key. The default clusterrole configuration doesn't provide these +permissions. +{{% /alert %}} + +**How it works** + +All haproxy-ingress instances should declare `--acme-server` +[command-line option]({{% relref "command-line/#acme" %}}), which will start a local +server to answer acme challenges, a work queue to enqueue the domain authorization +and certificate signing, and will also start a leader election to define which +haproxy-ingress instance should perform authorizations and certificate signing. + +The haproxy-ingress leader tracks ingress objects that declares the annotation +`ingress.kubernetes.io/cert-signer` with value `acme` and a configured secret name for +TLS certificate. The annotation `kubernetes.io/tls-acme` with value `"true"` will also +be used if the command-line option `--acme-track-tls-annotation` is declared. The +secret does not need to exist. A new certificate will be issued if the certificate is +old, the secret does not exist or has an invalid certificate, or the domains of the +certificate doesn't cover all the domains configured in the ingress. + +Every `24h` or the duration configured in the `--acme-check-period`, and also when the +leader changes, all the certificates from all the tracked ingress will be verified. The +certificate is also verified whenever the list of the domains or the secret name changes, +so the periodic check will, in fact, only issue new certificates when there is `30` days +or less to the certificate expires. This duration can be changed with `acme-expiring` +configuration key. + +If an authorization fails, the certificate request is re-enqueued to be tried again after +`5m`. This duration can be changed with `--acme-fail-initial-duration` command-line +option. If the request fails again, it will be re-enqueued after the double of the time, +in this case, after `10m`. The duration will exponentially increase up to `8h` or the +duration defined by the command-line option `--acme-fail-max-duration`. The request will +continue in the work queue until it is successfully processed and stored, or when the +ingress object is untracked, either removing the annotation, removing the secret name or +removing the ingress object itself. + +See also: + +* [acme command-line options]({{% relref "command-line/#acme" %}}) doc. + +--- + + ## Affinity | Configuration key | Scope | Default | Since | diff --git a/pkg/acme/client.go b/pkg/acme/client.go new file mode 100644 index 000000000..e919cc5f7 --- /dev/null +++ b/pkg/acme/client.go @@ -0,0 +1,207 @@ +/* +Copyright 2019 The HAProxy Ingress Controller Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package acme + +import ( + "context" + "crypto" + "crypto/rand" + "crypto/rsa" + "crypto/x509" + "encoding/pem" + "fmt" + "reflect" + "strings" + + "github.com/jcmoraisjr/haproxy-ingress/pkg/acme/x/acme" + "github.com/jcmoraisjr/haproxy-ingress/pkg/types" + "github.com/jcmoraisjr/haproxy-ingress/pkg/version" +) + +const ( + acmeChallengeHTTP01 = "http-01" + acmeErrAcctDoesNotExist = "urn:ietf:params:acme:error:accountDoesNotExist" +) + +var ( + acmeUserAgent = "haproxy-ingress/" + version.RELEASE +) + +// NewClient ... +func NewClient(logger types.Logger, resolver ClientResolver, account *Account) (Client, error) { + key, err := resolver.GetKey() + if err != nil { + return nil, err + } + emails := strings.Split(account.Emails, ",") + contact := make([]string, len(emails)) + for i, email := range emails { + contact[i] = "mailto:" + email + } + client := &client{ + client: &acme.Client{ + DirectoryURL: account.Endpoint + "/directory", + Key: key, + UserAgent: acmeUserAgent, + }, + ctx: context.Background(), + contact: contact, + endpoint: account.Endpoint, + logger: logger, + resolver: resolver, + termsAgreed: account.TermsAgreed, + } + if err := client.ensureAccount(); err != nil { + return nil, err + } + return client, nil +} + +// Account ... +type Account struct { + Emails string + Endpoint string + TermsAgreed bool +} + +// ClientResolver ... +type ClientResolver interface { + GetKey() (crypto.Signer, error) + SetToken(domain string, uri, token string) error +} + +// Client ... +type Client interface { + Sign(dnsnames []string) (crt, key []byte, err error) +} + +type client struct { + client *acme.Client + contact []string + ctx context.Context + endpoint string + logger types.Logger + resolver ClientResolver + termsAgreed bool +} + +func (c *client) ensureAccount() error { + if acct, err := c.client.GetAccount(c.ctx); err != nil { + acmeErr, ok := err.(*acme.Error) + if ok && acmeErr.Type == acmeErrAcctDoesNotExist { + _, err = c.client.CreateAccount(c.ctx, &acme.Account{ + Contact: c.contact, + TermsAgreed: c.termsAgreed, + }) + if err != nil { + return err + } + c.logger.Info("acme: terms agreed, new account created on %s", c.endpoint) + } else { + return err + } + } else if !reflect.DeepEqual(acct.Contact, c.contact) { + c.logger.InfoV(2, "acme: changing contact from %+v to %+v", acct.Contact, c.contact) + acct.Contact = c.contact + if _, err := c.client.UpdateAccount(c.ctx, acct); err == nil { + c.logger.Info("acme: contact info updated to %s", strings.Join(c.contact, ",")) + } else { + c.logger.Warn("acme: error trying to update contact info: %v", err) + } + } else { + c.logger.Info("acme: client account successfully retrieved") + } + return nil +} + +func (c *client) Sign(dnsnames []string) (crt, key []byte, err error) { + if len(dnsnames) == 0 { + return crt, key, fmt.Errorf("dnsnames is empty") + } + order, err := c.client.CreateOrder(c.ctx, acme.NewOrder(dnsnames...)) + if err != nil { + return crt, key, err + } + if err := c.authorize(dnsnames, order); err != nil { + return crt, key, err + } + csrTemplate := &x509.CertificateRequest{} + csrTemplate.Subject.CommonName = dnsnames[0] + csrTemplate.DNSNames = dnsnames + return c.signRequest(order, csrTemplate) +} + +func (c *client) authorize(dnsnames []string, order *acme.Order) error { + for _, authStr := range order.Authorizations { + auth, err := c.client.GetAuthorization(c.ctx, authStr) + if err != nil { + return err + } + for _, challenge := range auth.Challenges { + if challenge.Type == acmeChallengeHTTP01 { + checkURI := c.client.HTTP01ChallengePath(challenge.Token) + checkRes, err := c.client.HTTP01ChallengeResponse(challenge.Token) + if err != nil { + return err + } + if err := c.resolver.SetToken(auth.Identifier.Value, checkURI, checkRes); err != nil { + return err + } + _, err = c.client.AcceptChallenge(c.ctx, challenge) + if err != nil { + return err + } + _, err = c.client.WaitAuthorization(c.ctx, challenge.URL) + _ = c.resolver.SetToken(auth.Identifier.Value, checkURI, "") + if err != nil { + if acmeErr, ok := err.(acme.AuthorizationError); ok { + // acme client returns an empty Identifier.Value on acmeErr.Authorization + return fmt.Errorf("acme: authorization error: domain=%s status=%s", auth.Identifier.Value, acmeErr.Authorization.Status) + } + return err + } + } + } + } + return nil +} + +func (c *client) signRequest(order *acme.Order, csrTemplate *x509.CertificateRequest) (crt, key []byte, err error) { + keys, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + return crt, key, err + } + csr, err := x509.CreateCertificateRequest(rand.Reader, csrTemplate, keys) + if err != nil { + return crt, key, err + } + rawCerts, err := c.client.FinalizeOrder(c.ctx, order.FinalizeURL, csr) + if err != nil { + return crt, key, err + } + key = pem.EncodeToMemory(&pem.Block{ + Type: "RSA PRIVATE KEY", + Bytes: x509.MarshalPKCS1PrivateKey(keys), + }) + for _, rawCert := range rawCerts { + crt = append(crt, pem.EncodeToMemory(&pem.Block{ + Type: "CERTIFICATE", + Bytes: rawCert, + })...) + } + return crt, key, nil +} diff --git a/pkg/acme/client_test.go b/pkg/acme/client_test.go new file mode 100644 index 000000000..fd3569cd9 --- /dev/null +++ b/pkg/acme/client_test.go @@ -0,0 +1,89 @@ +/* +Copyright 2019 The HAProxy Ingress Controller Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package acme + +import ( + "crypto" + "crypto/x509" + "encoding/base64" + "fmt" + "io/ioutil" + "testing" + "time" + + types_helper "github.com/jcmoraisjr/haproxy-ingress/pkg/types/helper_test" +) + +// Update the following keys to run the test. The challenge will be saved in /tmp/out, +// the test will wait 20s to continue and validate the challenge. +const ( + // Add here an email, a domain you have access to and the single-line base64 encoding of a client private key in DER format. + // Email should be valid and a new account will be created if the key does not exist yet. + // Optional, nothing will be done if any value is missing. + // + // DO NOT COMMIT+PUSH THE CLIENT KEY! + clientkey = `` + email = `` + domain = `` +) + +func TestSign(t *testing.T) { + if clientkey == "" || email == "" || domain == "" { + return + } + c := setup(t) + defer c.teardown() + resolver := &clientResolver{logger: c.logger} + client, err := NewClient(c.logger, resolver, &Account{ + Endpoint: "https://acme-staging-v02.api.letsencrypt.org", + Emails: email, + TermsAgreed: true, + }) + if err != nil { + t.Errorf("error creating acme client: %v", err) + } + // TODO test resulting crt + // TODO debug/fine logging in the Sign() steps + _, _, err = client.Sign([]string{domain}) + if err != nil { + t.Errorf("error signing certificate: %v", err) + } + // This will only success after the first run - the message changes when the account is created. + // No problem, run the test again and everything will be fine + c.logger.CompareLogging("INFO acme: client account successfully retrieved") +} + +type clientResolver struct { + logger *types_helper.LoggerMock +} + +func (c *clientResolver) GetKey() (crypto.Signer, error) { + der, _ := base64.StdEncoding.DecodeString(clientkey) + key, _ := x509.ParsePKCS1PrivateKey(der) + return key, nil +} + +func (c *clientResolver) SetToken(domain string, uri, token string) error { + if token == "" { + return nil + } + out := fmt.Sprintf("%s%s = %s", domain, uri, token) + ioutil.WriteFile("/tmp/out", []byte(out), 0644) + // 20s to copy the challenge from /tmp/out and update the server + time.Sleep(20 * time.Second) + return nil +} diff --git a/pkg/acme/server.go b/pkg/acme/server.go new file mode 100644 index 000000000..c9760edc9 --- /dev/null +++ b/pkg/acme/server.go @@ -0,0 +1,86 @@ +/* +Copyright 2019 The HAProxy Ingress Controller Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package acme + +import ( + "fmt" + "net" + "net/http" + + "github.com/jcmoraisjr/haproxy-ingress/pkg/types" +) + +// NewServer ... +func NewServer(logger types.Logger, socket string, resolver ServerResolver) Server { + return &server{ + logger: logger, + socket: socket, + resolver: resolver, + } +} + +// ServerResolver ... +type ServerResolver interface { + GetToken(domain, uri string) string +} + +// Server ... +type Server interface { + Listen(stopCh chan struct{}) error +} + +type server struct { + logger types.Logger + resolver ServerResolver + server *http.Server + socket string +} + +func (s *server) Listen(stopCh chan struct{}) error { + handler := http.NewServeMux() + handler.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { + host := r.Host + uri := r.URL.Path + token := s.resolver.GetToken(host, uri) + if token == "" { + w.WriteHeader(http.StatusNotFound) + fmt.Fprintf(w, "404 not found\n") + s.logger.Warn("acme: url token not found: domain=%s uri=%s", host, uri) + return + } + fmt.Fprintf(w, token) + s.logger.Info("acme: request token: domain=%s uri=%s", host, uri) + }) + s.server = &http.Server{Addr: s.socket, Handler: handler} + l, err := net.Listen("unix", s.server.Addr) + if err != nil { + return err + } + s.logger.Info("acme: listening on unix socket: %s", s.socket) + go s.server.Serve(l) + go func() { + <-stopCh + if s.server == nil { + s.logger.Error("acme: cannot close, server is nil") + } + s.logger.Info("acme: closing unix socket") + if err := s.server.Close(); err != nil { + s.logger.Error("acme: error closing socket: %v", err) + } + }() + return nil +} diff --git a/pkg/acme/signer.go b/pkg/acme/signer.go new file mode 100644 index 000000000..86b99c051 --- /dev/null +++ b/pkg/acme/signer.go @@ -0,0 +1,171 @@ +/* +Copyright 2019 The HAProxy Ingress Controller Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package acme + +import ( + "crypto/rsa" + "crypto/x509" + "fmt" + "reflect" + "strings" + "time" + + "github.com/jcmoraisjr/haproxy-ingress/pkg/types" +) + +// NewSigner ... +func NewSigner(logger types.Logger, cache Cache) Signer { + return &signer{ + logger: logger, + cache: cache, + } +} + +// Signer ... +type Signer interface { + AcmeAccount(endpoint, emails string, termsAgreed bool) + AcmeConfig(expiring time.Duration) + HasAccount() bool + Notify(item interface{}) error +} + +// Cache ... +type Cache interface { + ClientResolver + ServerResolver + SignerResolver +} + +// SignerResolver ... +type SignerResolver interface { + GetTLSSecretContent(secretName string) *TLSSecret + SetTLSSecretContent(secretName string, pemCrt, pemKey []byte) error +} + +// TLSSecret ... +type TLSSecret struct { + Crt *x509.Certificate + Key *rsa.PrivateKey +} + +type signer struct { + logger types.Logger + cache Cache + account Account + client Client + expiring time.Duration + verifyCount int +} + +func (s *signer) AcmeAccount(endpoint, emails string, termsAgreed bool) { + switch endpoint { + case "v2", "v02": + endpoint = "https://acme-v02.api.letsencrypt.org" + case "v2-staging", "v02-staging": + endpoint = "https://acme-staging-v02.api.letsencrypt.org" + } + account := Account{ + Endpoint: endpoint, + Emails: emails, + TermsAgreed: termsAgreed, + } + if reflect.DeepEqual(s.account, account) { + return + } + s.client = nil + s.logger.Info("loading account %+v", account) + client, err := NewClient(s.logger, s.cache, &account) + if err != nil { + s.logger.Warn("error creating the acme client: %v", err) + return + } + s.account = account + s.client = client +} + +func (s *signer) AcmeConfig(expiring time.Duration) { + s.expiring = expiring +} + +func (s *signer) HasAccount() bool { + return s.client != nil +} + +func (s *signer) Notify(item interface{}) error { + if !s.HasAccount() { + return fmt.Errorf("acme: account was not properly initialized") + } + cert := strings.Split(item.(string), ",") + secretName := cert[0] + domains := cert[1:] + err := s.verify(secretName, domains) + return err +} + +func (s *signer) verify(secretName string, domains []string) error { + duedate := time.Now().Add(s.expiring) + tls := s.cache.GetTLSSecretContent(secretName) + strdomains := strings.Join(domains, ",") + if tls == nil || tls.Crt.NotAfter.Before(duedate) || !match(domains, tls.Crt.DNSNames) { + var why string + if tls == nil { + why = "certificate does not exist" + } else if tls.Crt.NotAfter.Before(duedate) { + why = fmt.Sprintf("certificate expires in %s", tls.Crt.NotAfter.String()) + } else { + why = "added one or more domains to an existing certificate" + } + s.verifyCount++ + s.logger.InfoV(2, "acme: authorizing: id=%d secret=%s domain(s)=%s endpoint=%s why=\"%s\"", + s.verifyCount, secretName, strdomains, s.account.Endpoint, why) + crt, key, err := s.client.Sign(domains) + if err == nil { + if errTLS := s.cache.SetTLSSecretContent(secretName, crt, key); errTLS == nil { + s.logger.Info("acme: new certificate issued: id=%d secret=%s domain(s)=%s", + s.verifyCount, secretName, strdomains) + } else { + s.logger.Warn("acme: error storing new certificate: id=%d secret=%s domain(s)=%s error=%v", + s.verifyCount, secretName, strdomains, errTLS) + return errTLS + } + } else { + s.logger.Warn("acme: error signing new certificate: id=%d secret=%s domain(s)=%s error=%v", + s.verifyCount, secretName, strdomains, err) + return err + } + } else { + s.logger.InfoV(2, "acme: skipping sign, certificate is updated: secret=%s domain(s)=%s", secretName, strdomains) + } + return nil +} + +// match return true if all hosts in hostnames (desired configuration) +// are already in dnsnames (current certificate). +func match(domains, dnsnames []string) bool { + for _, domain := range domains { + found := false + for _, dns := range dnsnames { + if domain == dns { + found = true + } + } + if !found { + return false + } + } + return true +} diff --git a/pkg/acme/signer_test.go b/pkg/acme/signer_test.go new file mode 100644 index 000000000..ed771904f --- /dev/null +++ b/pkg/acme/signer_test.go @@ -0,0 +1,141 @@ +/* +Copyright 2019 The HAProxy Ingress Controller Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package acme + +import ( + "crypto" + "crypto/x509" + "encoding/base64" + "testing" + "time" + + types_helper "github.com/jcmoraisjr/haproxy-ingress/pkg/types/helper_test" +) + +const dumbcrt = `MIIC+DCCAeCgAwIBAgIBAzANBgkqhkiG9w0BAQsFADASMRAwDgYDVQQDDAdEdW1iIENBMB4XDTE5MTIwMTE2MzMxNFoXDTIwMTIwMTE2MzMxNFowEzERMA8GA1UEAwwIZDEubG9jYWwwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQDZuSjOKNrlOFQ/6JCZDbh5OykiSyv/GVzEsazMeLCcvmQecI9CtqCTMLENaDpSUC4/j2b5i61CoRHoucr9EoMo4KJslWRebfBz5y8H6zbRSI9J3MskVB5oDqC4NV8LRoxQRQwsHwR1UXkdUoVMCKwVXF7JUV9vr/lyjfX7+d1XYsX4jlVQ955RfPlVod6On4IOL2GmYzKER6F/IBPLHpIpwJYAM5vmbLo8/xkVb+gHw7tnJPxiMTO+/Rqno/Tx8avLqTFfuMZwPtE/aUjEzXBoMv2gItnDCkNBUO7LZdzlkcAN1iphPGMGN1Zpbd2pRUL1zWbQM7qL+qlEWlWapk0vAgMBAAGjWDBWMAkGA1UdEwQCMAAwCwYDVR0PBAQDAgXgMB0GA1UdJQQWMBQGCCsGAQUFBwMCBggrBgEFBQcDATAdBgNVHREEFjAUgghkMS5sb2NhbIIIZDIubG9jYWwwDQYJKoZIhvcNAQELBQADggEBACeFb+foCC6SS7pu6mBC0MbyKMKcuShI0xkTjjov/Fo1kYhAIDoEs7MRCv2eyfnqoXZ0ZBJJTsTaz2ADS3lrL422wy/udwLTty8f3/hOFAl/Bp3uJ8+7y26tOU+vdLXheO5ZCMOH8H39GHFXH31c9CLqvOUL78tCrkjxxvYHGFvWciLn2/AYRfoE/WKhvrEynPmVFtJXpGfIeBWB5SL6234c8fd0RpSRXNCmRQr3Tviy86jfz5eG0Tb3131E6sK8mB/Q/x1IonfSSm094chM54/Zwhq9MWx+T0EixnGXO4z7jI14EtsdQW12tDd+ADCU75Ob/06JXF0nrxNn0ej8MJM=` + +func TestNotifyVerify(t *testing.T) { + testCases := []struct { + input string + expiresIn time.Duration + logging string + }{ + // 0 + { + input: "s1,d1.local", + expiresIn: 10 * 24 * time.Hour, + logging: ` +INFO-V(2) acme: skipping sign, certificate is updated: secret=s1 domain(s)=d1.local`, + }, + // 1 + { + input: "s1,d2.local", + expiresIn: -10 * 24 * time.Hour, + logging: ` +INFO-V(2) acme: authorizing: id=1 secret=s1 domain(s)=d2.local endpoint=https://acme-v2.local why="certificate expires in 2020-12-01 16:33:14 +0000 UTC" +INFO acme: new certificate issued: id=1 secret=s1 domain(s)=d2.local`, + }, + // 2 + { + input: "s1,d3.local", + expiresIn: 10 * 24 * time.Hour, + logging: ` +INFO-V(2) acme: authorizing: id=1 secret=s1 domain(s)=d3.local endpoint=https://acme-v2.local why="added one or more domains to an existing certificate" +INFO acme: new certificate issued: id=1 secret=s1 domain(s)=d3.local`, + }, + // 3 + { + input: "s2,d1.local", + expiresIn: 10 * 24 * time.Hour, + logging: ` +INFO-V(2) acme: authorizing: id=1 secret=s2 domain(s)=d1.local endpoint=https://acme-v2.local why="certificate does not exist" +INFO acme: new certificate issued: id=1 secret=s2 domain(s)=d1.local`, + }, + } + c := setup(t) + defer c.teardown() + crt, _ := base64.StdEncoding.DecodeString(dumbcrt) + x509, _ := x509.ParseCertificate(crt) + c.cache.tlsSecret["s1"] = &TLSSecret{Crt: x509} + for _, test := range testCases { + signer := c.newSigner() + signer.account.Endpoint = "https://acme-v2.local" + signer.expiring = x509.NotAfter.Sub(time.Now().Add(test.expiresIn)) + signer.Notify(test.input) + c.logger.CompareLogging(test.logging) + } +} + +func setup(t *testing.T) *config { + return &config{ + t: t, + cache: &cache{ + tlsSecret: map[string]*TLSSecret{}, + }, + logger: types_helper.NewLoggerMock(t), + } +} + +type config struct { + t *testing.T + cache *cache + logger *types_helper.LoggerMock +} + +func (c *config) teardown() { + c.logger.CompareLogging("") +} + +func (c *config) newSigner() *signer { + signer := NewSigner(c.logger, c.cache).(*signer) + signer.client = &clientMock{} + return signer +} + +type clientMock struct{} + +func (c *clientMock) Sign(domains []string) (crt, key []byte, err error) { + return nil, nil, nil +} + +type cache struct { + tlsSecret map[string]*TLSSecret +} + +func (c *cache) GetKey() (crypto.Signer, error) { + return nil, nil +} + +func (c *cache) SetToken(domain string, uri, token string) error { + return nil +} + +func (c *cache) GetToken(domain, uri string) string { + return "" +} + +func (c *cache) GetTLSSecretContent(secretName string) *TLSSecret { + tls, found := c.tlsSecret[secretName] + if found { + return tls + } + return nil +} + +func (c *cache) SetTLSSecretContent(secretName string, pemCrt, pemKey []byte) error { + return nil +} diff --git a/pkg/acme/x/acme/acme.go b/pkg/acme/x/acme/acme.go new file mode 100644 index 000000000..d3a4e4af8 --- /dev/null +++ b/pkg/acme/x/acme/acme.go @@ -0,0 +1,965 @@ +// Copyright 2015 The Go Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +// Package acme provides an implementation of the +// Automatic Certificate Management Environment (ACME) spec. +// See https://tools.ietf.org/html/draft-ietf-acme-acme-09 for details. +// +// Most common scenarios will want to use autocert subdirectory instead, +// which provides automatic access to certificates from Let's Encrypt +// and any other ACME-based CA. +// +// This package is a work in progress and makes no API stability promises. +package acme + +// From: https://golang.org/cl/86635 + +import ( + "bytes" + "context" + "crypto" + "crypto/rand" + "crypto/sha256" + "encoding/base64" + "encoding/json" + "encoding/pem" + "errors" + "fmt" + "io" + "io/ioutil" + "math/big" + "net/http" + "net/url" + "strconv" + "sync" + "time" +) + +// LetsEncryptURL is the Directory endpoint of Let's Encrypt CA. +const LetsEncryptURL = "https://acme-v02.api.letsencrypt.org/directory" + +const ( + // max length of a certificate chain + maxChainLen = 5 + // max size of a certificate chain response, in bytes + maxChainSize = (1 << 20) * maxChainLen + + // Max number of collected nonces kept in memory. + // Expect usual peak of 1 or 2. + maxNonces = 100 + + // User-Agent, bump the version each time a change is made to the + // handling of API requests. + userAgent = "go-acme/2" +) + +// Client is an ACME client. +// The only required field is Key. An example of creating a client with a new key +// is as follows: +// +// key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) +// if err != nil { +// log.Fatal(err) +// } +// client := &Client{Key: key} +// +type Client struct { + // Key is the account key used to register with a CA and sign requests. + // Key.Public() must return a *rsa.PublicKey or *ecdsa.PublicKey. + Key crypto.Signer + + // HTTPClient optionally specifies an HTTP client to use + // instead of http.DefaultClient. + HTTPClient *http.Client + + // DirectoryURL points to the CA directory endpoint. + // If empty, LetsEncryptURL is used. + // Mutating this value after a successful call of Client's Discover method + // will have no effect. + DirectoryURL string + + // UserAgent is an optional string that identifies this client and + // version to the ACME server. It should be set to something like + // "myclient/1.2.3". + UserAgent string + + noncesMu sync.Mutex + nonces map[string]struct{} // nonces collected from previous responses + + urlMu sync.Mutex // urlMu guards writes to dir and accountURL + dir *Directory // cached result of Client's Discover method + accountURL string +} + +// Discover performs ACME server discovery using c.DirectoryURL. +// +// It caches successful result. So, subsequent calls will not result in +// a network round-trip. This also means mutating c.DirectoryURL after successful call +// of this method will have no effect. +func (c *Client) Discover(ctx context.Context) (Directory, error) { + c.urlMu.Lock() + defer c.urlMu.Unlock() + if c.dir != nil { + return *c.dir, nil + } + + dirURL := c.DirectoryURL + if dirURL == "" { + dirURL = LetsEncryptURL + } + res, err := c.get(ctx, dirURL) + if err != nil { + return Directory{}, err + } + defer res.Body.Close() + c.addNonce(res.Header) + if res.StatusCode != http.StatusOK { + return Directory{}, responseError(res) + } + + var v struct { + NewNonce string + NewAccount string + NewOrder string + NewAuthz string + RevokeCert string + KeyChange string + Meta struct { + TermsOfService string + Website string + CAAIdentities []string + ExternalAccountRequired bool + } + } + if err := json.NewDecoder(res.Body).Decode(&v); err != nil { + // NOTE: changed from `return Directory{}, err` to this: + return Directory{}, fmt.Errorf("acme: invalid response from %s: %v", c.DirectoryURL, err) + } + c.dir = &Directory{ + NewNonceURL: v.NewNonce, + NewAccountURL: v.NewAccount, + NewOrderURL: v.NewOrder, + NewAuthzURL: v.NewAuthz, + RevokeCertURL: v.RevokeCert, + KeyChangeURL: v.KeyChange, + Terms: v.Meta.TermsOfService, + Website: v.Meta.Website, + CAA: v.Meta.CAAIdentities, + ExternalAccountRequired: v.Meta.ExternalAccountRequired, + } + return *c.dir, nil +} + +// CreateOrder creates a new certificate order. The input order argument is not +// modified and can be built using NewOrder. +func (c *Client) CreateOrder(ctx context.Context, order *Order) (*Order, error) { + if _, err := c.Discover(ctx); err != nil { + return nil, err + } + + req := struct { + Identifiers []wireAuthzID `json:"identifiers"` + NotBefore string `json:"notBefore,omitempty"` + NotAfter string `json:"notAfter,omitempty"` + }{ + Identifiers: make([]wireAuthzID, len(order.Identifiers)), + } + for i, id := range order.Identifiers { + req.Identifiers[i] = wireAuthzID{ + Type: id.Type, + Value: id.Value, + } + } + if !order.NotBefore.IsZero() { + req.NotBefore = order.NotBefore.Format(time.RFC3339) + } + if !order.NotAfter.IsZero() { + req.NotAfter = order.NotAfter.Format(time.RFC3339) + } + + res, err := c.postWithJWSAccount(ctx, c.dir.NewOrderURL, req) + if err != nil { + return nil, err + } + defer res.Body.Close() + if res.StatusCode != http.StatusCreated { + return nil, responseError(res) + } + var v wireOrder + if err := json.NewDecoder(res.Body).Decode(&v); err != nil { + return nil, err + } + l, err := resolveLocation(c.dir.NewOrderURL, res.Header) + if err != nil { + return nil, err + } + o := v.order(l, "") + + if o.Status == StatusInvalid { + return nil, OrderInvalidError{o} + } + return o, nil +} + +// FinalizeOrder finalizes an order using the Certificate Signing Request csr +// encoded in DER format. If the order has not been fully authorized, +// an OrderPendingError will be returned. +// +// After requesting finalization, FinalizOrder polls the order using WaitOrder +// until it is finalized and then fetches the associated certificate and returns +// it. +// +// Callers are encouraged to parse the returned certificate chain to ensure it +// is valid and has the expected attributes. +func (c *Client) FinalizeOrder(ctx context.Context, finalizeURL string, csr []byte) (der [][]byte, err error) { + if _, err := c.Discover(ctx); err != nil { + return nil, err + } + + req := struct { + CSR string `json:"csr"` + }{ + CSR: base64.RawURLEncoding.EncodeToString(csr), + } + + res, err := c.postWithJWSAccount(ctx, finalizeURL, req) + if err != nil { + return nil, err + } + defer res.Body.Close() + if res.StatusCode != http.StatusOK { + return nil, responseError(res) + } + var v wireOrder + if err := json.NewDecoder(res.Body).Decode(&v); err != nil { + return nil, err + } + l, err := resolveLocation(finalizeURL, res.Header) + if err != nil { + return nil, err + } + o := v.order(l, res.Header.Get("Retry-After")) + if o.Status == StatusProcessing || o.Status == StatusPending { + o, err = c.WaitOrder(ctx, o.URL) + if err != nil { + return nil, err + } + } + if o.Status != StatusValid { + return nil, fmt.Errorf("acme: unexpected order status %q", o.Status) + } + + return c.getCert(ctx, o.CertificateURL) +} + +// GetOrder retrieves an order identified by url. +// +// If a caller needs to poll an order until its status is final, +// see the WaitOrder method. +func (c *Client) GetOrder(ctx context.Context, url string) (*Order, error) { + res, err := c.postWithJWSAccount(ctx, url, nil) + if err != nil { + return nil, err + } + defer res.Body.Close() + if res.StatusCode != http.StatusOK { + err = responseError(res) + return nil, err + } + var v wireOrder + if err := json.NewDecoder(res.Body).Decode(&v); err != nil { + return nil, err + } + return v.order(url, res.Header.Get("Retry-After")), nil +} + +// WaitOrder waits for an order to transition from StatusProcessing to a final +// state (StatusValid/StatusInvalid), it retries the request until the order is +// final, ctx is cancelled by the caller, or an error response is received. +// +// It returns a non-nil Order only if its Status is StatusValid. In all other +// cases WaitOrder returns an error. If the Status is StatusInvalid, the +// returned error will be of type OrderInvalidError. If the status is +// StatusPending, the returned error will be of type OrderPendingError. +func (c *Client) WaitOrder(ctx context.Context, url string) (*Order, error) { + sleep := timeSleeper(ctx) + for { + o, err := c.GetOrder(ctx, url) + if e, ok := err.(*Error); ok && e.StatusCode >= 500 && e.StatusCode <= 599 { + // retriable 5xx error + if err := sleep(retryAfter(e.Header.Get("Retry-After"))); err != nil { + return nil, err + } + continue + } + if err != nil { + return nil, err + } + switch o.Status { + case StatusValid: + return o, nil + case StatusInvalid: + return nil, OrderInvalidError{o} + case StatusPending: + return nil, OrderPendingError{o} + case StatusProcessing: // continue retry loop + default: + return nil, fmt.Errorf("acme: unexpected order status %q", o.Status) + } + if err := sleep(o.RetryAfter); err != nil { + return nil, err + } + } +} + +// RevokeCert revokes a previously issued certificate cert, provided in DER +// format. +// +// If key is nil, the account must have been used to issue the certificate or +// have valid authorizations for all of the identifiers in the certificate. If +// key is provided, it must be the certificate's private key. +func (c *Client) RevokeCert(ctx context.Context, key crypto.Signer, cert []byte, reason CRLReasonCode) error { + if _, err := c.Discover(ctx); err != nil { + return err + } + + body := &struct { + Cert string `json:"certificate"` + Reason int `json:"reason"` + }{ + Cert: base64.RawURLEncoding.EncodeToString(cert), + Reason: int(reason), + } + var res *http.Response + var err error + if key == nil { + res, err = c.postWithJWSAccount(ctx, c.dir.RevokeCertURL, body) + } else { + res, err = c.postWithJWSKey(ctx, key, c.dir.RevokeCertURL, body) + } + if err != nil { + return err + } + defer res.Body.Close() + if res.StatusCode != http.StatusOK { + return responseError(res) + } + return nil +} + +// CreateAccount creates a new account. It returns the account details from the +// server and does not modify the account argument that it is called with. +func (c *Client) CreateAccount(ctx context.Context, a *Account) (*Account, error) { + if _, err := c.Discover(ctx); err != nil { + return nil, err + } + return c.doAccount(ctx, c.dir.NewAccountURL, false, a) +} + +// GetAccount retrieves the account that the client is configured with. +func (c *Client) GetAccount(ctx context.Context) (*Account, error) { + if _, err := c.Discover(ctx); err != nil { + return nil, err + } + return c.doAccount(ctx, c.dir.NewAccountURL, true, nil) +} + +// UpdateAccount updates an existing account. It returns an updated account +// copy. The provided account is not modified. +func (c *Client) UpdateAccount(ctx context.Context, a *Account) (*Account, error) { + return c.doAccount(ctx, a.URL, false, a) +} + +// GetAuthorization retrieves an authorization identified by the given URL. +// +// If a caller needs to poll an authorization until its status is final, +// see the WaitAuthorization method. +func (c *Client) GetAuthorization(ctx context.Context, url string) (*Authorization, error) { + res, err := c.postWithJWSAccount(ctx, url, nil) + if err != nil { + return nil, err + } + defer res.Body.Close() + if res.StatusCode != http.StatusOK { + return nil, responseError(res) + } + var v wireAuthz + if err := json.NewDecoder(res.Body).Decode(&v); err != nil { + return nil, fmt.Errorf("acme: invalid response: %v", err) + } + return v.authorization(url), nil +} + +// DeactivateAuthorization relinquishes an existing authorization identified by +// the given URL. +// +// If successful, the caller will be required to obtain a new authorization +// before a new certificate for the domain associated with the authorization is +// issued. +// +// It does not revoke existing certificates. +func (c *Client) DeactivateAuthorization(ctx context.Context, url string) error { + res, err := c.postWithJWSAccount(ctx, url, json.RawMessage(`{"status":"deactivated"}`)) + if err != nil { + return err + } + defer res.Body.Close() + if res.StatusCode != http.StatusOK { + return responseError(res) + } + return nil +} + +// WaitAuthorization polls an authorization at the given URL +// until it is in one of the final states, StatusValid or StatusInvalid, +// the ACME CA responded with a 4xx error code, or the context is done. +// +// It returns a non-nil Authorization only if its Status is StatusValid. +// In all other cases WaitAuthorization returns an error. +// If the Status is StatusInvalid, StatusDeactivated, or StatusRevoked the +// returned error will be of type AuthorizationError. +func (c *Client) WaitAuthorization(ctx context.Context, url string) (*Authorization, error) { + sleep := sleeper(ctx) + for { + res, err := c.postWithJWSAccount(ctx, url, nil) + if err != nil { + return nil, err + } + if res.StatusCode >= 400 && res.StatusCode <= 499 { + // Non-retriable error. For instance, Let's Encrypt may return 404 Not Found + // when requesting an expired authorization. + defer res.Body.Close() + return nil, responseError(res) + } + + retry := res.Header.Get("Retry-After") + if res.StatusCode != http.StatusOK && res.StatusCode != http.StatusAccepted { + res.Body.Close() + if err := sleep(retry); err != nil { + return nil, err + } + continue + } + var raw wireAuthz + err = json.NewDecoder(res.Body).Decode(&raw) + res.Body.Close() + if err != nil { + return nil, err + } + switch raw.Status { + case StatusValid: + return raw.authorization(url), nil + case StatusInvalid, StatusDeactivated, StatusRevoked: + return nil, AuthorizationError{raw.authorization(url)} + case StatusPending, StatusProcessing: // fall through to sleep + default: + return nil, fmt.Errorf("acme: unknown authorization status %q", raw.Status) + } + if err := sleep(retry); err != nil { + return nil, err + } + } +} + +// GetChallenge retrieves the current status of a challenge. +// +// A client typically polls a challenge status using this method. +func (c *Client) GetChallenge(ctx context.Context, url string) (*Challenge, error) { + res, err := c.postWithJWSAccount(ctx, url, nil) + if err != nil { + return nil, err + } + defer res.Body.Close() + if res.StatusCode != http.StatusOK { + return nil, responseError(res) + } + v := wireChallenge{URL: url} + if err := json.NewDecoder(res.Body).Decode(&v); err != nil { + return nil, fmt.Errorf("acme: invalid response: %v", err) + } + return v.challenge(), nil +} + +// AcceptChallenge informs the server that the client accepts one of its +// authorization challenges previously obtained with +// CreateOrder/GetAuthorization. +// +// The server will then perform the validation asynchronously. +func (c *Client) AcceptChallenge(ctx context.Context, chal *Challenge) (*Challenge, error) { + if _, err := c.Discover(ctx); err != nil { + return nil, err + } + + auth, err := keyAuth(c.Key.Public(), chal.Token) + if err != nil { + return nil, err + } + + req := struct { + Auth string `json:"keyAuthorization"` + }{auth} + res, err := c.postWithJWSAccount(ctx, chal.URL, req) + if err != nil { + return nil, err + } + defer res.Body.Close() + if res.StatusCode != http.StatusOK { + return nil, responseError(res) + } + + var v wireChallenge + if err := json.NewDecoder(res.Body).Decode(&v); err != nil { + return nil, fmt.Errorf("acme: invalid response: %v", err) + } + return v.challenge(), nil +} + +// DNS01ChallengeRecord returns a DNS record value for a dns-01 challenge response. +// A TXT record containing the returned value must be provisioned under +// "_acme-challenge" name of the domain being validated. +// +// The token argument is a Challenge.Token value. +func (c *Client) DNS01ChallengeRecord(token string) (string, error) { + ka, err := keyAuth(c.Key.Public(), token) + if err != nil { + return "", err + } + b := sha256.Sum256([]byte(ka)) + return base64.RawURLEncoding.EncodeToString(b[:]), nil +} + +// HTTP01ChallengeResponse returns the response for an http-01 challenge. +// Servers should respond with the value to HTTP requests at the URL path +// provided by HTTP01ChallengePath to validate the challenge and prove control +// over a domain name. +// +// The token argument is a Challenge.Token value. +func (c *Client) HTTP01ChallengeResponse(token string) (string, error) { + return keyAuth(c.Key.Public(), token) +} + +// HTTP01ChallengePath returns the URL path at which the response for an http-01 challenge +// should be provided by the servers. +// The response value can be obtained with HTTP01ChallengeResponse. +// +// The token argument is a Challenge.Token value. +func (c *Client) HTTP01ChallengePath(token string) string { + return "/.well-known/acme-challenge/" + token +} + +// doAccount creates, updates, and reads accounts. +// +// A non-nil acct argument indicates whether the intention is to mutate data of +// the Account. Only the Contact field can be updated. +func (c *Client) doAccount(ctx context.Context, url string, getExistingWithKey bool, acct *Account) (*Account, error) { + req := struct { + Contact []string `json:"contact,omitempty"` + TermsAgreed bool `json:"termsOfServiceAgreed,omitempty"` + GetExisting bool `json:"onlyReturnExisting,omitempty"` + }{ + GetExisting: getExistingWithKey, + } + var accountURL string + if url != c.dir.NewAccountURL { + accountURL = url + } + if acct != nil { + req.Contact = acct.Contact + req.TermsAgreed = acct.TermsAgreed + } + res, err := c.retryPostJWS(ctx, c.Key, accountURL, url, req) + if err != nil { + return nil, err + } + defer res.Body.Close() + if res.StatusCode < 200 || res.StatusCode > 299 { + return nil, responseError(res) + } + + if getExistingWithKey { + l, err := resolveLocation(url, res.Header) + if err != nil { + return nil, err + } + return c.doAccount(ctx, l, false, nil) + } + + var v struct { + Status string + Contact []string + Orders string + } + if err := json.NewDecoder(res.Body).Decode(&v); err != nil { + return nil, fmt.Errorf("acme: invalid response: %v", err) + } + l, err := resolveLocation(url, res.Header) + if err != nil { + return nil, err + } + a := &Account{ + URL: l, + Status: v.Status, + Contact: v.Contact, + OrdersURL: v.Orders, + } + if a.URL == "" { + a.URL = url + } + c.urlMu.Lock() + defer c.urlMu.Unlock() + c.accountURL = a.URL + return a, nil +} + +// cacheAccount ensures that the account URL is cached and returns it. +func (c *Client) cacheAccountURL(ctx context.Context) (string, error) { + c.urlMu.Lock() + defer c.urlMu.Unlock() + if c.accountURL != "" { + return c.accountURL, nil + } + res, err := c.postWithJWSKey(ctx, c.Key, c.dir.NewAccountURL, json.RawMessage(`{"onlyReturnExisting":true}`)) + if err != nil { + return "", err + } + defer res.Body.Close() + if res.StatusCode != http.StatusOK { + return "", responseError(res) + } + l, err := resolveLocation(c.dir.NewAccountURL, res.Header) + if err != nil { + return "", err + } + c.accountURL = l + return c.accountURL, nil +} + +func (c *Client) postWithJWSKey(ctx context.Context, key crypto.Signer, url string, body interface{}) (*http.Response, error) { + return c.retryPostJWS(ctx, key, "", url, body) +} + +func (c *Client) postWithJWSAccount(ctx context.Context, url string, body interface{}) (*http.Response, error) { + accountURL, err := c.cacheAccountURL(ctx) + if err != nil { + return nil, err + } + return c.retryPostJWS(ctx, c.Key, accountURL, url, body) +} + +// retryPostJWS will retry calls to postJWS if there is a badNonce error, +// clearing the stored nonces after each error. +// If the response was 4XX-5XX, then responseError is called on the body, +// the body is closed, and the error returned. +func (c *Client) retryPostJWS(ctx context.Context, key crypto.Signer, accountURL, url string, body interface{}) (*http.Response, error) { + sleep := sleeper(ctx) + for { + res, err := c.postJWS(ctx, key, accountURL, url, body) + if err != nil { + return nil, err + } + // handle errors 4XX-5XX with responseError + if res.StatusCode >= 400 && res.StatusCode <= 599 { + err := responseError(res) + res.Body.Close() + if ae, ok := err.(*Error); ok && ae.Type == "urn:ietf:params:acme:error:badNonce" { + // clear any nonces that we might've stored that might now be + // considered bad + c.clearNonces() + retry := res.Header.Get("Retry-After") + if err := sleep(retry); err != nil { + return nil, err + } + continue + } + return nil, err + } + return res, nil + } +} + +// postJWS signs the body with the given key and POSTs it to the provided url. +// The body argument must be JSON-serializable. +// The accountURL should be empty for account creation and certificate revocation. +func (c *Client) postJWS(ctx context.Context, key crypto.Signer, accountURL, url string, body interface{}) (*http.Response, error) { + nonce, err := c.popNonce(ctx) + if err != nil { + return nil, err + } + b, err := jwsEncodeJSON(body, key, accountURL, url, nonce) + if err != nil { + return nil, err + } + res, err := c.post(ctx, url, "application/jose+json", bytes.NewReader(b)) + if err != nil { + return nil, err + } + c.addNonce(res.Header) + return res, nil +} + +// popNonce returns a nonce value previously stored with c.addNonce +// or fetches a fresh one. +func (c *Client) popNonce(ctx context.Context) (string, error) { + c.noncesMu.Lock() + defer c.noncesMu.Unlock() + if len(c.nonces) == 0 { + return c.fetchNonce(ctx) + } + var nonce string + for nonce = range c.nonces { + delete(c.nonces, nonce) + break + } + return nonce, nil +} + +// clearNonces clears any stored nonces +func (c *Client) clearNonces() { + c.noncesMu.Lock() + defer c.noncesMu.Unlock() + c.nonces = make(map[string]struct{}) +} + +// addNonce stores a nonce value found in h (if any) for future use. +func (c *Client) addNonce(h http.Header) { + v := nonceFromHeader(h) + if v == "" { + return + } + c.noncesMu.Lock() + defer c.noncesMu.Unlock() + if len(c.nonces) >= maxNonces { + return + } + if c.nonces == nil { + c.nonces = make(map[string]struct{}) + } + c.nonces[v] = struct{}{} +} + +func (c *Client) httpClient() *http.Client { + if c.HTTPClient != nil { + return c.HTTPClient + } + return http.DefaultClient +} + +func (c *Client) get(ctx context.Context, urlStr string) (*http.Response, error) { + req, err := c.newRequest("GET", urlStr, nil) + if err != nil { + return nil, err + } + return c.do(ctx, req) +} + +func (c *Client) head(ctx context.Context, urlStr string) (*http.Response, error) { + req, err := c.newRequest("HEAD", urlStr, nil) + if err != nil { + return nil, err + } + return c.do(ctx, req) +} + +func (c *Client) post(ctx context.Context, urlStr, contentType string, body io.Reader) (*http.Response, error) { + req, err := c.newRequest("POST", urlStr, body) + if err != nil { + return nil, err + } + req.Header.Set("Content-Type", contentType) + return c.do(ctx, req) +} + +func (c *Client) newRequest(method, url string, body io.Reader) (*http.Request, error) { + req, err := http.NewRequest(method, url, body) + if err != nil { + return nil, err + } + ua := userAgent + if c.UserAgent != "" { + ua += " " + c.UserAgent + } + req.Header.Set("User-Agent", ua) + return req, nil +} + +func (c *Client) do(ctx context.Context, req *http.Request) (*http.Response, error) { + res, err := c.httpClient().Do(req.WithContext(ctx)) + if err != nil { + select { + case <-ctx.Done(): + // Prefer the unadorned context error. + // (The acme package had tests assuming this, previously from ctxhttp's + // behavior, predating net/http supporting contexts natively) + // TODO(bradfitz): reconsider this in the future. But for now this + // requires no test updates. + return nil, ctx.Err() + default: + return nil, err + } + } + return res, nil +} + +func (c *Client) fetchNonce(ctx context.Context) (string, error) { + resp, err := c.head(ctx, c.dir.NewNonceURL) + if err != nil { + return "", err + } + defer resp.Body.Close() + nonce := nonceFromHeader(resp.Header) + if nonce == "" { + if resp.StatusCode > 299 { + return "", responseError(resp) + } + return "", errors.New("acme: nonce not found") + } + return nonce, nil +} + +func nonceFromHeader(h http.Header) string { + return h.Get("Replay-Nonce") +} + +func (c *Client) getCert(ctx context.Context, url string) ([][]byte, error) { + res, err := c.postWithJWSAccount(ctx, url, nil) + if err != nil { + return nil, err + } + defer res.Body.Close() + data, err := ioutil.ReadAll(io.LimitReader(res.Body, maxChainSize+1)) + if err != nil { + return nil, fmt.Errorf("acme: error getting certificate: %v", err) + } + if len(data) > maxChainSize { + return nil, errors.New("acme: certificate chain is too big") + } + var chain [][]byte + for { + var p *pem.Block + p, data = pem.Decode(data) + if p == nil { + if len(chain) == 0 { + return nil, errors.New("acme: invalid PEM certificate chain") + } + break + } + if len(chain) == maxChainLen { + return nil, errors.New("acme: certificate chain is too long") + } + if p.Type != "CERTIFICATE" { + return nil, fmt.Errorf("acme: invalid PEM block type %q", p.Type) + } + chain = append(chain, p.Bytes) + } + return chain, nil +} + +// responseError creates an error of Error type from resp. +func responseError(resp *http.Response) error { + // don't care if ReadAll returns an error: + // json.Unmarshal will fail in that case anyway + b, _ := ioutil.ReadAll(resp.Body) + e := &wireError{Status: resp.StatusCode} + if err := json.Unmarshal(b, e); err != nil { + // this is not a regular error response: + // populate detail with anything we received, + // e.Status will already contain HTTP response code value + e.Detail = string(b) + if e.Detail == "" { + e.Detail = resp.Status + } + } + return e.error(resp.Header) +} + +// sleeper returns a function that accepts the Retry-After HTTP header value +// and an increment that's used with backoff to increasingly sleep on +// consecutive calls until the context is done. If the Retry-After header +// cannot be parsed, then backoff is used with a maximum sleep time of 10 +// seconds. +func sleeper(ctx context.Context) func(ra string) error { + sleep := timeSleeper(ctx) + return func(ra string) error { + return sleep(retryAfter(ra)) + } +} + +func timeSleeper(ctx context.Context) func(time.Time) error { + var count int + return func(t time.Time) error { + d := backoff(count, 10*time.Second) + count++ + if !t.IsZero() { + d = t.Sub(timeNow()) + } + wakeup := time.NewTimer(d) + defer wakeup.Stop() + select { + case <-ctx.Done(): + return ctx.Err() + case <-wakeup.C: + return nil + } + } +} + +// retryAfter parses a Retry-After HTTP header value, +// trying to convert v into an int (seconds) or use http.ParseTime otherwise. +func retryAfter(v string) time.Time { + if i, err := strconv.Atoi(v); err == nil { + return timeNow().Add(time.Duration(i) * time.Second) + } + t, err := http.ParseTime(v) + if err != nil { + return time.Time{} + } + return t +} + +// backoff computes a duration after which an n+1 retry iteration should occur +// using truncated exponential backoff algorithm. +// +// The n argument is always bounded between 0 and 30. +// The max argument defines upper bound for the returned value. +func backoff(n int, max time.Duration) time.Duration { + if n < 0 { + n = 0 + } + if n > 30 { + n = 30 + } + var d time.Duration + if x, err := rand.Int(rand.Reader, big.NewInt(1000)); err == nil { + d = time.Duration(x.Int64()) * time.Millisecond + } + d += time.Duration(1< max { + return max + } + return d +} + +// keyAuth generates a key authorization string for a given token. +func keyAuth(pub crypto.PublicKey, token string) (string, error) { + th, err := JWKThumbprint(pub) + if err != nil { + return "", err + } + return fmt.Sprintf("%s.%s", token, th), nil +} + +func resolveLocation(base string, h http.Header) (string, error) { + u, err := url.Parse(base) + if err != nil { + return "", err + } + u, err = u.Parse(h.Get("Location")) + if err != nil { + return "", fmt.Errorf("acme: error parsing Location: %s", err) + } + return u.String(), nil +} + +// timeNow is useful for testing for fixed current time. +var timeNow = time.Now diff --git a/pkg/acme/x/acme/acme_test.go b/pkg/acme/x/acme/acme_test.go new file mode 100644 index 000000000..e0ad09784 --- /dev/null +++ b/pkg/acme/x/acme/acme_test.go @@ -0,0 +1,1180 @@ +// Copyright 2015 The Go Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package acme + +import ( + "context" + "crypto" + "crypto/rand" + "crypto/x509" + "crypto/x509/pkix" + "encoding/base64" + "encoding/json" + "encoding/pem" + "fmt" + "io/ioutil" + "math/big" + "net/http" + "net/http/httptest" + "reflect" + "strings" + "testing" + "time" +) + +// Decodes a JWS-encoded request and unmarshals the decoded JSON into a provided +// interface. +func decodeJWSRequest(t *testing.T, v interface{}, r *http.Request) { + // Decode request + var req struct{ Payload string } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + t.Fatal(err) + } + payload, err := base64.RawURLEncoding.DecodeString(req.Payload) + if err != nil { + t.Fatal(err) + } + err = json.Unmarshal(payload, v) + if err != nil { + t.Fatal(err) + } +} + +type jwsHead struct { + Alg string + Nonce string + JWK map[string]string `json:"jwk"` +} + +func decodeJWSHead(r *http.Request) (*jwsHead, error) { + var req struct{ Protected string } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + return nil, err + } + b, err := base64.RawURLEncoding.DecodeString(req.Protected) + if err != nil { + return nil, err + } + var head jwsHead + if err := json.Unmarshal(b, &head); err != nil { + return nil, err + } + return &head, nil +} + +func newTestClient(key crypto.Signer, ts *httptest.Server) *Client { + return &Client{ + Key: key, + accountURL: "https://example.com/acme/account", + dir: &Directory{ + NewNonceURL: ts.URL, + NewAccountURL: ts.URL + "/account", + }, + } +} + +func TestDiscover(t *testing.T) { + const ( + keyChange = "https://example.com/acme/key-change" + newAccount = "https://example.com/acme/new-account" + newNonce = "https://example.com/acme/new-nonce" + newOrder = "https://example.com/acme/new-order" + revokeCert = "https://example.com/acme/revoke-cert" + terms = "https://example.com/acme/terms" + ) + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + fmt.Fprintf(w, `{ + "keyChange": %q, + "newAccount": %q, + "newNonce": %q, + "newOrder": %q, + "revokeCert": %q, + "meta": { + "termsOfService": %q + } + }`, keyChange, newAccount, newNonce, newOrder, revokeCert, terms) + })) + defer ts.Close() + c := Client{DirectoryURL: ts.URL} + dir, err := c.Discover(context.Background()) + if err != nil { + t.Fatal(err) + } + if dir.KeyChangeURL != keyChange { + t.Errorf("dir.KeyChangeURL = %q; want %q", dir.KeyChangeURL, keyChange) + } + if dir.NewAccountURL != newAccount { + t.Errorf("dir.NewAccountURL = %q; want %q", dir.NewAccountURL, newAccount) + } + if dir.NewNonceURL != newNonce { + t.Errorf("dir.NewNonceURL = %q; want %q", dir.NewNonceURL, newNonce) + } + if dir.RevokeCertURL != revokeCert { + t.Errorf("dir.RevokeCertURL = %q; want %q", dir.RevokeCertURL, revokeCert) + } + if dir.Terms != terms { + t.Errorf("dir.Terms = %q; want %q", dir.Terms, terms) + } +} + +func TestCreateAccount(t *testing.T) { + contacts := []string{"mailto:admin@example.com"} + + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method == "HEAD" { + w.Header().Set("Replay-Nonce", "test-nonce") + return + } + if r.Method != "POST" { + t.Errorf("r.Method = %q; want POST", r.Method) + } + + var j struct { + Contact []string + TermsOfServiceAgreed bool + } + decodeJWSRequest(t, &j, r) + + if !reflect.DeepEqual(j.Contact, contacts) { + t.Errorf("j.Contact = %v; want %v", j.Contact, contacts) + } + if !j.TermsOfServiceAgreed { + t.Error("j.TermsOfServiceAgreed = false; want true") + } + + w.Header().Set("Location", "https://example.com/acme/account/1") + w.WriteHeader(http.StatusCreated) + b, _ := json.Marshal(contacts) + fmt.Fprintf(w, `{"status":"valid","orders":"https://example.com/acme/orders","contact":%s}`, b) + })) + defer ts.Close() + + c := Client{Key: testKeyEC, dir: &Directory{NewAccountURL: ts.URL, NewNonceURL: ts.URL}} + a := &Account{Contact: contacts, TermsAgreed: true} + var err error + if a, err = c.CreateAccount(context.Background(), a); err != nil { + t.Fatal(err) + } + if a.URL != "https://example.com/acme/account/1" { + t.Errorf("a.URL = %q; want https://example.com/acme/account/1", a.URL) + } + if a.OrdersURL != "https://example.com/acme/orders" { + t.Errorf("a.OrdersURL = %q; want https://example.com/acme/orders", a.OrdersURL) + } + if a.Status != StatusValid { + t.Errorf("a.Status = %q; want valid", a.Status) + } + if !reflect.DeepEqual(a.Contact, contacts) { + t.Errorf("a.Contact = %v; want %v", a.Contact, contacts) + } +} + +func TestUpdateAccount(t *testing.T) { + contacts := []string{"mailto:admin@example.com"} + + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method == "HEAD" { + w.Header().Set("Replay-Nonce", "test-nonce") + return + } + if r.Method != "POST" { + t.Errorf("r.Method = %q; want POST", r.Method) + } + + var j struct { + Contact []string + } + decodeJWSRequest(t, &j, r) + + if !reflect.DeepEqual(j.Contact, contacts) { + t.Errorf("j.Contact = %v; want %v", j.Contact, contacts) + } + b, _ := json.Marshal(contacts) + fmt.Fprintf(w, `{"status":"valid","orders":"https://example.com/acme/orders","contact":%s}`, b) + })) + defer ts.Close() + + c := Client{Key: testKeyEC, dir: &Directory{NewNonceURL: ts.URL}} + a := &Account{URL: ts.URL, Contact: contacts} + var err error + if a, err = c.UpdateAccount(context.Background(), a); err != nil { + t.Fatal(err) + } + if a.OrdersURL != "https://example.com/acme/orders" { + t.Errorf("a.OrdersURL = %q; want https://example.com/acme/orders", a.OrdersURL) + } + if a.Status != StatusValid { + t.Errorf("a.Status = %q; want valid", a.Status) + } + if !reflect.DeepEqual(a.Contact, contacts) { + t.Errorf("a.Contact = %v; want %v", a.Contact, contacts) + } + if a.URL != ts.URL { + t.Errorf("a.URL = %q; want %q", a.URL, ts.URL) + } +} + +func TestGetAccount(t *testing.T) { + contacts := []string{"mailto:admin@example.com"} + + var ts *httptest.Server + ts = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method == "HEAD" { + w.Header().Set("Replay-Nonce", "test-nonce") + return + } + if r.Method != "POST" { + t.Errorf("r.Method = %q; want POST", r.Method) + } + + var req struct { + Existing bool `json:"onlyReturnExisting"` + } + decodeJWSRequest(t, &req, r) + if req.Existing { + w.Header().Set("Location", ts.URL) + w.WriteHeader(http.StatusOK) + return + } + b, _ := json.Marshal(contacts) + fmt.Fprintf(w, `{"status":"valid","orders":"https://example.com/acme/orders","contact":%s}`, b) + })) + defer ts.Close() + + c := Client{Key: testKeyEC, dir: &Directory{NewNonceURL: ts.URL, NewAccountURL: ts.URL}} + a, err := c.GetAccount(context.Background()) + if err != nil { + t.Fatal(err) + } + if a.OrdersURL != "https://example.com/acme/orders" { + t.Errorf("a.OrdersURL = %q; want https://example.com/acme/orders", a.OrdersURL) + } + if a.Status != StatusValid { + t.Errorf("a.Status = %q; want valid", a.Status) + } + if !reflect.DeepEqual(a.Contact, contacts) { + t.Errorf("a.Contact = %v; want %v", a.Contact, contacts) + } + if a.URL != ts.URL { + t.Errorf("a.URL = %q; want %q", a.URL, ts.URL) + } +} + +func TestCreateOrder(t *testing.T) { + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method == "HEAD" { + w.Header().Set("Replay-Nonce", "test-nonce") + return + } + if r.Method != "POST" { + t.Errorf("r.Method = %q; want POST", r.Method) + } + + var j struct { + Identifiers []struct { + Type string + Value string + } + } + decodeJWSRequest(t, &j, r) + + // Test request + if len(j.Identifiers) != 1 { + t.Errorf("len(j.Identifiers) = %d; want 1", len(j.Identifiers)) + } + if j.Identifiers[0].Type != "dns" { + t.Errorf("j.Identifier.Type = %q; want dns", j.Identifiers[0].Type) + } + if j.Identifiers[0].Value != "example.com" { + t.Errorf("j.Identifier.Value = %q; want example.com", j.Identifiers[0].Value) + } + + w.Header().Set("Location", "https://example.com/acme/order/1") + w.WriteHeader(http.StatusCreated) + fmt.Fprintf(w, `{ + "identifiers": [{"type":"dns","value":"example.com"}], + "status":"pending", + "authorizations":["https://example.com/acme/order/1/1"], + "finalize":"https://example.com/acme/order/1/finalize" + }`) + })) + defer ts.Close() + + cl := Client{Key: testKeyEC, accountURL: "https://example.com/acme/account", dir: &Directory{NewOrderURL: ts.URL, NewNonceURL: ts.URL}} + o, err := cl.CreateOrder(context.Background(), NewOrder("example.com")) + if err != nil { + t.Fatal(err) + } + + if o.URL != "https://example.com/acme/order/1" { + t.Errorf("URL = %q; want https://example.com/acme/order/1", o.URL) + } + if o.Status != "pending" { + t.Errorf("Status = %q; want pending", o.Status) + } + if o.FinalizeURL != "https://example.com/acme/order/1/finalize" { + t.Errorf("FinalizeURL = %q; want https://example.com/acme/order/1/finalize", o.FinalizeURL) + } + + if n := len(o.Identifiers); n != 1 { + t.Fatalf("len(o.Identifiers) = %d; want 1", n) + } + if o.Identifiers[0].Type != "dns" { + t.Errorf("Identifiers[0].Type = %q; want dns", o.Identifiers[0].Type) + } + if o.Identifiers[0].Value != "example.com" { + t.Errorf("Identifiers[0].Value = %q; want example.com", o.Identifiers[0].Value) + } + + if n := len(o.Authorizations); n != 1 { + t.Fatalf("len(o.Authorizations) = %d; want 1", n) + } + if o.Authorizations[0] != "https://example.com/acme/order/1/1" { + t.Errorf("o.Authorizations[0] = %q; https://example.com/acme/order/1/1", o.Authorizations[0]) + } +} + +func TestGetAuthorization(t *testing.T) { + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method == "HEAD" { + w.Header().Set("Replay-Nonce", "nonce") + return + } + if r.Method != "POST" { + t.Errorf("r.Method = %q; want POST", r.Method) + } + + w.WriteHeader(http.StatusOK) + fmt.Fprintf(w, `{ + "identifier": {"type":"dns","value":"example.com"}, + "status":"pending", + "challenges":[ + { + "type":"http-01", + "status":"pending", + "url":"https://example.com/acme/challenge/publickey/id1", + "token":"token1" + }, + { + "type":"tls-sni-02", + "status":"pending", + "url":"https://example.com/acme/challenge/publickey/id2", + "token":"token2" + } + ] + }`) + })) + defer ts.Close() + + cl := newTestClient(testKeyEC, ts) + auth, err := cl.GetAuthorization(context.Background(), ts.URL) + if err != nil { + t.Fatal(err) + } + + if auth.Status != "pending" { + t.Errorf("Status = %q; want pending", auth.Status) + } + if auth.Identifier.Type != "dns" { + t.Errorf("Identifier.Type = %q; want dns", auth.Identifier.Type) + } + if auth.Identifier.Value != "example.com" { + t.Errorf("Identifier.Value = %q; want example.com", auth.Identifier.Value) + } + + if n := len(auth.Challenges); n != 2 { + t.Fatalf("len(set.Challenges) = %d; want 2", n) + } + + c := auth.Challenges[0] + if c.Type != "http-01" { + t.Errorf("c.Type = %q; want http-01", c.Type) + } + if c.URL != "https://example.com/acme/challenge/publickey/id1" { + t.Errorf("c.URI = %q; want https://example.com/acme/challenge/publickey/id1", c.URL) + } + if c.Token != "token1" { + t.Errorf("c.Token = %q; want token1", c.Token) + } + + c = auth.Challenges[1] + if c.Type != "tls-sni-02" { + t.Errorf("c.Type = %q; want tls-sni-02", c.Type) + } + if c.URL != "https://example.com/acme/challenge/publickey/id2" { + t.Errorf("c.URI = %q; want https://example.com/acme/challenge/publickey/id2", c.URL) + } + if c.Token != "token2" { + t.Errorf("c.Token = %q; want token2", c.Token) + } +} + +func TestWaitAuthorization(t *testing.T) { + var count int + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method == "HEAD" { + w.Header().Set("Replay-Nonce", "nonce") + return + } + count++ + w.Header().Set("Retry-After", "0") + if count > 1 { + fmt.Fprintf(w, `{"status":"valid"}`) + return + } + fmt.Fprintf(w, `{"status":"pending"}`) + })) + defer ts.Close() + + type res struct { + authz *Authorization + err error + } + done := make(chan res) + defer close(done) + go func() { + client := newTestClient(testKey, ts) + a, err := client.WaitAuthorization(context.Background(), ts.URL) + done <- res{a, err} + }() + + select { + case <-time.After(5 * time.Second): + t.Fatal("WaitAuthz took too long to return") + case res := <-done: + if res.err != nil { + t.Fatalf("res.err = %v", res.err) + } + if res.authz == nil { + t.Fatal("res.authz is nil") + } + } +} + +func TestWaitAuthorizationInvalid(t *testing.T) { + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method == "HEAD" { + w.Header().Set("Replay-Nonce", "nonce") + return + } + fmt.Fprintf(w, `{"status":"invalid"}`) + })) + defer ts.Close() + + res := make(chan error) + defer close(res) + go func() { + client := newTestClient(testKey, ts) + _, err := client.WaitAuthorization(context.Background(), ts.URL) + res <- err + }() + + select { + case <-time.After(3 * time.Second): + t.Fatal("WaitAuthz took too long to return") + case err := <-res: + if err == nil { + t.Error("err is nil") + } + if _, ok := err.(AuthorizationError); !ok { + t.Errorf("err is %T; want *AuthorizationError", err) + } + } +} + +func TestWaitAuthorizationClientError(t *testing.T) { + const code = http.StatusBadRequest + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(code) + })) + defer ts.Close() + + ch := make(chan error, 1) + go func() { + client := newTestClient(testKey, ts) + _, err := client.WaitAuthorization(context.Background(), ts.URL) + ch <- err + }() + + select { + case <-time.After(3 * time.Second): + t.Fatal("WaitAuthz took too long to return") + case err := <-ch: + res, ok := err.(*Error) + if !ok { + t.Fatalf("err is %v (%T); want a non-nil *Error", err, err) + } + if res.StatusCode != code { + t.Errorf("res.StatusCode = %d; want %d", res.StatusCode, code) + } + } +} + +func TestWaitAuthorizationCancel(t *testing.T) { + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Retry-After", "60") + fmt.Fprintf(w, `{"status":"pending"}`) + })) + defer ts.Close() + + res := make(chan error) + defer close(res) + go func() { + client := newTestClient(testKey, ts) + ctx, cancel := context.WithTimeout(context.Background(), 200*time.Millisecond) + defer cancel() + _, err := client.WaitAuthorization(ctx, ts.URL) + res <- err + }() + + select { + case <-time.After(time.Second): + t.Fatal("WaitAuthz took too long to return") + case err := <-res: + if err == nil { + t.Error("err is nil") + } + } +} + +func TestDeactivateAuthorization(t *testing.T) { + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method == "HEAD" { + w.Header().Set("Replay-Nonce", "nonce") + return + } + switch r.URL.Path { + case "/1": + var req struct { + Status string + } + decodeJWSRequest(t, &req, r) + if req.Status != "deactivated" { + t.Errorf("req.Status = %q; want deactivated", req.Status) + } + case "/2": + w.WriteHeader(http.StatusInternalServerError) + case "/account": + w.Header().Set("Location", "https://example.com/acme/account/0") + w.Write([]byte("{}")) + } + })) + defer ts.Close() + client := &Client{Key: testKey, dir: &Directory{NewNonceURL: ts.URL, NewAccountURL: ts.URL + "/account"}} + ctx := context.Background() + if err := client.DeactivateAuthorization(ctx, ts.URL+"/1"); err != nil { + t.Errorf("err = %v", err) + } + if client.DeactivateAuthorization(ctx, ts.URL+"/2") == nil { + t.Error("nil error") + } +} + +func TestGetChallenge(t *testing.T) { + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method == "HEAD" { + w.Header().Set("Replay-Nonce", "nonce") + return + } + if r.Method != "POST" { + t.Errorf("r.Method = %q; want POST", r.Method) + } + + w.WriteHeader(http.StatusOK) + fmt.Fprintf(w, `{ + "type":"http-01", + "status":"pending", + "url":"https://example.com/acme/challenge/publickey/id1", + "validated": "2014-12-01T12:05:00Z", + "error": { + "type": "urn:ietf:params:acme:error:malformed", + "detail": "rejected", + "subproblems": [ + { + "type": "urn:ietf:params:acme:error:unknown", + "detail": "invalid", + "identifier": { + "type": "dns", + "value": "_example.com" + } + } + ] + }, + "token":"token1"}`) + })) + defer ts.Close() + + cl := newTestClient(testKeyEC, ts) + chall, err := cl.GetChallenge(context.Background(), ts.URL) + if err != nil { + t.Fatal(err) + } + + if chall.Status != "pending" { + t.Errorf("Status = %q; want pending", chall.Status) + } + if chall.Type != "http-01" { + t.Errorf("c.Type = %q; want http-01", chall.Type) + } + if chall.URL != "https://example.com/acme/challenge/publickey/id1" { + t.Errorf("c.URI = %q; want https://example.com/acme/challenge/publickey/id1", chall.URL) + } + if chall.Token != "token1" { + t.Errorf("c.Token = %q; want token1", chall.Token) + } + vt, _ := time.Parse(time.RFC3339, "2014-12-01T12:05:00Z") + if !chall.Validated.Equal(vt) { + t.Errorf("c.Validated = %v; want %v", chall.Validated, vt) + } + e := chall.Error + if e.Type != "urn:ietf:params:acme:error:malformed" { + t.Fatalf("e.Type = %q; want urn:ietf:params:acme:error:malformed", e.Type) + } + if e.Detail != "rejected" { + t.Fatalf("e.Detail = %q; want rejected", e.Detail) + } + if l := len(e.Subproblems); l != 1 { + t.Fatalf("len(e.Subproblems) = %d; want 1", l) + } + p := e.Subproblems[0] + if p.Type != "urn:ietf:params:acme:error:unknown" { + t.Fatalf("p.Type = %q; want urn:ietf:params:acme:error:unknown", p.Type) + } + if p.Detail != "invalid" { + t.Fatalf("p.Detail = %q; want rejected", p.Detail) + } + if p.Identifier.Type != "dns" { + t.Fatalf("p.Identifier.Type = %q; want dns", p.Identifier.Type) + } + if p.Identifier.Value != "_example.com" { + t.Fatalf("p.Identifier.Type = %q; want _example.com", p.Identifier.Value) + } +} + +func TestAcceptChallenge(t *testing.T) { + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method == "HEAD" { + w.Header().Set("Replay-Nonce", "test-nonce") + return + } + if r.Method != "POST" { + t.Errorf("r.Method = %q; want POST", r.Method) + } + + var j struct { + Auth string `json:"keyAuthorization"` + } + decodeJWSRequest(t, &j, r) + + keyAuth := "token1." + testKeyECThumbprint + if j.Auth != keyAuth { + t.Errorf(`keyAuthorization = %q; want %q`, j.Auth, keyAuth) + } + + // Respond to request + w.WriteHeader(http.StatusOK) + fmt.Fprintf(w, `{ + "type":"http-01", + "status":"pending", + "url":"https://example.com/acme/challenge/publickey/id1", + "token":"token1", + "keyAuthorization":%q + }`, keyAuth) + })) + defer ts.Close() + + cl := Client{Key: testKeyEC, accountURL: "https://example.com/acme/account", dir: &Directory{NewNonceURL: ts.URL}} + c, err := cl.AcceptChallenge(context.Background(), &Challenge{ + URL: ts.URL, + Token: "token1", + }) + if err != nil { + t.Fatal(err) + } + + if c.Type != "http-01" { + t.Errorf("c.Type = %q; want http-01", c.Type) + } + if c.URL != "https://example.com/acme/challenge/publickey/id1" { + t.Errorf("c.URL = %q; want https://example.com/acme/challenge/publickey/id1", c.URL) + } + if c.Token != "token1" { + t.Errorf("c.Token = %q; want token1", c.Token) + } +} + +func TestFinalizeOrder(t *testing.T) { + notBefore := time.Now() + notAfter := notBefore.AddDate(0, 2, 0) + timeNow = func() time.Time { return notBefore } + var sampleCert []byte + + var ts *httptest.Server + var orderGets int + ts = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method == "HEAD" { + w.Header().Set("Replay-Nonce", "test-nonce") + return + } + if r.URL.Path == "/cert" && r.Method == "POST" { + pem.Encode(w, &pem.Block{Type: "CERTIFICATE", Bytes: sampleCert}) + return + } + if r.URL.Path == "/order" { + status := "processing" + if orderGets > 0 { + status = "valid" + } + fmt.Fprintf(w, `{ + "identifiers": [{"type":"dns","value":"example.com"}], + "status":%q, + "authorizations":["https://example.com/acme/order/1/1"], + "finalize":"https://example.com/acme/order/1/finalize", + "certificate":%q + }`, status, ts.URL+"/cert") + orderGets++ + return + } + if r.Method != "POST" { + t.Errorf("r.Method = %q; want POST", r.Method) + } + + var j struct { + CSR string `json:"csr"` + } + decodeJWSRequest(t, &j, r) + + template := x509.Certificate{ + SerialNumber: big.NewInt(int64(1)), + Subject: pkix.Name{ + Organization: []string{"goacme"}, + }, + NotBefore: notBefore, + NotAfter: notAfter, + + KeyUsage: x509.KeyUsageKeyEncipherment | x509.KeyUsageDigitalSignature, + ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, + BasicConstraintsValid: true, + } + + var err error + sampleCert, err = x509.CreateCertificate(rand.Reader, &template, &template, &testKeyEC.PublicKey, testKeyEC) + if err != nil { + t.Fatalf("Error creating certificate: %v", err) + } + + w.Header().Set("Location", "/order") + fmt.Fprintf(w, `{ + "identifiers": [{"type":"dns","value":"example.com"}], + "status":"processing", + "authorizations":["https://example.com/acme/order/1/1"], + "finalize":"https://example.com/acme/order/1/finalize" + }`) + })) + defer ts.Close() + + csr := x509.CertificateRequest{ + Version: 0, + Subject: pkix.Name{ + CommonName: "example.com", + Organization: []string{"goacme"}, + }, + } + csrb, err := x509.CreateCertificateRequest(rand.Reader, &csr, testKeyEC) + if err != nil { + t.Fatal(err) + } + + c := Client{Key: testKeyEC, accountURL: "https://example.com/acme/account", dir: &Directory{NewNonceURL: ts.URL}} + cert, err := c.FinalizeOrder(context.Background(), ts.URL, csrb) + if err != nil { + t.Fatal(err) + } + if cert == nil { + t.Errorf("cert is nil") + } +} + +func TestWaitOrderInvalid(t *testing.T) { + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method == "HEAD" { + w.Header().Set("Replay-Nonce", "nonce") + return + } + const order = `{"status":%q}` + if r.URL.Path == "/invalid" { + fmt.Fprintf(w, order, "invalid") + } + if r.URL.Path == "/pending" { + fmt.Fprintf(w, order, "pending") + } + })) + defer ts.Close() + + client := newTestClient(testKey, ts) + _, err := client.WaitOrder(context.Background(), ts.URL+"/pending") + if e, ok := err.(OrderPendingError); ok { + if e.Order == nil { + t.Error("order is nil") + } + if e.Order.Status != "pending" { + t.Errorf("status = %q; want pending", e.Order.Status) + } + } else if err != nil { + t.Error(err) + } + + _, err = client.WaitOrder(context.Background(), ts.URL+"/invalid") + if e, ok := err.(OrderInvalidError); ok { + if e.Order == nil { + t.Error("order is nil") + } + if e.Order.Status != "invalid" { + t.Errorf("status = %q; want invalid", e.Order.Status) + } + } else if err != nil { + t.Error(err) + } +} + +func TestGetOrder(t *testing.T) { + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method == "HEAD" { + w.Header().Set("Replay-Nonce", "nonce") + return + } + fmt.Fprintf(w, `{ + "identifiers": [{"type":"dns","value":"example.com"}], + "status":"valid", + "authorizations":["https://example.com/acme/order/1/1"], + "finalize":"https://example.com/acme/order/1/finalize", + "certificate":"https://example.com/acme/cert" + }`) + })) + defer ts.Close() + + client := newTestClient(testKey, ts) + o, err := client.GetOrder(context.Background(), ts.URL) + if err != nil { + t.Fatal(err) + } + if o.URL != ts.URL { + t.Errorf("URL = %q; want %s", o.URL, ts.URL) + } + if o.Status != "valid" { + t.Errorf("Status = %q; want valid", o.Status) + } + if l := len(o.Authorizations); l != 1 { + t.Errorf("len(Authorizations) = %d; want 1", l) + } + if v := o.Authorizations[0]; v != "https://example.com/acme/order/1/1" { + t.Errorf("Authorizations[0] = %q; want https://example.com/acme/order/1/1", v) + } + if l := len(o.Identifiers); l != 1 { + t.Errorf("len(Identifiers) = %d; want 1", l) + } + if v := o.Identifiers[0].Type; v != "dns" { + t.Errorf("Identifiers[0].Type = %q; want dns", v) + } + if v := o.Identifiers[0].Value; v != "example.com" { + t.Errorf("Identifiers[0].Value = %q; want example.com", v) + } + if o.FinalizeURL != "https://example.com/acme/order/1/finalize" { + t.Errorf("FinalizeURL = %q; want https://example.com/acme/order/1/finalize", o.FinalizeURL) + } + if o.CertificateURL != "https://example.com/acme/cert" { + t.Errorf("FinalizeURL = %q; want https://example.com/acme/cert", o.CertificateURL) + } +} + +func TestRevokeCert(t *testing.T) { + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method == "HEAD" { + w.Header().Set("Replay-Nonce", "nonce") + return + } + + var req struct { + Certificate string + Reason int + } + decodeJWSRequest(t, &req, r) + if req.Reason != 1 { + t.Errorf("req.Reason = %d; want 1", req.Reason) + } + // echo -n cert | base64 | tr -d '=' | tr '/+' '_-' + cert := "Y2VydA" + if req.Certificate != cert { + t.Errorf("req.Certificate = %q; want %q", req.Certificate, cert) + } + })) + defer ts.Close() + client := &Client{Key: testKeyEC, accountURL: "https://example.com/acme/account", dir: &Directory{RevokeCertURL: ts.URL, NewNonceURL: ts.URL}} + ctx := context.Background() + if err := client.RevokeCert(ctx, nil, []byte("cert"), CRLReasonKeyCompromise); err != nil { + t.Fatal(err) + } +} + +func TestNonce_add(t *testing.T) { + var c Client + c.addNonce(http.Header{"Replay-Nonce": {"nonce"}}) + c.addNonce(http.Header{"Replay-Nonce": {}}) + c.addNonce(http.Header{"Replay-Nonce": {"nonce"}}) + + nonces := map[string]struct{}{"nonce": {}} + if !reflect.DeepEqual(c.nonces, nonces) { + t.Errorf("c.nonces = %q; want %q", c.nonces, nonces) + } +} + +func TestNonce_addMax(t *testing.T) { + c := &Client{nonces: make(map[string]struct{})} + for i := 0; i < maxNonces; i++ { + c.nonces[fmt.Sprintf("%d", i)] = struct{}{} + } + c.addNonce(http.Header{"Replay-Nonce": {"nonce"}}) + if n := len(c.nonces); n != maxNonces { + t.Errorf("len(c.nonces) = %d; want %d", n, maxNonces) + } +} + +func TestNonce_fetch(t *testing.T) { + tests := []struct { + code int + nonce string + }{ + {http.StatusOK, "nonce1"}, + {http.StatusBadRequest, "nonce2"}, + {http.StatusOK, ""}, + } + var i int + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != "HEAD" { + t.Errorf("%d: r.Method = %q; want HEAD", i, r.Method) + } + w.Header().Set("Replay-Nonce", tests[i].nonce) + w.WriteHeader(tests[i].code) + })) + defer ts.Close() + for ; i < len(tests); i++ { + test := tests[i] + c := &Client{dir: &Directory{NewNonceURL: ts.URL}} + n, err := c.fetchNonce(context.Background()) + if n != test.nonce { + t.Errorf("%d: n=%q; want %q", i, n, test.nonce) + } + switch { + case err == nil && test.nonce == "": + t.Errorf("%d: n=%q, err=%v; want non-nil error", i, n, err) + case err != nil && test.nonce != "": + t.Errorf("%d: n=%q, err=%v; want %q", i, n, err, test.nonce) + } + } +} + +func TestNonce_fetchError(t *testing.T) { + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusTooManyRequests) + })) + defer ts.Close() + c := &Client{dir: &Directory{NewNonceURL: ts.URL}} + _, err := c.fetchNonce(context.Background()) + e, ok := err.(*Error) + if !ok { + t.Fatalf("err is %T; want *Error", err) + } + if e.StatusCode != http.StatusTooManyRequests { + t.Errorf("e.StatusCode = %d; want %d", e.StatusCode, http.StatusTooManyRequests) + } +} + +func TestNonce_postJWS(t *testing.T) { + var count int + seen := make(map[string]bool) + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + count++ + w.Header().Set("Replay-Nonce", fmt.Sprintf("nonce%d", count)) + if r.Method == "HEAD" { + // We expect the client do a HEAD request + // but only to fetch the first nonce. + return + } + // Make client.CreateOrder happy; we're not testing its result. + defer func() { + w.Header().Set("Location", "https://example.com/acme/order/1") + w.WriteHeader(http.StatusCreated) + w.Write([]byte(`{"status":"valid"}`)) + }() + + head, err := decodeJWSHead(r) + if err != nil { + t.Errorf("decodeJWSHead: %v", err) + return + } + if head.Nonce == "" { + t.Error("head.Nonce is empty") + return + } + if seen[head.Nonce] { + t.Errorf("nonce is already used: %q", head.Nonce) + } + seen[head.Nonce] = true + })) + defer ts.Close() + + client := Client{Key: testKey, accountURL: "https://example.com/acme/account", dir: &Directory{NewOrderURL: ts.URL, NewNonceURL: ts.URL}} + if _, err := client.CreateOrder(context.Background(), NewOrder("example.com")); err != nil { + t.Errorf("client.CreateOrder 1: %v", err) + } + // The second call should not generate another extra HEAD request. + if _, err := client.CreateOrder(context.Background(), NewOrder("example.com")); err != nil { + t.Errorf("client.CreateOrder 2: %v", err) + } + + if count != 3 { + t.Errorf("total requests count: %d; want 3", count) + } + if n := len(client.nonces); n != 1 { + t.Errorf("len(client.nonces) = %d; want 1", n) + } + for k := range seen { + if _, exist := client.nonces[k]; exist { + t.Errorf("used nonce %q in client.nonces", k) + } + } +} + +func TestRetryPostJWS(t *testing.T) { + var count int + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + count++ + w.Header().Set("Replay-Nonce", fmt.Sprintf("nonce%d", count)) + if r.Method == "HEAD" { + // We expect the client to do 2 head requests to fetch + // nonces, one to start and another after getting badNonce + return + } + + head, err := decodeJWSHead(r) + if err != nil { + t.Errorf("decodeJWSHead: %v", err) + } else if head.Nonce == "" { + t.Error("head.Nonce is empty") + } else if head.Nonce == "nonce1" { + // return a badNonce error to force the call to retry + w.WriteHeader(http.StatusBadRequest) + w.Write([]byte(`{"type":"urn:ietf:params:acme:error:badNonce"}`)) + return + } + // Make client.CreateOrder happy; we're not testing its result. + w.Header().Set("Location", "https://example.com/acme/order/1") + w.WriteHeader(http.StatusCreated) + w.Write([]byte(`{"status":"valid"}`)) + })) + defer ts.Close() + + client := Client{Key: testKey, accountURL: "https://example.com/acme/account", dir: &Directory{NewOrderURL: ts.URL, NewNonceURL: ts.URL}} + // This call will fail with badNonce, causing a retry + if _, err := client.CreateOrder(context.Background(), NewOrder("example.com")); err != nil { + t.Errorf("client.CreateOrder 1: %v", err) + } + if count != 4 { + t.Errorf("total requests count: %d; want 4", count) + } +} + +func TestErrorResponse(t *testing.T) { + s := `{ + "status": 400, + "type": "urn:acme:error:xxx", + "detail": "text" + }` + res := &http.Response{ + StatusCode: 400, + Status: "400 Bad Request", + Body: ioutil.NopCloser(strings.NewReader(s)), + Header: http.Header{"X-Foo": {"bar"}}, + } + err := responseError(res) + v, ok := err.(*Error) + if !ok { + t.Fatalf("err = %+v (%T); want *Error type", err, err) + } + if v.StatusCode != 400 { + t.Errorf("v.StatusCode = %v; want 400", v.StatusCode) + } + if v.Type != "urn:acme:error:xxx" { + t.Errorf("v.Type = %q; want urn:acme:error:xxx", v.Type) + } + if v.Detail != "text" { + t.Errorf("v.Detail = %q; want text", v.Detail) + } + if !reflect.DeepEqual(v.Header, res.Header) { + t.Errorf("v.Header = %+v; want %+v", v.Header, res.Header) + } +} + +func TestHTTP01Challenge(t *testing.T) { + const ( + token = "xxx" + // thumbprint is precomputed for testKeyEC in jws_test.go + value = token + "." + testKeyECThumbprint + urlpath = "/.well-known/acme-challenge/" + token + ) + client := &Client{Key: testKeyEC} + val, err := client.HTTP01ChallengeResponse(token) + if err != nil { + t.Fatal(err) + } + if val != value { + t.Errorf("val = %q; want %q", val, value) + } + if path := client.HTTP01ChallengePath(token); path != urlpath { + t.Errorf("path = %q; want %q", path, urlpath) + } +} + +func TestDNS01ChallengeRecord(t *testing.T) { + // echo -n xxx. | \ + // openssl dgst -binary -sha256 | \ + // base64 | tr -d '=' | tr '/+' '_-' + const value = "8DERMexQ5VcdJ_prpPiA0mVdp7imgbCgjsG4SqqNMIo" + + client := &Client{Key: testKeyEC} + val, err := client.DNS01ChallengeRecord("xxx") + if err != nil { + t.Fatal(err) + } + if val != value { + t.Errorf("val = %q; want %q", val, value) + } +} + +func TestBackoff(t *testing.T) { + tt := []struct{ min, max time.Duration }{ + {time.Second, 2 * time.Second}, + {2 * time.Second, 3 * time.Second}, + {4 * time.Second, 5 * time.Second}, + {8 * time.Second, 9 * time.Second}, + } + for i, test := range tt { + d := backoff(i, time.Minute) + if d < test.min || test.max < d { + t.Errorf("%d: d = %v; want between %v and %v", i, d, test.min, test.max) + } + } + + min, max := time.Second, 2*time.Second + if d := backoff(-1, time.Minute); d < min || max < d { + t.Errorf("d = %v; want between %v and %v", d, min, max) + } + + bound := 10 * time.Second + if d := backoff(100, bound); d != bound { + t.Errorf("d = %v; want %v", d, bound) + } +} diff --git a/pkg/acme/x/acme/integration_test.go b/pkg/acme/x/acme/integration_test.go new file mode 100644 index 000000000..12ca92a5b --- /dev/null +++ b/pkg/acme/x/acme/integration_test.go @@ -0,0 +1,146 @@ +// Copyright 2018 The Go Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +// +build integration_test + +package acme_test + +import ( + "context" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/tls" + "crypto/x509" + "net" + "net/http" + "os" + "reflect" + "testing" + + "golang.org/x/crypto/acme" +) + +// This test works with Pebble and Let's Encrypt staging. +// For pebble use: ACME_DIRECTORY_URL=https://localhost:14000/dir go test -tags integration_test +// For Let's Encrypt you'll need a publicly accessible HTTP server like `ngrok http 8080` and then +// TEST_HOST=xxx.ngrok.io:8080 ACME_DIRECTORY_URL=https://acme-staging-v02.api.letsencrypt.org/directory TEST_ACCOUNT_GET=1 TEST_REVOKE=1 go test -tags integration_test +func TestIntegration(t *testing.T) { + dir := os.Getenv("ACME_DIRECTORY_URL") + testAccountGet := os.Getenv("TEST_ACCOUNT_GET") != "" + testRevoke := os.Getenv("TEST_REVOKE") != "" + testHost := os.Getenv("TEST_HOST") + if testHost == "" { + testHost = "localhost:5002" + } + testIdentifier, listenPort, _ := net.SplitHostPort(testHost) + if dir == "" { + t.Fatal("ACME_DIRECTORY_URL is required") + } + + key, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + c := &acme.Client{ + Key: key, + DirectoryURL: dir, + HTTPClient: &http.Client{ + Transport: &http.Transport{ + TLSClientConfig: &tls.Config{ + InsecureSkipVerify: true, + }, + }, + }, + } + + a := &acme.Account{ + Contact: []string{"mailto:user@example.com"}, + TermsAgreed: true, + } + na, err := c.CreateAccount(context.Background(), a) + if err != nil { + t.Fatal(err) + } + + if !reflect.DeepEqual(a.Contact, na.Contact) { + t.Errorf("na.Contact = %q; want %q", na.Contact, a.Contact) + } + if na.URL == "" { + t.Fatal("empty na.URL") + } + + // this endpoint is not supported by pebble, so put it behind a flag + if testAccountGet { + na, err = c.GetAccount(context.Background()) + if err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(a.Contact, na.Contact) { + t.Errorf("na.Contact = %q; want %q", na.Contact, a.Contact) + } + } + + order, err := c.CreateOrder(context.Background(), acme.NewOrder(testIdentifier)) + if err != nil { + t.Fatal(err) + } + auth, err := c.GetAuthorization(context.Background(), order.Authorizations[0]) + if err != nil { + t.Fatal(err) + } + + var challenge *acme.Challenge + for _, ch := range auth.Challenges { + if ch.Type == "http-01" { + challenge = ch + break + } + } + if challenge == nil { + t.Fatal("missing http-01 challenge") + } + + l, err := net.Listen("tcp", ":"+listenPort) + if err != nil { + t.Errorf("error listening for challenge: %s", err) + } + defer l.Close() + go http.Serve(l, http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { + if req.URL.Path != c.HTTP01ChallengePath(challenge.Token) { + w.WriteHeader(404) + return + } + res, _ := c.HTTP01ChallengeResponse(challenge.Token) + w.Write([]byte(res)) + })) + + _, err = c.AcceptChallenge(context.Background(), challenge) + if err != nil { + t.Fatal(err) + } + + _, err = c.WaitAuthorization(context.Background(), order.Authorizations[0]) + if err != nil { + t.Fatal(err) + } + + certKey, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + csr, _ := x509.CreateCertificateRequest(rand.Reader, &x509.CertificateRequest{DNSNames: []string{testIdentifier}}, certKey) + der, err := c.FinalizeOrder(context.Background(), order.FinalizeURL, csr) + if err != nil { + t.Fatal(err) + } + + cert, err := x509.ParseCertificate(der[0]) + if err != nil { + t.Fatal(err) + } + if cert.DNSNames[0] != testIdentifier { + t.Errorf("unexpected DNSNames %v", cert.DNSNames) + } + + if testRevoke { + if err := c.RevokeCert(context.Background(), certKey, der[0], acme.CRLReasonUnspecified); err != nil { + t.Fatal(err) + } + } +} diff --git a/pkg/acme/x/acme/jws.go b/pkg/acme/x/acme/jws.go new file mode 100644 index 000000000..c00f37eaf --- /dev/null +++ b/pkg/acme/x/acme/jws.go @@ -0,0 +1,163 @@ +// Copyright 2015 The Go Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package acme + +// From: https://golang.org/cl/86635 + +import ( + "crypto" + "crypto/ecdsa" + "crypto/rand" + "crypto/rsa" + "crypto/sha256" + _ "crypto/sha512" // need for EC keys + "encoding/base64" + "encoding/json" + "fmt" + "math/big" +) + +// jwsEncodeJSON signs claimset using provided key and a nonce. +// The result is serialized in JSON format. +// See https://tools.ietf.org/html/rfc7515#section-7. +func jwsEncodeJSON(claimset interface{}, key crypto.Signer, accountURL, url, nonce string) ([]byte, error) { + alg, sha := jwsHasher(key) + if alg == "" || !sha.Available() { + return nil, ErrUnsupportedKey + } + var phead string + if accountURL == "" { + jwk, err := jwkEncode(key.Public()) + if err != nil { + return nil, err + } + phead = fmt.Sprintf(`{"alg":%q,"jwk":%s,"nonce":%q,"url":%q}`, alg, jwk, nonce, url) + } else { + phead = fmt.Sprintf(`{"alg":%q,"kid":%q,"nonce":%q,"url":%q}`, alg, accountURL, nonce, url) + } + phead = base64.RawURLEncoding.EncodeToString([]byte(phead)) + var payload string + if claimset != nil { + cs, err := json.Marshal(claimset) + if err != nil { + return nil, err + } + payload = base64.RawURLEncoding.EncodeToString(cs) + } + hash := sha.New() + hash.Write([]byte(phead + "." + payload)) + sig, err := jwsSign(key, sha, hash.Sum(nil)) + if err != nil { + return nil, err + } + + enc := struct { + Protected string `json:"protected"` + Payload string `json:"payload"` + Sig string `json:"signature"` + }{ + Protected: phead, + Payload: payload, + Sig: base64.RawURLEncoding.EncodeToString(sig), + } + return json.Marshal(&enc) +} + +// jwkEncode encodes public part of an RSA or ECDSA key into a JWK. +// The result is also suitable for creating a JWK thumbprint. +// https://tools.ietf.org/html/rfc7517 +func jwkEncode(pub crypto.PublicKey) (string, error) { + switch pub := pub.(type) { + case *rsa.PublicKey: + // https://tools.ietf.org/html/rfc7518#section-6.3.1 + n := pub.N + e := big.NewInt(int64(pub.E)) + // Field order is important. + // See https://tools.ietf.org/html/rfc7638#section-3.3 for details. + return fmt.Sprintf(`{"e":"%s","kty":"RSA","n":"%s"}`, + base64.RawURLEncoding.EncodeToString(e.Bytes()), + base64.RawURLEncoding.EncodeToString(n.Bytes()), + ), nil + case *ecdsa.PublicKey: + // https://tools.ietf.org/html/rfc7518#section-6.2.1 + p := pub.Curve.Params() + n := p.BitSize / 8 + if p.BitSize%8 != 0 { + n++ + } + x := pub.X.Bytes() + if n > len(x) { + x = append(make([]byte, n-len(x)), x...) + } + y := pub.Y.Bytes() + if n > len(y) { + y = append(make([]byte, n-len(y)), y...) + } + // Field order is important. + // See https://tools.ietf.org/html/rfc7638#section-3.3 for details. + return fmt.Sprintf(`{"crv":"%s","kty":"EC","x":"%s","y":"%s"}`, + p.Name, + base64.RawURLEncoding.EncodeToString(x), + base64.RawURLEncoding.EncodeToString(y), + ), nil + } + return "", ErrUnsupportedKey +} + +// jwsSign signs the digest using the given key. +// It returns ErrUnsupportedKey if the key type is unknown. +// The hash is used only for RSA keys. +func jwsSign(key crypto.Signer, hash crypto.Hash, digest []byte) ([]byte, error) { + switch key := key.(type) { + case *rsa.PrivateKey: + return key.Sign(rand.Reader, digest, hash) + case *ecdsa.PrivateKey: + r, s, err := ecdsa.Sign(rand.Reader, key, digest) + if err != nil { + return nil, err + } + rb, sb := r.Bytes(), s.Bytes() + size := key.Params().BitSize / 8 + if size%8 > 0 { + size++ + } + sig := make([]byte, size*2) + copy(sig[size-len(rb):], rb) + copy(sig[size*2-len(sb):], sb) + return sig, nil + } + return nil, ErrUnsupportedKey +} + +// jwsHasher indicates suitable JWS algorithm name and a hash function +// to use for signing a digest with the provided key. +// It returns ("", 0) if the key is not supported. +func jwsHasher(key crypto.Signer) (string, crypto.Hash) { + switch key := key.(type) { + case *rsa.PrivateKey: + return "RS256", crypto.SHA256 + case *ecdsa.PrivateKey: + switch key.Params().Name { + case "P-256": + return "ES256", crypto.SHA256 + case "P-384": + return "ES384", crypto.SHA384 + case "P-521": + return "ES512", crypto.SHA512 + } + } + return "", 0 +} + +// JWKThumbprint creates a JWK thumbprint out of pub +// as specified in https://tools.ietf.org/html/rfc7638. +func JWKThumbprint(pub crypto.PublicKey) (string, error) { + jwk, err := jwkEncode(pub) + if err != nil { + return "", err + } + b := sha256.Sum256([]byte(jwk)) + return base64.RawURLEncoding.EncodeToString(b[:]), nil +} diff --git a/pkg/acme/x/acme/jws_test.go b/pkg/acme/x/acme/jws_test.go new file mode 100644 index 000000000..3d0b08482 --- /dev/null +++ b/pkg/acme/x/acme/jws_test.go @@ -0,0 +1,320 @@ +// Copyright 2015 The Go Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package acme + +import ( + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rsa" + "crypto/x509" + "encoding/base64" + "encoding/json" + "encoding/pem" + "fmt" + "math/big" + "testing" +) + +const ( + testKeyPEM = ` +-----BEGIN RSA PRIVATE KEY----- +MIIEowIBAAKCAQEA4xgZ3eRPkwoRvy7qeRUbmMDe0V+xH9eWLdu0iheeLlrmD2mq +WXfP9IeSKApbn34g8TuAS9g5zhq8ELQ3kmjr+KV86GAMgI6VAcGlq3QrzpTCf/30 +Ab7+zawrfRaFONa1HwEzPY1KHnGVkxJc85gNkwYI9SY2RHXtvln3zs5wITNrdosq +EXeaIkVYBEhbhNu54pp3kxo6TuWLi9e6pXeWetEwmlBwtWZlPoib2j3TxLBksKZf +oyFyek380mHgJAumQ/I2fjj98/97mk3ihOY4AgVdCDj1z/GCoZkG5Rq7nbCGyosy +KWyDX00Zs+nNqVhoLeIvXC4nnWdJMZ6rogxyQQIDAQABAoIBACIEZTOI1Kao9nmV +9IeIsuaR1Y61b9neOF/MLmIVIZu+AAJFCMB4Iw11FV6sFodwpEyeZhx2WkpWVN+H +r19eGiLX3zsL0DOdqBJoSIHDWCCMxgnYJ6nvS0nRxX3qVrBp8R2g12Ub+gNPbmFm +ecf/eeERIVxfifd9VsyRu34eDEvcmKFuLYbElFcPh62xE3x12UZvV/sN7gXbawpP +G+w255vbE5MoaKdnnO83cTFlcHvhn24M/78qP7Te5OAeelr1R89kYxQLpuGe4fbS +zc6E3ym5Td6urDetGGrSY1Eu10/8sMusX+KNWkm+RsBRbkyKq72ks/qKpOxOa+c6 +9gm+Y8ECgYEA/iNUyg1ubRdH11p82l8KHtFC1DPE0V1gSZsX29TpM5jS4qv46K+s +8Ym1zmrORM8x+cynfPx1VQZQ34EYeCMIX212ryJ+zDATl4NE0I4muMvSiH9vx6Xc +7FmhNnaYzPsBL5Tm9nmtQuP09YEn8poiOJFiDs/4olnD5ogA5O4THGkCgYEA5MIL +qWYBUuqbEWLRtMruUtpASclrBqNNsJEsMGbeqBJmoMxdHeSZckbLOrqm7GlMyNRJ +Ne/5uWRGSzaMYuGmwsPpERzqEvYFnSrpjW5YtXZ+JtxFXNVfm9Z1gLLgvGpOUCIU +RbpoDckDe1vgUuk3y5+DjZihs+rqIJ45XzXTzBkCgYBWuf3segruJZy5rEKhTv+o +JqeUvRn0jNYYKFpLBeyTVBrbie6GkbUGNIWbrK05pC+c3K9nosvzuRUOQQL1tJbd +4gA3oiD9U4bMFNr+BRTHyZ7OQBcIXdz3t1qhuHVKtnngIAN1p25uPlbRFUNpshnt +jgeVoHlsBhApcs5DUc+pyQKBgDzeHPg/+g4z+nrPznjKnktRY1W+0El93kgi+J0Q +YiJacxBKEGTJ1MKBb8X6sDurcRDm22wMpGfd9I5Cv2v4GsUsF7HD/cx5xdih+G73 +c4clNj/k0Ff5Nm1izPUno4C+0IOl7br39IPmfpSuR6wH/h6iHQDqIeybjxyKvT1G +N0rRAoGBAKGD+4ZI/E1MoJ5CXB8cDDMHagbE3cq/DtmYzE2v1DFpQYu5I4PCm5c7 +EQeIP6dZtv8IMgtGIb91QX9pXvP0aznzQKwYIA8nZgoENCPfiMTPiEDT9e/0lObO +9XWsXpbSTsRPj0sv1rB+UzBJ0PgjK4q2zOF0sNo7b1+6nlM3BWPx +-----END RSA PRIVATE KEY----- +` + + // This thumbprint is for the testKey defined above. + testKeyThumbprint = "6nicxzh6WETQlrvdchkz-U3e3DOQZ4heJKU63rfqMqQ" + + // openssl ecparam -name secp256k1 -genkey -noout + testKeyECPEM = ` +-----BEGIN EC PRIVATE KEY----- +MHcCAQEEIK07hGLr0RwyUdYJ8wbIiBS55CjnkMD23DWr+ccnypWLoAoGCCqGSM49 +AwEHoUQDQgAE5lhEug5xK4xBDZ2nAbaxLtaLiv85bxJ7ePd1dkO23HThqIrvawF5 +QAaS/RNouybCiRhRjI3EaxLkQwgrCw0gqQ== +-----END EC PRIVATE KEY----- +` + // openssl ecparam -name secp384r1 -genkey -noout + testKeyEC384PEM = ` +-----BEGIN EC PRIVATE KEY----- +MIGkAgEBBDAQ4lNtXRORWr1bgKR1CGysr9AJ9SyEk4jiVnlUWWUChmSNL+i9SLSD +Oe/naPqXJ6CgBwYFK4EEACKhZANiAAQzKtj+Ms0vHoTX5dzv3/L5YMXOWuI5UKRj +JigpahYCqXD2BA1j0E/2xt5vlPf+gm0PL+UHSQsCokGnIGuaHCsJAp3ry0gHQEke +WYXapUUFdvaK1R2/2hn5O+eiQM8YzCg= +-----END EC PRIVATE KEY----- +` + // openssl ecparam -name secp521r1 -genkey -noout + testKeyEC512PEM = ` +-----BEGIN EC PRIVATE KEY----- +MIHcAgEBBEIBSNZKFcWzXzB/aJClAb305ibalKgtDA7+70eEkdPt28/3LZMM935Z +KqYHh/COcxuu3Kt8azRAUz3gyr4zZKhlKUSgBwYFK4EEACOhgYkDgYYABAHUNKbx +7JwC7H6pa2sV0tERWhHhB3JmW+OP6SUgMWryvIKajlx73eS24dy4QPGrWO9/ABsD +FqcRSkNVTXnIv6+0mAF25knqIBIg5Q8M9BnOu9GGAchcwt3O7RDHmqewnJJDrbjd +GGnm6rb+NnWR9DIopM0nKNkToWoF/hzopxu4Ae/GsQ== +-----END EC PRIVATE KEY----- +` + // 1. openssl ec -in key.pem -noout -text + // 2. remove first byte, 04 (the header); the rest is X and Y + // 3. convert each with: echo | xxd -r -p | base64 -w 100 | tr -d '=' | tr '/+' '_-' + testKeyECPubX = "5lhEug5xK4xBDZ2nAbaxLtaLiv85bxJ7ePd1dkO23HQ" + testKeyECPubY = "4aiK72sBeUAGkv0TaLsmwokYUYyNxGsS5EMIKwsNIKk" + testKeyEC384PubX = "MyrY_jLNLx6E1-Xc79_y-WDFzlriOVCkYyYoKWoWAqlw9gQNY9BP9sbeb5T3_oJt" + testKeyEC384PubY = "Dy_lB0kLAqJBpyBrmhwrCQKd68tIB0BJHlmF2qVFBXb2itUdv9oZ-TvnokDPGMwo" + testKeyEC512PubX = "AdQ0pvHsnALsfqlraxXS0RFaEeEHcmZb44_pJSAxavK8gpqOXHvd5Lbh3LhA8atY738AGwMWpxFKQ1VNeci_r7SY" + testKeyEC512PubY = "AXbmSeogEiDlDwz0Gc670YYByFzC3c7tEMeap7CckkOtuN0Yaebqtv42dZH0MiikzSco2ROhagX-HOinG7gB78ax" + + // echo -n '{"crv":"P-256","kty":"EC","x":"","y":""}' | \ + // openssl dgst -binary -sha256 | base64 | tr -d '=' | tr '/+' '_-' + testKeyECThumbprint = "zedj-Bd1Zshp8KLePv2MB-lJ_Hagp7wAwdkA0NUTniU" +) + +var ( + testKey *rsa.PrivateKey + testKeyEC *ecdsa.PrivateKey + testKeyEC384 *ecdsa.PrivateKey + testKeyEC512 *ecdsa.PrivateKey +) + +func init() { + testKey = parseRSA(testKeyPEM, "testKeyPEM") + testKeyEC = parseEC(testKeyECPEM, "testKeyECPEM") + testKeyEC384 = parseEC(testKeyEC384PEM, "testKeyEC384PEM") + testKeyEC512 = parseEC(testKeyEC512PEM, "testKeyEC512PEM") +} + +func decodePEM(s, name string) []byte { + d, _ := pem.Decode([]byte(s)) + if d == nil { + panic("no block found in " + name) + } + return d.Bytes +} + +func parseRSA(s, name string) *rsa.PrivateKey { + b := decodePEM(s, name) + k, err := x509.ParsePKCS1PrivateKey(b) + if err != nil { + panic(fmt.Sprintf("%s: %v", name, err)) + } + return k +} + +func parseEC(s, name string) *ecdsa.PrivateKey { + b := decodePEM(s, name) + k, err := x509.ParseECPrivateKey(b) + if err != nil { + panic(fmt.Sprintf("%s: %v", name, err)) + } + return k +} + +func TestJWSEncodeJSON(t *testing.T) { + claims := struct{ Msg string }{"Hello JWS"} + // JWS signed with testKey and "nonce" as the nonce value + // JSON-serialized JWS fields are split for easier testing + const ( + // {"alg":"RS256","jwk":{"e":"AQAB","kty":"RSA","n":"..."},"url":"https://example.com","nonce":"nonce"} + protected = "eyJhbGciOiJSUzI1NiIsImp3ayI6eyJlIjoiQVFBQiIsImt0eSI6" + + "IlJTQSIsIm4iOiI0eGdaM2VSUGt3b1J2eTdxZVJVYm1NRGUwVi14" + + "SDllV0xkdTBpaGVlTGxybUQybXFXWGZQOUllU0tBcGJuMzRnOFR1" + + "QVM5ZzV6aHE4RUxRM2ttanItS1Y4NkdBTWdJNlZBY0dscTNRcnpw" + + "VENmXzMwQWI3LXphd3JmUmFGT05hMUh3RXpQWTFLSG5HVmt4SmM4" + + "NWdOa3dZSTlTWTJSSFh0dmxuM3pzNXdJVE5yZG9zcUVYZWFJa1ZZ" + + "QkVoYmhOdTU0cHAza3hvNlR1V0xpOWU2cFhlV2V0RXdtbEJ3dFda" + + "bFBvaWIyajNUeExCa3NLWmZveUZ5ZWszODBtSGdKQXVtUV9JMmZq" + + "ajk4Xzk3bWszaWhPWTRBZ1ZkQ0RqMXpfR0NvWmtHNVJxN25iQ0d5" + + "b3N5S1d5RFgwMFpzLW5OcVZob0xlSXZYQzRubldkSk1aNnJvZ3h5" + + "UVEifSwibm9uY2UiOiJub25jZSIsInVybCI6Imh0dHBzOi8vZXhh" + + "bXBsZS5jb20ifQ" + // {"Msg":"Hello JWS"} + payload = "eyJNc2ciOiJIZWxsbyBKV1MifQ" + signature = "EtedUusG_N7NkuHRs9Ios6V0_VZdjYPut8vqRMDHvZQ3kZO0d5-9" + + "BivWINleGajAW29So64s4WYsITx2Y0g3obSw70Xsr8XVsVox2Wsx" + + "RJgd6KBNk1SGzqUW7-yEaS0fs0ax5SHPwpS9ek9WPCZ0MphfUH3d" + + "qK40x6dYbgY9mInfzf7L11QeRrQdJfGuef_74SJGTp6D4B5UrR2w" + + "m-AoSsRXY5A99U7J8YE9LFTg7pUQRSQWqqGZu-U9VDiB8bBvViVH" + + "1abbI5xHaSagDb1avfdIXqYv_QVeMXF67Nis8f963FOdX0zwjpob" + + "mpi-rsSmLBEtUkLERBIU_8JRdkXMcw" + ) + + b, err := jwsEncodeJSON(claims, testKey, "", "https://example.com", "nonce") + if err != nil { + t.Fatal(err) + } + var jws struct{ Protected, Payload, Signature string } + if err := json.Unmarshal(b, &jws); err != nil { + t.Fatal(err) + } + if jws.Protected != protected { + t.Errorf("protected:\n%s\nwant:\n%s", jws.Protected, protected) + } + if jws.Payload != payload { + t.Errorf("payload:\n%s\nwant:\n%s", jws.Payload, payload) + } + if jws.Signature != signature { + t.Errorf("signature:\n%s\nwant:\n%s", jws.Signature, signature) + } +} + +func TestJWSEncodeJSONEC(t *testing.T) { + tt := []struct { + key *ecdsa.PrivateKey + x, y string + alg, crv string + }{ + {testKeyEC, testKeyECPubX, testKeyECPubY, "ES256", "P-256"}, + {testKeyEC384, testKeyEC384PubX, testKeyEC384PubY, "ES384", "P-384"}, + {testKeyEC512, testKeyEC512PubX, testKeyEC512PubY, "ES512", "P-521"}, + } + for i, test := range tt { + claims := struct{ Msg string }{"Hello JWS"} + b, err := jwsEncodeJSON(claims, test.key, "", "https://example.com", "nonce") + if err != nil { + t.Errorf("%d: %v", i, err) + continue + } + var jws struct{ Protected, Payload, Signature string } + if err := json.Unmarshal(b, &jws); err != nil { + t.Errorf("%d: %v", i, err) + continue + } + + b, err = base64.RawURLEncoding.DecodeString(jws.Protected) + if err != nil { + t.Errorf("%d: jws.Protected: %v", i, err) + } + var head struct { + Alg string + Nonce string + JWK struct { + Crv string + Kty string + X string + Y string + } `json:"jwk"` + } + if err := json.Unmarshal(b, &head); err != nil { + t.Errorf("%d: jws.Protected: %v", i, err) + } + if head.Alg != test.alg { + t.Errorf("%d: head.Alg = %q; want %q", i, head.Alg, test.alg) + } + if head.Nonce != "nonce" { + t.Errorf("%d: head.Nonce = %q; want nonce", i, head.Nonce) + } + if head.JWK.Crv != test.crv { + t.Errorf("%d: head.JWK.Crv = %q; want %q", i, head.JWK.Crv, test.crv) + } + if head.JWK.Kty != "EC" { + t.Errorf("%d: head.JWK.Kty = %q; want EC", i, head.JWK.Kty) + } + if head.JWK.X != test.x { + t.Errorf("%d: head.JWK.X = %q; want %q", i, head.JWK.X, test.x) + } + if head.JWK.Y != test.y { + t.Errorf("%d: head.JWK.Y = %q; want %q", i, head.JWK.Y, test.y) + } + } +} + +func TestJWKThumbprintRSA(t *testing.T) { + // Key example from RFC 7638 + const base64N = "0vx7agoebGcQSuuPiLJXZptN9nndrQmbXEps2aiAFbWhM78LhWx4cbbfAAt" + + "VT86zwu1RK7aPFFxuhDR1L6tSoc_BJECPebWKRXjBZCiFV4n3oknjhMstn6" + + "4tZ_2W-5JsGY4Hc5n9yBXArwl93lqt7_RN5w6Cf0h4QyQ5v-65YGjQR0_FD" + + "W2QvzqY368QQMicAtaSqzs8KJZgnYb9c7d0zgdAZHzu6qMQvRL5hajrn1n9" + + "1CbOpbISD08qNLyrdkt-bFTWhAI4vMQFh6WeZu0fM4lFd2NcRwr3XPksINH" + + "aQ-G_xBniIqbw0Ls1jF44-csFCur-kEgU8awapJzKnqDKgw" + const base64E = "AQAB" + const expected = "NzbLsXh8uDCcd-6MNwXF4W_7noWXFZAfHkxZsRGC9Xs" + + b, err := base64.RawURLEncoding.DecodeString(base64N) + if err != nil { + t.Fatalf("Error parsing example key N: %v", err) + } + n := new(big.Int).SetBytes(b) + + b, err = base64.RawURLEncoding.DecodeString(base64E) + if err != nil { + t.Fatalf("Error parsing example key E: %v", err) + } + e := new(big.Int).SetBytes(b) + + pub := &rsa.PublicKey{N: n, E: int(e.Uint64())} + th, err := JWKThumbprint(pub) + if err != nil { + t.Error(err) + } + if th != expected { + t.Errorf("thumbprint = %q; want %q", th, expected) + } +} + +func TestJWKThumbprintEC(t *testing.T) { + // Key example from RFC 7520 + // expected was computed with + // echo -n '{"crv":"P-521","kty":"EC","x":"","y":""}' | \ + // openssl dgst -binary -sha256 | \ + // base64 | \ + // tr -d '=' | tr '/+' '_-' + const ( + base64X = "AHKZLLOsCOzz5cY97ewNUajB957y-C-U88c3v13nmGZx6sYl_oJXu9A5RkT" + + "KqjqvjyekWF-7ytDyRXYgCF5cj0Kt" + base64Y = "AdymlHvOiLxXkEhayXQnNCvDX4h9htZaCJN34kfmC6pV5OhQHiraVySsUda" + + "QkAgDPrwQrJmbnX9cwlGfP-HqHZR1" + expected = "dHri3SADZkrush5HU_50AoRhcKFryN-PI6jPBtPL55M" + ) + + b, err := base64.RawURLEncoding.DecodeString(base64X) + if err != nil { + t.Fatalf("Error parsing example key X: %v", err) + } + x := new(big.Int).SetBytes(b) + + b, err = base64.RawURLEncoding.DecodeString(base64Y) + if err != nil { + t.Fatalf("Error parsing example key Y: %v", err) + } + y := new(big.Int).SetBytes(b) + + pub := &ecdsa.PublicKey{Curve: elliptic.P521(), X: x, Y: y} + th, err := JWKThumbprint(pub) + if err != nil { + t.Error(err) + } + if th != expected { + t.Errorf("thumbprint = %q; want %q", th, expected) + } +} + +func TestJWKThumbprintErrUnsupportedKey(t *testing.T) { + _, err := JWKThumbprint(struct{}{}) + if err != ErrUnsupportedKey { + t.Errorf("err = %q; want %q", err, ErrUnsupportedKey) + } +} diff --git a/pkg/acme/x/acme/types.go b/pkg/acme/x/acme/types.go new file mode 100644 index 000000000..583b232b5 --- /dev/null +++ b/pkg/acme/x/acme/types.go @@ -0,0 +1,411 @@ +// Copyright 2016 The Go Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package acme + +// From: https://golang.org/cl/86635 + +import ( + "errors" + "fmt" + "net/http" + "time" +) + +// ACME server response statuses used to describe Account, Authorization, and Challenge states. +const ( + StatusUnknown = "unknown" + StatusPending = "pending" + StatusProcessing = "processing" + StatusValid = "valid" + StatusInvalid = "invalid" + StatusRevoked = "revoked" + StatusDeactivated = "deactivated" +) + +// CRLReasonCode identifies the reason for a certificate revocation. +type CRLReasonCode int + +// CRL reason codes as defined in RFC 5280. +const ( + CRLReasonUnspecified CRLReasonCode = 0 + CRLReasonKeyCompromise CRLReasonCode = 1 + CRLReasonCACompromise CRLReasonCode = 2 + CRLReasonAffiliationChanged CRLReasonCode = 3 + CRLReasonSuperseded CRLReasonCode = 4 + CRLReasonCessationOfOperation CRLReasonCode = 5 + CRLReasonCertificateHold CRLReasonCode = 6 + CRLReasonRemoveFromCRL CRLReasonCode = 8 + CRLReasonPrivilegeWithdrawn CRLReasonCode = 9 + CRLReasonAACompromise CRLReasonCode = 10 +) + +// ErrUnsupportedKey is returned when an unsupported key type is encountered. +var ErrUnsupportedKey = errors.New("acme: unknown key type; only RSA and ECDSA are supported") + +// Error is an ACME error as defined in RFC 7807, Problem Details for HTTP APIs. +type Error struct { + // StatusCode is The HTTP status code generated by the origin server. + StatusCode int + + // Type is a URI that identifies the problem type, typically in + // a "urn:ietf:params:acme:error:xxx" form. + Type string + + // Detail is a human-readable explanation specific to this occurrence of the problem. + Detail string + + // Subproblems is an optional list of additional error information, usually + // indicating problems with specific identifiers during authorization. + Subproblems []Subproblem + + // Header is the original server error response headers. + // It may be nil. + Header http.Header +} + +func (e *Error) Error() string { + return fmt.Sprintf("acme: %s: %s", e.Type, e.Detail) +} + +// An Subproblem is additional error detail that is included in an Error, +// usually indicating a problem with a specific identifier during authorization. +type Subproblem struct { + // Type is a URI that identifies the subproblem type, typically in + // "urn:ietf:params:acme:error:xxx" form. + Type string + + // Detail is a human-readable explanation specific to this occurrence of the + // subproblem. + Detail string + + // Identifier optionally indicates the identifier that this subproblem is about. + Identifier *AuthzID +} + +// OrderInvalidError is returned when an order is marked as invalid. +type OrderInvalidError struct { + // Order is the order that is invalid. + Order *Order +} + +func (e OrderInvalidError) Error() string { + if e.Order == nil || e.Order.Error == nil { + return "acme: order is invalid" + } + return fmt.Sprintf("acme: invalid order (%s): %s", e.Order.Error.Type, e.Order.Error.Detail) +} + +// OrderPendingError is returned when an order is still pending after an +// attempted finalization. +type OrderPendingError struct { + // Order is the order that is pending. + Order *Order +} + +func (e OrderPendingError) Error() string { + return "acme: order is pending due to incomplete authorization" +} + +// AuthorizationError is returned when an authorization is marked as invalid. +type AuthorizationError struct { + // Authorization is the authorization that is invalid. + Authorization *Authorization +} + +func (e AuthorizationError) Error() string { + if e.Authorization == nil { + return "acme: authorization is invalid" + } + return fmt.Sprintf("acme: authorization for identifier %s is %s", e.Authorization.Identifier.Value, e.Authorization.Status) +} + +// RateLimit reports whether err represents a rate limit error and +// any Retry-After duration returned by the server. +// +// See the following for more details on rate limiting: +// https://tools.ietf.org/html/draft-ietf-acme-acme-09#section-6.5 +func RateLimit(err error) (time.Time, bool) { + e, ok := err.(*Error) + if !ok || e.Type != "urn:ietf:params:acme:error:rateLimited" { + return time.Time{}, false + } + if e.Header == nil { + return time.Time{}, true + } + return retryAfter(e.Header.Get("Retry-After")), true +} + +// Account is a user account. It is associated with a private key. +type Account struct { + // URL uniquely identifies the account. + URL string + + // Status is the status of the account. Valid values are StatusValid, + // StatusDeactivated, and StatusRevoked. + Status string + + // Contact is a list of URLs that the server can use to contact the client + // for issues related to this account. + Contact []string + + // TermsAgreed indicates agreement with the terms of service. It is not + // modifiable after account creation. + TermsAgreed bool + + // OrdersURL is the URL used to fetch a list of orders submitted by this + // account. + OrdersURL string +} + +// Directory is ACME server discovery data. +type Directory struct { + // NewNonceURL is used to retrieve new nonces. + NewNonceURL string + + // NewAccountURL is used to create new accounts. + NewAccountURL string + + // NewOrderURL is used to create new orders. + NewOrderURL string + + // NewAuthzURL is used to create new authorizations. + NewAuthzURL string + + // RevokeCertURL is used to revoke a certificate. + RevokeCertURL string + + // KeyChangeURL is used to change the account key. + KeyChangeURL string + + // Terms is a URL identifying the current terms of service. + Terms string + + // Website is an HTTP or HTTPS URL locating a website + // providing more information about the ACME server. + Website string + + // CAA consists of lowercase hostname elements, which the ACME server + // recognises as referring to itself for the purposes of CAA record validation + // as defined in RFC6844. + CAA []string + + // ExternalAccountRequired, if true, indicates that the CA requires that all + // new account requests include an ExternalAccountBinding field associating + // the new account with an external account. + ExternalAccountRequired bool +} + +// NewOrder creates a new order with the domains provided, suitable for creating +// a TLS certificate order with CreateOrder. +func NewOrder(domains ...string) *Order { + o := &Order{Identifiers: make([]AuthzID, len(domains))} + for i, d := range domains { + o.Identifiers[i] = AuthzID{ + Type: "dns", + Value: d, + } + } + return o +} + +// An Order represents a request for a certificate and is used to track the +// progress through to issuance. +type Order struct { + // URL uniquely identifies the order. + URL string + + // Status is the status of the order. It will be one of StatusPending, + // StatusProcessing, StatusValid, and StatusInvalid. + Status string + + // Expires is the teimstamp after which the server will consider the order invalid. + Expires time.Time + + // Identifiers is a list of identifiers that the order pertains to. + Identifiers []AuthzID + + // NotBefore is an optional requested value of the notBefore field in the certificate. + NotBefore time.Time + + // NotAfter is an optional requested value of the notAfter field in the certificate. + NotAfter time.Time + + // Error is the error that occurred while processing the order, if any. + Error *Error + + // Authorizations is a list of URLs for authorizations that the client needs + // to complete before the requested certificate can be issued. For + // valid/invalid orders, these are the authorizations that were completed. + Authorizations []string + + // FinalizeURL is the URL that is used to finalize the Order. + FinalizeURL string + + // CertificateURL is the URL for the certificate that has been issued in + // response to this order. + CertificateURL string + + // RetryAfter is the timestamp, if any, to wait for before fetching this + // order again. + RetryAfter time.Time +} + +// A Challenge is a CA challenge for an identifier. +type Challenge struct { + // Type is the challenge type, e.g. "http-01" or "dns-01". + Type string + + // URL is the URL where a challenge response can be posted. + URL string + + // Token is a random value that uniquely identifies the challenge. + Token string + + // Validated is the time at which the server validated this challenge. + Validated time.Time + + // Status identifies the status of this challenge. Valid values are + // StatusPending, StatusValid, and StatusInvalid. + Status string + + // Error indicates the errors that occurred while the server was validating + // this challenge. + Error *Error +} + +// Authorization encodes an authorization response. +type Authorization struct { + // URL uniquely identifies the authorization. + URL string + + // Status is the status of the authorization. Valid values are + // StatusPending, StatusProcessing, StatusValid, StatusInvalid, and + // StatusRevoked. + Status string + + // Identifier is the identifier that the account is authorized to represent. + Identifier AuthzID + + // Wildcard is true if the authorization is for the base domain of a wildcard identifier. + Wildcard bool + + // Expires is the timestamp after which the server will consider this authorization invalid. + Expires time.Time + + // Challenges is the list of challenges that the client can fulfill in order + // to prove posession of the identifier. For valid/invalid authorizations, + // this is the list of challenges that were used. + Challenges []*Challenge +} + +// AuthzID is an identifier that an account is authorized to represent. +type AuthzID struct { + Type string // The type of identifier, e.g. "dns". + Value string // The identifier itself, e.g. "example.org". +} + +type wireAuthzID struct { + Type string `json:"type"` + Value string `json:"value"` +} + +// wireAuthz is ACME JSON representation of Authorization objects. +type wireAuthz struct { + Status string + Challenges []wireChallenge + Expires time.Time + Identifier struct { + Type string + Value string + } + Wildcard bool +} + +func (z *wireAuthz) authorization(url string) *Authorization { + a := &Authorization{ + URL: url, + Status: z.Status, + Expires: z.Expires, + Identifier: AuthzID{Type: z.Identifier.Type, Value: z.Identifier.Value}, + Wildcard: z.Wildcard, + Challenges: make([]*Challenge, len(z.Challenges)), + } + for i, v := range z.Challenges { + a.Challenges[i] = v.challenge() + } + return a +} + +// wireChallenge is ACME JSON challenge representation. +type wireChallenge struct { + URL string + Type string + Token string + Status string + Validated time.Time + Error *Error +} + +func (c *wireChallenge) challenge() *Challenge { + v := &Challenge{ + URL: c.URL, + Type: c.Type, + Token: c.Token, + Status: c.Status, + Validated: c.Validated, + Error: c.Error, + } + if v.Status == "" { + v.Status = StatusUnknown + } + return v +} + +// wireError is a subset of fields of the Problem Details object +// as described in https://tools.ietf.org/html/rfc7807#section-3.1. +type wireError struct { + Status int + Type string + Detail string + Subproblems []Subproblem +} + +func (e *wireError) error(h http.Header) *Error { + return &Error{ + StatusCode: e.Status, + Type: e.Type, + Detail: e.Detail, + Subproblems: e.Subproblems, + Header: h, + } +} + +type wireOrder struct { + Status string + Expires time.Time + Identifiers []AuthzID + NotBefore time.Time + NotAfter time.Time + Error *Error + Authorizations []string + Finalize string + Certificate string +} + +func (o *wireOrder) order(url string, retryHeader string) *Order { + return &Order{ + URL: url, + Status: o.Status, + Expires: o.Expires, + Identifiers: o.Identifiers, + NotBefore: o.NotBefore, + NotAfter: o.NotAfter, + Error: o.Error, + Authorizations: o.Authorizations, + FinalizeURL: o.Finalize, + CertificateURL: o.Certificate, + RetryAfter: retryAfter(retryHeader), + } +} diff --git a/pkg/acme/x/acme/types_test.go b/pkg/acme/x/acme/types_test.go new file mode 100644 index 000000000..c0564f4ca --- /dev/null +++ b/pkg/acme/x/acme/types_test.go @@ -0,0 +1,63 @@ +// Copyright 2017 The Go Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package acme + +import ( + "errors" + "net/http" + "testing" + "time" +) + +func TestRateLimit(t *testing.T) { + now := time.Date(2017, 04, 27, 10, 0, 0, 0, time.UTC) + f := timeNow + defer func() { timeNow = f }() + timeNow = func() time.Time { return now } + + h120, hTime := http.Header{}, http.Header{} + h120.Set("Retry-After", "120") + hTime.Set("Retry-After", "Tue Apr 27 11:00:00 2017") + + err1 := &Error{ + Type: "urn:ietf:params:acme:error:nolimit", + Header: h120, + } + err2 := &Error{ + Type: "urn:ietf:params:acme:error:rateLimited", + Header: h120, + } + err3 := &Error{ + Type: "urn:ietf:params:acme:error:rateLimited", + Header: nil, + } + err4 := &Error{ + Type: "urn:ietf:params:acme:error:rateLimited", + Header: hTime, + } + + tt := []struct { + err error + res time.Time + ok bool + }{ + {}, + {err: errors.New("dummy")}, + {err: err1}, + {err: err2, res: now.Add(2 * time.Minute), ok: true}, + {err: err3, ok: true}, + {err: err4, res: now.Add(time.Hour), ok: true}, + } + for i, test := range tt { + res, ok := RateLimit(test.err) + if ok != test.ok { + t.Errorf("%d: RateLimit(%+v): ok = %v; want %v", i, test.err, ok, test.ok) + continue + } + if !res.Equal(test.res) { + t.Errorf("%d: RateLimit(%+v) = %v; want %v", i, test.err, res, test.res) + } + } +} diff --git a/pkg/common/ingress/controller/controller.go b/pkg/common/ingress/controller/controller.go index 393e2e579..cc075bc02 100644 --- a/pkg/common/ingress/controller/controller.go +++ b/pkg/common/ingress/controller/controller.go @@ -135,6 +135,15 @@ type Configuration struct { DisableNodeList bool AnnPrefix string + AcmeServer bool + AcmeCheckPeriod time.Duration + AcmeFailInitialDuration time.Duration + AcmeFailMaxDuration time.Duration + AcmeElectionID string + AcmeSecretKeyName string + AcmeTokenConfigmapName string + AcmeTrackTLSAnn bool + // optional TCPConfigMapName string // optional @@ -212,6 +221,11 @@ func (ic *GenericController) GetConfig() *Configuration { return ic.cfg } +// GetStopCh ... +func (ic *GenericController) GetStopCh() chan struct{} { + return ic.stopCh +} + // Info returns information about the backend func (ic GenericController) Info() *ingress.BackendInfo { return ic.cfg.Backend.Info() diff --git a/pkg/common/ingress/controller/launch.go b/pkg/common/ingress/controller/launch.go index c2c1b9ad1..9a158c4e2 100644 --- a/pkg/common/ingress/controller/launch.go +++ b/pkg/common/ingress/controller/launch.go @@ -49,6 +49,36 @@ func NewIngressController(backend ingress.Controller) *GenericController { configMap = flags.String("configmap", "", `Name of the ConfigMap that contains the custom configuration to use`) + acmeServer = flags.Bool("acme-server", false, + `Enables acme server. This server is used to receive and answer challenges from + Lets Encrypt or other acme implementations.`) + + acmeCheckPeriod = flags.Duration("acme-check-period", 24*time.Hour, + `Time between checks of invalid or expiring certificates`) + + acmeElectionID = flags.String("acme-election-id", "acme-leader", + `Prefix of the election ID used to choose the acme leader`) + + acmeFailInitialDuration = flags.Duration("acme-fail-initial-duration", 5*time.Minute, + `The initial time to wait to retry sign a new certificate after a failure. + The time between retries will grow exponentially until 'acme-fail-max-duration'`) + + acmeFailMaxDuration = flags.Duration("acme-fail-max-duration", 8*time.Hour, + `The maximum time to wait after failing to sign a new certificate`) + + acmeSecretKeyName = flags.String("acme-secret-key-name", "acme-private-key", + `Name and an optional namespace of the secret which will store the acme account + private key. If a namespace is not provided, the secret will be created in the same + namespace of the controller pod`) + + acmeTokenConfigmapName = flags.String("acme-token-configmap-name", "acme-validation-tokens", + `Name and an optional namespace of the configmap which will store acme tokens + used to answer the acme challenges. If a namespace is not provided, the secret will be created + in the same namespace of the controller pod`) + + acmeTrackTLSAnn = flags.Bool("acme-track-tls-annotation", false, + `Enable tracking of ingress objects annotated with 'kubernetes.io/tls-acme'`) + publishSvc = flags.String("publish-service", "", `Service fronting the ingress controllers. Takes the form namespace/name. The controller will set the endpoint records on the @@ -252,6 +282,14 @@ func NewIngressController(backend ingress.Controller) *GenericController { UpdateStatus: *updateStatus, ElectionID: *electionID, Client: kubeClient, + AcmeServer: *acmeServer, + AcmeCheckPeriod: *acmeCheckPeriod, + AcmeElectionID: *acmeElectionID, + AcmeFailInitialDuration: *acmeFailInitialDuration, + AcmeFailMaxDuration: *acmeFailMaxDuration, + AcmeSecretKeyName: *acmeSecretKeyName, + AcmeTokenConfigmapName: *acmeTokenConfigmapName, + AcmeTrackTLSAnn: *acmeTrackTLSAnn, RateLimitUpdate: *rateLimitUpdate, ResyncPeriod: *resyncPeriod, DefaultService: *defaultSvc, diff --git a/pkg/common/ingress/controller/listers.go b/pkg/common/ingress/controller/listers.go index 84f05de46..4466859c5 100644 --- a/pkg/common/ingress/controller/listers.go +++ b/pkg/common/ingress/controller/listers.go @@ -218,6 +218,8 @@ func (ic *GenericController) createListers(disableNodeLister bool) (*ingress.Sto } lister := &ingress.StoreLister{} + lister.Secret.Client = ic.cfg.Client + lister.ConfigMap.Client = ic.cfg.Client controller := &cacheController{} diff --git a/pkg/common/ingress/store/main.go b/pkg/common/ingress/store/main.go index 92659338d..c54b2e60b 100644 --- a/pkg/common/ingress/store/main.go +++ b/pkg/common/ingress/store/main.go @@ -20,6 +20,7 @@ import ( "fmt" apiv1 "k8s.io/api/core/v1" + k8s "k8s.io/client-go/kubernetes" "k8s.io/client-go/tools/cache" "k8s.io/kubernetes/pkg/util/node" ) @@ -31,6 +32,7 @@ type IngressLister struct { // SecretLister makes a Store that lists Secrets. type SecretLister struct { + Client k8s.Interface cache.Store } @@ -46,8 +48,20 @@ func (sl *SecretLister) GetByName(name string) (*apiv1.Secret, error) { return s.(*apiv1.Secret), nil } +// CreateOrUpdate ... +func (sl *SecretLister) CreateOrUpdate(secret *apiv1.Secret) (err error) { + cli := sl.Client.CoreV1().Secrets(secret.Namespace) + if _, exists, _ := sl.GetByKey(secret.Namespace + "/" + secret.Name); exists { + _, err = cli.Update(secret) + } else { + _, err = cli.Create(secret) + } + return err +} + // ConfigMapLister makes a Store that lists Configmaps. type ConfigMapLister struct { + Client k8s.Interface cache.Store } @@ -63,6 +77,17 @@ func (cml *ConfigMapLister) GetByName(name string) (*apiv1.ConfigMap, error) { return s.(*apiv1.ConfigMap), nil } +// CreateOrUpdate ... +func (cml *ConfigMapLister) CreateOrUpdate(cm *apiv1.ConfigMap) (err error) { + cli := cml.Client.CoreV1().ConfigMaps(cm.Namespace) + if _, exists, _ := cml.GetByKey(cm.Namespace + "/" + cm.Name); exists { + _, err = cli.Update(cm) + } else { + _, err = cli.Create(cm) + } + return err +} + // ServiceLister makes a Store that lists Services. type ServiceLister struct { cache.Store diff --git a/pkg/controller/cache.go b/pkg/controller/cache.go index 69a4bd2de..b38217724 100644 --- a/pkg/controller/cache.go +++ b/pkg/controller/cache.go @@ -17,6 +17,11 @@ limitations under the License. package controller import ( + "crypto" + "crypto/rand" + "crypto/rsa" + "crypto/x509" + "encoding/pem" "fmt" "os" "strings" @@ -25,6 +30,7 @@ import ( metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" k8s "k8s.io/client-go/kubernetes" + "github.com/jcmoraisjr/haproxy-ingress/pkg/acme" cfile "github.com/jcmoraisjr/haproxy-ingress/pkg/common/file" "github.com/jcmoraisjr/haproxy-ingress/pkg/common/ingress" "github.com/jcmoraisjr/haproxy-ingress/pkg/common/ingress/controller" @@ -33,18 +39,41 @@ import ( ) type cache struct { - client k8s.Interface - listers *ingress.StoreLister - controller *controller.GenericController - crossNS bool + client k8s.Interface + listers *ingress.StoreLister + controller *controller.GenericController + crossNS bool + acmeSecretKeyName string + acmeTokenConfigmapName string } func newCache(client k8s.Interface, listers *ingress.StoreLister, controller *controller.GenericController) *cache { + namespace := os.Getenv("POD_NAMESPACE") + if namespace == "" { + // TODO implement a smart fallback or error checking + // Fallback to a valid name if envvar is not provided. Should never be used because: + // - `namespace` is only used in `acme*` + // - `acme*` is only used by acme client and server + // - acme client and server are only used if leader elector is enabled + // - leader elector will panic if this envvar is not provided + namespace = "default" + } + cfg := controller.GetConfig() + acmeSecretKeyName := cfg.AcmeSecretKeyName + if !strings.Contains(acmeSecretKeyName, "/") { + acmeSecretKeyName = namespace + "/" + acmeSecretKeyName + } + acmeTokenConfigmapName := cfg.AcmeTokenConfigmapName + if !strings.Contains(acmeTokenConfigmapName, "/") { + acmeTokenConfigmapName = namespace + "/" + acmeTokenConfigmapName + } return &cache{ - client: client, - listers: listers, - controller: controller, - crossNS: controller.GetConfig().AllowCrossNamespace, + client: client, + listers: listers, + controller: controller, + crossNS: cfg.AllowCrossNamespace, + acmeSecretKeyName: acmeSecretKeyName, + acmeTokenConfigmapName: acmeTokenConfigmapName, } } @@ -190,3 +219,120 @@ func (c *cache) GetSecretContent(defaultNamespace, secretName, keyName string) ( } return data, nil } + +// Implements acme.ClientResolver +func (c *cache) GetKey() (crypto.Signer, error) { + secret, err := c.listers.Secret.GetByName(c.acmeSecretKeyName) + var key *rsa.PrivateKey + if err == nil { + pemKey, found := secret.Data[api.TLSPrivateKeyKey] + if !found { + return nil, fmt.Errorf("secret '%s' does not have a key", c.acmeSecretKeyName) + } + derBlock, _ := pem.Decode(pemKey) + if derBlock == nil { + return nil, fmt.Errorf("secret '%s' has not a valid pem encoded private key", c.acmeSecretKeyName) + } + key, err = x509.ParsePKCS1PrivateKey(derBlock.Bytes) + if err != nil { + return nil, fmt.Errorf("error parsing acme client private key: %v", err) + } + } + if key == nil { + key, err = rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + return nil, err + } + pemEncode := pem.EncodeToMemory(&pem.Block{ + Type: "RSA PRIVATE KEY", + Bytes: x509.MarshalPKCS1PrivateKey(key), + }) + secretName := strings.Split(c.acmeSecretKeyName, "/") + newSecret := &api.Secret{} + newSecret.Namespace = secretName[0] + newSecret.Name = secretName[1] + newSecret.Data = map[string][]byte{api.TLSPrivateKeyKey: pemEncode} + if err := c.listers.Secret.CreateOrUpdate(newSecret); err != nil { + return nil, err + } + } + return key, nil +} + +// Implements acme.SignerResolver +func (c *cache) GetTLSSecretContent(secretName string) *acme.TLSSecret { + secret, err := c.listers.Secret.GetByName(secretName) + if err != nil { + return nil + } + pemCrt, foundCrt := secret.Data[api.TLSCertKey] + pemKey, foundKey := secret.Data[api.TLSPrivateKeyKey] + if !foundCrt || !foundKey { + return nil + } + derCrt, _ := pem.Decode(pemCrt) + derKey, _ := pem.Decode(pemKey) + if derCrt == nil || derKey == nil { + return nil + } + crt, errCrt := x509.ParseCertificate(derCrt.Bytes) + key, errKey := x509.ParsePKCS1PrivateKey(derKey.Bytes) + if errCrt != nil || errKey != nil { + return nil + } + return &acme.TLSSecret{ + Crt: crt, + Key: key, + } +} + +// Implements acme.SignerResolver +func (c *cache) SetTLSSecretContent(secretName string, pemCrt, pemKey []byte) error { + name := strings.Split(secretName, "/") + secret := &api.Secret{} + secret.Namespace = name[0] + secret.Name = name[1] + secret.Type = api.SecretTypeTLS + secret.Data = map[string][]byte{ + api.TLSCertKey: pemCrt, + api.TLSPrivateKeyKey: pemKey, + } + return c.listers.Secret.CreateOrUpdate(secret) +} + +// Implements acme.ServerResolver +func (c *cache) GetToken(domain, uri string) string { + config, err := c.listers.ConfigMap.GetByName(c.acmeTokenConfigmapName) + if err != nil { + return "" + } + data, found := config.Data[domain] + if !found { + return "" + } + prefix := uri + "=" + if !strings.HasPrefix(data, prefix) { + return "" + } + return strings.TrimPrefix(data, prefix) +} + +// Implements acme.ClientResolver +func (c *cache) SetToken(domain string, uri, token string) error { + config, err := c.listers.ConfigMap.GetByName(c.acmeTokenConfigmapName) + if err != nil { + configName := strings.Split(c.acmeTokenConfigmapName, "/") + config = &api.ConfigMap{} + config.Namespace = configName[0] + config.Name = configName[1] + } + if config.Data == nil { + config.Data = make(map[string]string, 1) + } + if token != "" { + config.Data[domain] = uri + "=" + token + } else { + delete(config.Data, domain) + } + return c.listers.ConfigMap.CreateOrUpdate(config) +} diff --git a/pkg/controller/controller.go b/pkg/controller/controller.go index 65b110ea5..1cbba3108 100644 --- a/pkg/controller/controller.go +++ b/pkg/controller/controller.go @@ -30,7 +30,9 @@ import ( "github.com/spf13/pflag" api "k8s.io/api/core/v1" extensions "k8s.io/api/extensions/v1beta1" + "k8s.io/apimachinery/pkg/util/wait" + "github.com/jcmoraisjr/haproxy-ingress/pkg/acme" "github.com/jcmoraisjr/haproxy-ingress/pkg/common/ingress" "github.com/jcmoraisjr/haproxy-ingress/pkg/common/ingress/annotations/class" "github.com/jcmoraisjr/haproxy-ingress/pkg/common/ingress/controller" @@ -51,7 +53,9 @@ type HAProxyController struct { instance haproxy.Instance logger *logger cache *cache - leaderelector LeaderElector + stopCh chan struct{} + acmeQueue utils.Queue + leaderelector types.LeaderElector updateCount int controller *controller.GenericController cfg *controller.Configuration @@ -106,17 +110,27 @@ func (hc *HAProxyController) configController() { } // starting v0.8 only config + hc.stopCh = hc.controller.GetStopCh() hc.logger = &logger{depth: 1} hc.cache = newCache(hc.cfg.Client, hc.storeLister, hc.controller) - if false { - // initialize only if needed: acme (to be merged) and status (to be moved from old controller) - electorID := fmt.Sprintf("ingress-controller-%s-elector", hc.cfg.IngressClass) + var acmeSigner acme.Signer + if hc.cfg.AcmeServer { + electorID := fmt.Sprintf("%s-%s", hc.cfg.AcmeElectionID, hc.cfg.IngressClass) hc.leaderelector = NewLeaderElector(electorID, hc.logger, hc.cache, hc) + acmeSigner = acme.NewSigner(hc.logger, hc.cache) + hc.acmeQueue = utils.NewFailureRateLimitingQueue( + hc.cfg.AcmeFailInitialDuration, + hc.cfg.AcmeFailMaxDuration, + acmeSigner.Notify, + ) } instanceOptions := haproxy.InstanceOptions{ HAProxyCmd: "haproxy", ReloadCmd: "/haproxy-reload.sh", HAProxyConfigFile: "/etc/haproxy/haproxy.cfg", + AcmeSigner: acmeSigner, + AcmeQueue: hc.acmeQueue, + LeaderElector: hc.leaderelector, ReloadStrategy: *hc.reloadStrategy, MaxOldConfigFiles: *hc.maxOldConfigFiles, ValidateConfig: *hc.validateConfig, @@ -131,13 +145,27 @@ func (hc *HAProxyController) configController() { AnnotationPrefix: hc.cfg.AnnPrefix, DefaultBackend: hc.cfg.DefaultService, DefaultSSLFile: hc.createDefaultSSLFile(hc.cache), + AcmeTrackTLSAnn: hc.cfg.AcmeTrackTLSAnn, } } func (hc *HAProxyController) startServices() { + if hc.cfg.V07 { + return + } if hc.leaderelector != nil { go hc.leaderelector.Run() } + if hc.cfg.AcmeServer { + // TODO deduplicate acme socket + server := acme.NewServer(hc.logger, "/var/run/acme.sock", hc.cache) + // TODO move goroutine from the server to the controller + if err := server.Listen(hc.stopCh); err != nil { + hc.logger.Fatal("error creating the acme server listener: %v", err) + } + go hc.acmeQueue.Run() + go wait.Until(hc.instance.AcmePeriodicCheck, hc.cfg.AcmeCheckPeriod, hc.stopCh) + } } func (hc *HAProxyController) createDefaultSSLFile(cache convtypes.Cache) (tlsFile convtypes.File) { @@ -161,6 +189,7 @@ func (hc *HAProxyController) createDefaultSSLFile(cache convtypes.Cache) (tlsFil // OnStartedLeading ... // implements LeaderSubscriber func (hc *HAProxyController) OnStartedLeading(stop <-chan struct{}) { + hc.instance.AcmePeriodicCheck() } // OnStoppedLeading ... diff --git a/pkg/controller/leaderelection.go b/pkg/controller/leaderelection.go index 84da2fcca..15789013e 100644 --- a/pkg/controller/leaderelection.go +++ b/pkg/controller/leaderelection.go @@ -20,6 +20,8 @@ import ( "os" "time" + "github.com/jcmoraisjr/haproxy-ingress/pkg/types" + api "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/util/wait" @@ -29,12 +31,6 @@ import ( "k8s.io/client-go/tools/record" ) -// LeaderElector ... -type LeaderElector interface { - IsLeader() bool - Run() -} - // LeaderSubscriber ... type LeaderSubscriber interface { OnStartedLeading(stop <-chan struct{}) @@ -48,7 +44,7 @@ type leaderelector struct { } // NewLeaderElector ... -func NewLeaderElector(id string, logger *logger, cache *cache, subscriber LeaderSubscriber) LeaderElector { +func NewLeaderElector(id string, logger *logger, cache *cache, subscriber LeaderSubscriber) types.LeaderElector { hostname, _ := os.Hostname() namespace, podname, err := cache.GetIngressPodName() if err != nil { @@ -104,6 +100,14 @@ func (l *leaderelector) IsLeader() bool { return l.le.IsLeader() } +func (l *leaderelector) LeaderName() string { + name := l.le.GetLeader() + if name == "" { + return "" + } + return name +} + func (l *leaderelector) Run() { go wait.Forever(func() { l.le.Run() diff --git a/pkg/converters/ingress/annotations/global.go b/pkg/converters/ingress/annotations/global.go index 300d95b18..e52c06974 100644 --- a/pkg/converters/ingress/annotations/global.go +++ b/pkg/converters/ingress/annotations/global.go @@ -20,12 +20,38 @@ import ( "fmt" "regexp" "strings" + "time" ingtypes "github.com/jcmoraisjr/haproxy-ingress/pkg/converters/ingress/types" hatypes "github.com/jcmoraisjr/haproxy-ingress/pkg/haproxy/types" "github.com/jcmoraisjr/haproxy-ingress/pkg/utils" ) +func (c *updater) buildGlobalAcme(d *globalData) { + endpoint := d.mapper.Get(ingtypes.GlobalAcmeEndpoint).Value + if endpoint == "" { + return + } + emails := d.mapper.Get(ingtypes.GlobalAcmeEmails).Value + if emails == "" { + c.logger.Warn("skipping acme config, missing email account") + return + } + termsAgreed := d.mapper.Get(ingtypes.GlobalAcmeTermsAgreed).Bool() + if !termsAgreed { + c.logger.Warn("acme terms was not agreed, configure '%s' with \"true\" value", ingtypes.GlobalAcmeTermsAgreed) + return + } + d.acme.Prefix = "/.well-known/acme-challenge/" + d.acme.Socket = "/var/run/acme.sock" + d.acme.Emails = emails + d.acme.Enabled = true + d.acme.Endpoint = endpoint + d.acme.Expiring = time.Duration(d.mapper.Get(ingtypes.GlobalAcmeExpiring).Int()) * 24 * time.Hour + d.acme.Shared = d.mapper.Get(ingtypes.GlobalAcmeShared).Bool() + d.acme.TermsAgreed = termsAgreed +} + func (c *updater) buildGlobalBind(d *globalData) { d.global.Bind.AcceptProxy = d.mapper.Get(ingtypes.GlobalUseProxyProtocol).Bool() d.global.Bind.TCPBindIP = d.mapper.Get(ingtypes.GlobalBindIPAddrTCP).Value diff --git a/pkg/converters/ingress/annotations/host.go b/pkg/converters/ingress/annotations/host.go index e102fad3d..227d95306 100644 --- a/pkg/converters/ingress/annotations/host.go +++ b/pkg/converters/ingress/annotations/host.go @@ -42,6 +42,23 @@ func (c *updater) buildHostAuthTLS(d *hostData) { } } +func (c *updater) buildHostCertSigner(d *hostData) { + signer := d.mapper.Get(ingtypes.HostCertSigner) + if signer.Value == "" { + return + } + if signer.Value != "acme" { + c.logger.Warn("ignoring invalid cert-signer on %v: %s", signer.Source, signer.Value) + return + } + acme := c.haproxy.Acme() + if acme.Endpoint == "" || acme.Emails == "" { + c.logger.Warn("ignoring acme signer on %v due to missing endpoint or email config", signer.Source) + return + } + // just the warnings, ingress.syncIngress() has already added the domains +} + func (c *updater) buildHostSSLPassthrough(d *hostData) { sslpassthrough := d.mapper.Get(ingtypes.HostSSLPassthrough) if !sslpassthrough.Bool() { diff --git a/pkg/converters/ingress/annotations/updater.go b/pkg/converters/ingress/annotations/updater.go index 72be7a9cb..9609b6324 100644 --- a/pkg/converters/ingress/annotations/updater.go +++ b/pkg/converters/ingress/annotations/updater.go @@ -30,27 +30,28 @@ import ( // Updater ... type Updater interface { - UpdateGlobalConfig(global *hatypes.Global, mapper *Mapper) + UpdateGlobalConfig(haproxyConfig haproxy.Config, mapper *Mapper) UpdateHostConfig(host *hatypes.Host, mapper *Mapper) UpdateBackendConfig(backend *hatypes.Backend, mapper *Mapper) } // NewUpdater ... -func NewUpdater(haproxy haproxy.Config, cache convtypes.Cache, logger types.Logger) Updater { +func NewUpdater(haproxy haproxy.Config, options *ingtypes.ConverterOptions) Updater { return &updater{ haproxy: haproxy, - cache: cache, - logger: logger, + logger: options.Logger, + cache: options.Cache, } } type updater struct { haproxy haproxy.Config - cache convtypes.Cache logger types.Logger + cache convtypes.Cache } type globalData struct { + acme *hatypes.Acme global *hatypes.Global mapper *Mapper } @@ -95,32 +96,34 @@ func (c *updater) splitCIDR(cidrlist *ConfigValue) []string { return cidrslice } -func (c *updater) UpdateGlobalConfig(global *hatypes.Global, mapper *Mapper) { - data := &globalData{ - global: global, +func (c *updater) UpdateGlobalConfig(haproxyConfig haproxy.Config, mapper *Mapper) { + d := &globalData{ + acme: haproxyConfig.Acme(), + global: haproxyConfig.Global(), mapper: mapper, } - global.AdminSocket = "/var/run/haproxy-stats.sock" - global.MaxConn = mapper.Get(ingtypes.GlobalMaxConnections).Int() - global.DrainSupport.Drain = mapper.Get(ingtypes.GlobalDrainSupport).Bool() - global.DrainSupport.Redispatch = mapper.Get(ingtypes.GlobalDrainSupportRedispatch).Bool() - global.Cookie.Key = mapper.Get(ingtypes.GlobalCookieKey).Value - global.LoadServerState = mapper.Get(ingtypes.GlobalLoadServerState).Bool() - global.SSL.ALPN = mapper.Get(ingtypes.GlobalTLSALPN).Value - global.StrictHost = mapper.Get(ingtypes.GlobalStrictHost).Bool() - global.UseHTX = mapper.Get(ingtypes.GlobalUseHTX).Bool() - c.buildGlobalBind(data) - c.buildGlobalCustomConfig(data) - c.buildGlobalDNS(data) - c.buildGlobalForwardFor(data) - c.buildGlobalHealthz(data) - c.buildGlobalHTTPStoHTTP(data) - c.buildGlobalModSecurity(data) - c.buildGlobalProc(data) - c.buildGlobalSSL(data) - c.buildGlobalStats(data) - c.buildGlobalSyslog(data) - c.buildGlobalTimeout(data) + d.global.AdminSocket = "/var/run/haproxy-stats.sock" + d.global.MaxConn = mapper.Get(ingtypes.GlobalMaxConnections).Int() + d.global.DrainSupport.Drain = mapper.Get(ingtypes.GlobalDrainSupport).Bool() + d.global.DrainSupport.Redispatch = mapper.Get(ingtypes.GlobalDrainSupportRedispatch).Bool() + d.global.Cookie.Key = mapper.Get(ingtypes.GlobalCookieKey).Value + d.global.LoadServerState = mapper.Get(ingtypes.GlobalLoadServerState).Bool() + d.global.SSL.ALPN = mapper.Get(ingtypes.GlobalTLSALPN).Value + d.global.StrictHost = mapper.Get(ingtypes.GlobalStrictHost).Bool() + d.global.UseHTX = mapper.Get(ingtypes.GlobalUseHTX).Bool() + c.buildGlobalAcme(d) + c.buildGlobalBind(d) + c.buildGlobalCustomConfig(d) + c.buildGlobalDNS(d) + c.buildGlobalForwardFor(d) + c.buildGlobalHealthz(d) + c.buildGlobalHTTPStoHTTP(d) + c.buildGlobalModSecurity(d) + c.buildGlobalProc(d) + c.buildGlobalSSL(d) + c.buildGlobalStats(d) + c.buildGlobalSyslog(d) + c.buildGlobalTimeout(d) } func (c *updater) UpdateHostConfig(host *hatypes.Host, mapper *Mapper) { @@ -133,6 +136,7 @@ func (c *updater) UpdateHostConfig(host *hatypes.Host, mapper *Mapper) { host.Alias.AliasRegex = mapper.Get(ingtypes.HostServerAliasRegex).Value host.VarNamespace = mapper.Get(ingtypes.HostVarNamespace).Bool() c.buildHostAuthTLS(data) + c.buildHostCertSigner(data) c.buildHostSSLPassthrough(data) c.buildHostTimeout(data) } diff --git a/pkg/converters/ingress/defaults.go b/pkg/converters/ingress/defaults.go index 0ca60c48e..4eaa49517 100644 --- a/pkg/converters/ingress/defaults.go +++ b/pkg/converters/ingress/defaults.go @@ -61,6 +61,7 @@ func createDefaults() map[string]string { types.BackTimeoutTunnel: "1h", types.BackWAFMode: "deny", // + types.GlobalAcmeExpiring: "30", types.GlobalBindIPAddrHealthz: "*", types.GlobalBindIPAddrHTTP: "*", types.GlobalBindIPAddrStats: "*", diff --git a/pkg/converters/ingress/ingress.go b/pkg/converters/ingress/ingress.go index 4bbed4bbd..3ac5ac584 100644 --- a/pkg/converters/ingress/ingress.go +++ b/pkg/converters/ingress/ingress.go @@ -53,7 +53,7 @@ func NewIngressConverter(options *ingtypes.ConverterOptions, haproxy haproxy.Con logger: options.Logger, cache: options.Cache, mapBuilder: annotations.NewMapBuilder(options.Logger, options.AnnotationPrefix+"/", defaultConfig), - updater: annotations.NewUpdater(haproxy, options.Cache, options.Logger), + updater: annotations.NewUpdater(haproxy, options), globalConfig: annotations.NewMapBuilder(options.Logger, "", defaultConfig).NewMapper(), hostAnnotations: map[*hatypes.Host]*annotations.Mapper{}, backendAnnotations: map[*hatypes.Backend]*annotations.Mapper{}, @@ -156,10 +156,28 @@ func (c *converter) syncIngress(ing *extensions.Ingress) { } } } + for _, tls := range ing.Spec.TLS { + // distinct prefix, read from the Annotations map + var tlsAcme bool + if c.options.AcmeTrackTLSAnn { + tlsAcmeStr, _ := ing.Annotations[ingtypes.ExtraTLSAcme] + tlsAcme, _ = strconv.ParseBool(tlsAcmeStr) + } + if !tlsAcme { + tlsAcme = strings.ToLower(annHost[ingtypes.HostCertSigner]) == "acme" + } + if tlsAcme { + if tls.SecretName != "" { + c.haproxy.Acme().AddDomains(ing.Namespace+"/"+tls.SecretName, tls.Hosts) + } else { + c.logger.Warn("skipping cert signer of ingress '%s': missing secret name", fullIngName) + } + } + } } func (c *converter) syncAnnotations() { - c.updater.UpdateGlobalConfig(c.haproxy.Global(), c.globalConfig) + c.updater.UpdateGlobalConfig(c.haproxy, c.globalConfig) for _, host := range c.haproxy.Hosts() { if ann, found := c.hostAnnotations[host]; found { c.updater.UpdateHostConfig(host, ann) diff --git a/pkg/converters/ingress/ingress_test.go b/pkg/converters/ingress/ingress_test.go index c76e5474b..d7824c677 100644 --- a/pkg/converters/ingress/ingress_test.go +++ b/pkg/converters/ingress/ingress_test.go @@ -1325,7 +1325,7 @@ func (c *testConfig) compareText(actual, expected string) { type updaterMock struct{} -func (u *updaterMock) UpdateGlobalConfig(global *hatypes.Global, config *annotations.Mapper) { +func (u *updaterMock) UpdateGlobalConfig(haproxyConfig haproxy.Config, config *annotations.Mapper) { } func (u *updaterMock) UpdateHostConfig(host *hatypes.Host, mapper *annotations.Mapper) { diff --git a/pkg/converters/ingress/types/annotations.go b/pkg/converters/ingress/types/annotations.go index ced9a254d..623c298d2 100644 --- a/pkg/converters/ingress/types/annotations.go +++ b/pkg/converters/ingress/types/annotations.go @@ -22,6 +22,7 @@ const ( HostAuthTLSErrorPage = "auth-tls-error-page" HostAuthTLSSecret = "auth-tls-secret" HostAuthTLSVerifyClient = "auth-tls-verify-client" + HostCertSigner = "cert-signer" HostServerAlias = "server-alias" HostServerAliasRegex = "server-alias-regex" HostSSLPassthrough = "ssl-passthrough" @@ -38,6 +39,7 @@ var ( HostAuthTLSErrorPage: {}, HostAuthTLSSecret: {}, HostAuthTLSVerifyClient: {}, + HostCertSigner: {}, HostServerAlias: {}, HostServerAliasRegex: {}, HostSSLPassthrough: {}, @@ -124,3 +126,8 @@ const ( BackWAFMode = "waf-mode" BackWhitelistSourceRange = "whitelist-source-range" ) + +// Extra Annotations +const ( + ExtraTLSAcme = "kubernetes.io/tls-acme" +) diff --git a/pkg/converters/ingress/types/global.go b/pkg/converters/ingress/types/global.go index 0d8a291d6..80ab91a27 100644 --- a/pkg/converters/ingress/types/global.go +++ b/pkg/converters/ingress/types/global.go @@ -18,6 +18,11 @@ package types // Global config const ( + GlobalAcmeEmails = "acme-emails" + GlobalAcmeEndpoint = "acme-endpoint" + GlobalAcmeExpiring = "acme-expiring" + GlobalAcmeShared = "acme-shared" + GlobalAcmeTermsAgreed = "acme-terms-agreed" GlobalBindFrontingProxy = "bind-fronting-proxy" GlobalBindHTTP = "bind-http" GlobalBindHTTPS = "bind-https" diff --git a/pkg/converters/ingress/types/options.go b/pkg/converters/ingress/types/options.go index 7e593835d..47bca3898 100644 --- a/pkg/converters/ingress/types/options.go +++ b/pkg/converters/ingress/types/options.go @@ -29,4 +29,5 @@ type ConverterOptions struct { DefaultBackend string DefaultSSLFile convtypes.File AnnotationPrefix string + AcmeTrackTLSAnn bool } diff --git a/pkg/haproxy/config.go b/pkg/haproxy/config.go index 27c10643d..49865a8ec 100644 --- a/pkg/haproxy/config.go +++ b/pkg/haproxy/config.go @@ -43,6 +43,7 @@ type Config interface { BuildBackendMaps() error DefaultHost() *hatypes.Host DefaultBackend() *hatypes.Backend + Acme() *hatypes.Acme Global() *hatypes.Global TCPBackends() []*hatypes.TCPBackend Hosts() []*hatypes.Host @@ -52,10 +53,13 @@ type Config interface { } type config struct { + // external state, cannot reflect in Config.Equals() + acme *hatypes.Acme + // haproxy internal state fgroup *hatypes.FrontendGroup mapsTemplate *template.Config mapsDir string - global hatypes.Global + global *hatypes.Global tcpbackends []*hatypes.TCPBackend hosts []*hatypes.Host backends []*hatypes.Backend @@ -76,6 +80,8 @@ func createConfig(options options) *config { mapsTemplate = template.CreateConfig() } return &config{ + acme: &hatypes.Acme{}, + global: &hatypes.Global{}, mapsTemplate: mapsTemplate, mapsDir: options.mapsDir, } @@ -467,8 +473,12 @@ func (c *config) DefaultBackend() *hatypes.Backend { return c.defaultBackend } +func (c *config) Acme() *hatypes.Acme { + return c.acme +} + func (c *config) Global() *hatypes.Global { - return &c.global + return c.global } func (c *config) TCPBackends() []*hatypes.TCPBackend { @@ -492,5 +502,8 @@ func (c *config) Equals(other Config) bool { if !ok { return false } - return reflect.DeepEqual(c, c2) + // (config struct): external state, cannot reflect in Config.Equals() + copy := *c2 + copy.acme = c.acme + return reflect.DeepEqual(c, ©) } diff --git a/pkg/haproxy/dynupdate.go b/pkg/haproxy/dynupdate.go index 6631409c1..06fbc35d7 100644 --- a/pkg/haproxy/dynupdate.go +++ b/pkg/haproxy/dynupdate.go @@ -83,7 +83,7 @@ func (d *dynUpdater) checkConfigPair() bool { oldConfigCopy := *oldConfig oldConfigCopy.backends = curConfig.backends oldConfigCopy.defaultBackend = curConfig.defaultBackend - if !reflect.DeepEqual(&oldConfigCopy, curConfig) { + if !oldConfigCopy.Equals(curConfig) { var diff []string if !reflect.DeepEqual(oldConfig.global, curConfig.global) { diff = append(diff, "global") diff --git a/pkg/haproxy/dynupdate_test.go b/pkg/haproxy/dynupdate_test.go index 10203c894..1978397b2 100644 --- a/pkg/haproxy/dynupdate_test.go +++ b/pkg/haproxy/dynupdate_test.go @@ -66,7 +66,7 @@ func TestDynUpdate(t *testing.T) { { oldConfig: &config{}, curConfig: &config{ - global: hatypes.Global{MaxConn: 1}, + global: &hatypes.Global{MaxConn: 1}, }, dynamic: false, logging: `INFO-V(2) diff outside backends - [global]`, diff --git a/pkg/haproxy/instance.go b/pkg/haproxy/instance.go index c542cc7fa..de0f97c72 100644 --- a/pkg/haproxy/instance.go +++ b/pkg/haproxy/instance.go @@ -19,15 +19,22 @@ package haproxy import ( "fmt" "os/exec" + "reflect" + "sort" "strings" + "github.com/jcmoraisjr/haproxy-ingress/pkg/acme" "github.com/jcmoraisjr/haproxy-ingress/pkg/haproxy/template" + hatypes "github.com/jcmoraisjr/haproxy-ingress/pkg/haproxy/types" "github.com/jcmoraisjr/haproxy-ingress/pkg/types" "github.com/jcmoraisjr/haproxy-ingress/pkg/utils" ) // InstanceOptions ... type InstanceOptions struct { + AcmeSigner acme.Signer + AcmeQueue utils.Queue + LeaderElector types.LeaderElector MaxOldConfigFiles int HAProxyCmd string HAProxyConfigFile string @@ -38,6 +45,7 @@ type InstanceOptions struct { // Instance ... type Instance interface { + AcmePeriodicCheck() ParseTemplates() error Config() Config Update(timer *utils.Timer) @@ -64,6 +72,61 @@ type instance struct { curConfig Config } +func (i *instance) AcmePeriodicCheck() { + if i.oldConfig == nil || i.options.AcmeQueue == nil { + return + } + le := i.options.LeaderElector + if !le.IsLeader() { + i.logger.Info("skipping acme periodic check, leader is %s", le.LeaderName()) + return + } + if !i.options.AcmeSigner.HasAccount() { + i.acmeEnsureConfig(i.oldConfig.Acme()) + } + i.logger.Info("starting periodic certificate check") + var count int + for storage, domains := range i.oldConfig.Acme().Certs { + i.acmeAddCert(storage, domains) + count++ + } + if count == 0 { + i.logger.Info("certificate list is empty") + } else { + i.logger.Info("finish adding %d certificate(s) to the work queue", count) + } +} + +func (i *instance) acmeEnsureConfig(acmeConfig *hatypes.Acme) { + i.options.AcmeSigner.AcmeConfig(acmeConfig.Expiring) + i.options.AcmeSigner.AcmeAccount(acmeConfig.Endpoint, acmeConfig.Emails, acmeConfig.TermsAgreed) +} + +func (i *instance) acmeBuildCert(storage string, domains map[string]struct{}) string { + cert := make([]string, len(domains)) + n := 0 + for dom := range domains { + cert[n] = dom + n++ + } + sort.Slice(cert, func(i, j int) bool { + return cert[i] < cert[j] + }) + return strings.Join(cert, ",") +} + +func (i *instance) acmeAddCert(storage string, domains map[string]struct{}) { + strcert := i.acmeBuildCert(storage, domains) + i.logger.InfoV(2, "enqueue certificate for processing: storage=%s domain(s)=%s", + storage, strcert) + i.options.AcmeQueue.Add(storage + "," + strcert) +} + +func (i *instance) acmeRemoveCert(storage string, domains map[string]struct{}) { + strcert := i.acmeBuildCert(storage, domains) + i.options.AcmeQueue.Remove(storage + "," + strcert) +} + func (i *instance) ParseTemplates() error { i.templates.ClearTemplates() i.mapsTemplate.ClearTemplates() @@ -107,6 +170,47 @@ func (i *instance) Config() Config { } func (i *instance) Update(timer *utils.Timer) { + i.acmeUpdate() + i.haproxyUpdate(timer) +} + +func (i *instance) acmeUpdate() { + if i.oldConfig == nil || i.curConfig == nil || i.options.AcmeQueue == nil { + return + } + le := i.options.LeaderElector + if le.IsLeader() { + i.acmeEnsureConfig(i.curConfig.Acme()) + } + var updated bool + oldCerts := i.oldConfig.Acme().Certs + curCerts := i.curConfig.Acme().Certs + // Remove from the retry queue certs that was removed from the config + for storage, domains := range oldCerts { + curdomains, found := curCerts[storage] + if !found || !reflect.DeepEqual(domains, curdomains) { + if le.IsLeader() { + i.acmeRemoveCert(storage, domains) + } + updated = true + } + } + // Add new certs to the work queue + for storage, domains := range curCerts { + olddomains, found := oldCerts[storage] + if !found || !reflect.DeepEqual(domains, olddomains) { + if le.IsLeader() { + i.acmeAddCert(storage, domains) + } + updated = true + } + } + if updated && !le.IsLeader() { + i.logger.InfoV(2, "skipping acme update check, leader is %s", le.LeaderName()) + } +} + +func (i *instance) haproxyUpdate(timer *utils.Timer) { // nil config, just ignore if i.curConfig == nil { i.logger.Info("new configuration is empty") diff --git a/pkg/haproxy/instance_test.go b/pkg/haproxy/instance_test.go index 6b939b0d4..7cedf86ce 100644 --- a/pkg/haproxy/instance_test.go +++ b/pkg/haproxy/instance_test.go @@ -2323,6 +2323,79 @@ backend d1_app_8080 } } +func TestAcme(t *testing.T) { + testCases := []struct { + shared bool + expected string + }{ + { + shared: false, + expected: ` +frontend _front_http + mode http + bind :80 + acl acme-challenge path_beg /.acme + http-request set-var(req.base) base,lower,regsub(:[0-9]+/,/) + http-request redirect scheme https if !acme-challenge { var(req.base),map_beg(/etc/haproxy/maps/_global_https_redir.map) yes } + <> + http-request set-var(req.backend) var(req.base),map_beg(/etc/haproxy/maps/_global_http_front.map) + use_backend _acme_challenge if acme-challenge + use_backend %[var(req.backend)] if { var(req.backend) -m found } + default_backend _error404`, + }, + { + shared: true, + expected: ` +frontend _front_http + mode http + bind :80 + acl acme-challenge path_beg /.acme + http-request set-var(req.base) base,lower,regsub(:[0-9]+/,/) + http-request redirect scheme https if { var(req.base),map_beg(/etc/haproxy/maps/_global_https_redir.map) yes } + <> + http-request set-var(req.backend) var(req.base),map_beg(/etc/haproxy/maps/_global_http_front.map) + use_backend %[var(req.backend)] if { var(req.backend) -m found } + use_backend _acme_challenge if acme-challenge + default_backend _error404`, + }, + } + for _, test := range testCases { + c := setup(t) + + var h *hatypes.Host + var b *hatypes.Backend + + b = c.config.AcquireBackend("d1", "app", "8080") + b.Endpoints = []*hatypes.Endpoint{endpointS1} + h = c.config.AcquireHost("d1.local") + h.AddPath(b, "/") + + acme := c.config.Acme() + acme.Enabled = true + acme.Prefix = "/.acme" + acme.Socket = "/run/acme.sock" + acme.Shared = test.shared + + c.Update() + c.checkConfig(` +<> +<> +backend d1_app_8080 + mode http + server s1 172.17.0.11:8080 weight 100 +backend _acme_challenge + mode http + server _acme_server unix@/run/acme.sock +<>` + test.expected + ` +<> + default_backend _error404 +<> +`) + c.logger.CompareLogging(defaultLogging) + c.teardown() + } +} + func TestStatsHealthz(t *testing.T) { testCases := []struct { stats hatypes.StatsConfig diff --git a/pkg/haproxy/types/global.go b/pkg/haproxy/types/global.go index 8e4b7324a..57a76a2c3 100644 --- a/pkg/haproxy/types/global.go +++ b/pkg/haproxy/types/global.go @@ -20,6 +20,21 @@ import ( "fmt" ) +// AddDomains ... +func (acme *Acme) AddDomains(storage string, domains []string) { + if acme.Certs == nil { + acme.Certs = map[string]map[string]struct{}{} + } + certs, found := acme.Certs[storage] + if !found { + certs = map[string]struct{}{} + acme.Certs[storage] = certs + } + for _, domain := range domains { + certs[domain] = struct{}{} + } +} + func (dns *DNSConfig) String() string { return fmt.Sprintf("%+v", *dns) } diff --git a/pkg/haproxy/types/global_test.go b/pkg/haproxy/types/global_test.go new file mode 100644 index 000000000..dad976948 --- /dev/null +++ b/pkg/haproxy/types/global_test.go @@ -0,0 +1,69 @@ +/* +Copyright 2019 The HAProxy Ingress Controller Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package types + +import ( + "reflect" + "testing" +) + +func TestAcmeAddDomain(t *testing.T) { + testCases := []struct { + certs [][]string + expected map[string]map[string]struct{} + }{ + // 0 + { + certs: [][]string{ + {"cert1", "d1.local"}, + }, + expected: map[string]map[string]struct{}{ + "cert1": {"d1.local": {}}, + }, + }, + // 1 + { + certs: [][]string{ + {"cert1", "d1.local", "d2.local"}, + {"cert1", "d2.local", "d3.local"}, + }, + expected: map[string]map[string]struct{}{ + "cert1": {"d1.local": {}, "d2.local": {}, "d3.local": {}}, + }, + }, + // 2 + { + certs: [][]string{ + {"cert1", "d1.local", "d2.local"}, + {"cert2", "d2.local", "d3.local"}, + }, + expected: map[string]map[string]struct{}{ + "cert1": {"d1.local": {}, "d2.local": {}}, + "cert2": {"d2.local": {}, "d3.local": {}}, + }, + }, + } + for i, test := range testCases { + acme := Acme{} + for _, cert := range test.certs { + acme.AddDomains(cert[0], cert[1:]) + } + if !reflect.DeepEqual(acme.Certs, test.expected) { + t.Errorf("acme certs differs on %d - expected: %+v, actual: %+v", i, test.expected, acme.Certs) + } + } +} diff --git a/pkg/haproxy/types/types.go b/pkg/haproxy/types/types.go index 4c8edc7d1..cf6e27675 100644 --- a/pkg/haproxy/types/types.go +++ b/pkg/haproxy/types/types.go @@ -16,6 +16,23 @@ limitations under the License. package types +import ( + "time" +) + +// Acme ... +type Acme struct { + Certs map[string]map[string]struct{} + Emails string + Enabled bool + Endpoint string + Expiring time.Duration + Prefix string + Shared bool + Socket string + TermsAgreed bool +} + // Global ... type Global struct { Bind GlobalBindConfig diff --git a/pkg/types/leaderelector.go b/pkg/types/leaderelector.go new file mode 100644 index 000000000..96188b0d2 --- /dev/null +++ b/pkg/types/leaderelector.go @@ -0,0 +1,24 @@ +/* +Copyright 2019 The HAProxy Ingress Controller Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package types + +// LeaderElector ... +type LeaderElector interface { + IsLeader() bool + LeaderName() string + Run() +} diff --git a/pkg/utils/queue.go b/pkg/utils/queue.go index 48c9310af..c29e01a68 100644 --- a/pkg/utils/queue.go +++ b/pkg/utils/queue.go @@ -27,6 +27,7 @@ import ( type Queue interface { Add(item interface{}) Notify() + Remove(item interface{}) Run() ShuttingDown() bool ShutDown() @@ -36,10 +37,15 @@ type queue struct { workqueue workqueue.RateLimitingInterface rateLimiter flowcontrol.RateLimiter running chan struct{} + forget set sync func(item interface{}) syncFailure func(item interface{}) error } +type set map[iface]empty +type iface interface{} +type empty struct{} + // NewQueue ... func NewQueue(sync func(item interface{})) Queue { return NewRateLimitingQueue(0, sync) @@ -71,15 +77,24 @@ func NewFailureRateLimitingQueue(failInitialWait, failMaxWait time.Duration, syn } func (q *queue) Add(item interface{}) { + delete(q.forget, item) q.workqueue.Add(item) } func (q *queue) Notify() { // When using with rateLimiter, `nil` will be deduplicated // and `queue.Get()` will release call to `sync()` just once + delete(q.forget, nil) q.workqueue.Add(nil) } +func (q *queue) Remove(item interface{}) { + if q.forget == nil { + q.forget = set{} + } + q.forget[item] = empty{} +} + func (q *queue) Run() { if q.running != nil { // queue already running @@ -98,7 +113,10 @@ func (q *queue) Run() { if q.sync != nil { q.sync(item) } else if q.syncFailure != nil { - if err := q.syncFailure(item); err != nil { + if _, forget := q.forget[item]; forget { + q.workqueue.Forget(item) + delete(q.forget, item) + } else if err := q.syncFailure(item); err != nil { q.workqueue.AddRateLimited(item) } else { q.workqueue.Forget(item) diff --git a/pkg/utils/queue_test.go b/pkg/utils/queue_test.go index 64d1f2e3c..9d8dfc047 100644 --- a/pkg/utils/queue_test.go +++ b/pkg/utils/queue_test.go @@ -154,6 +154,51 @@ func TestNotify(t *testing.T) { } } +func TestRemove(t *testing.T) { + var count int + // retries on 20ms, +40ms(60ms), +80ms(140ms), +160ms(300ms) ... up to 1s + q := NewFailureRateLimitingQueue(20*time.Millisecond, 1*time.Second, func(item interface{}) error { + count++ + return fmt.Errorf("oops") + }) + go q.Run() + checkCount := func(c int) { + if count != c { + t.Errorf("expected count=%d but was %d", c, count) + } + } + q.Add(1) + // 100ms + time.Sleep(100 * time.Millisecond) + checkCount(3) + q.Remove(1) + // 320ms + time.Sleep(220 * time.Millisecond) + checkCount(3) + q.ShutDown() +} + +func TestAddRemoved(t *testing.T) { + var count int + // retries on 20ms, +40ms(60ms), +80ms(140ms), +160ms(300ms) ... up to 1s + q := NewFailureRateLimitingQueue(20*time.Millisecond, 1*time.Second, func(item interface{}) error { + count++ + return fmt.Errorf("oops") + }) + go q.Run() + checkCount := func(c int) { + if count != c { + t.Errorf("expected count=%d but was %d", c, count) + } + } + q.Remove(1) + q.Add(1) + // 100ms + time.Sleep(100 * time.Millisecond) + checkCount(3) + q.ShutDown() +} + func TestBackoffQueue(t *testing.T) { var count int // retries on 30ms, +60ms(90ms), +120ms(210ms), +240ms(450ms) ... up to 2s diff --git a/rootfs/etc/haproxy/template/haproxy.tmpl b/rootfs/etc/haproxy/template/haproxy.tmpl index 885f56afd..825a54923 100644 --- a/rootfs/etc/haproxy/template/haproxy.tmpl +++ b/rootfs/etc/haproxy/template/haproxy.tmpl @@ -563,6 +563,17 @@ backend {{ $backend.ID }} {{- end }} {{- end }} +{{- if $cfg.Acme.Enabled }} + + # # # # # # # # # # # # # # # # # # # +# # +# acme challenge server +# +backend _acme_challenge + mode http + server _acme_server unix@{{ $cfg.Acme.Socket }} +{{- end }} + {{- if $cfg.Backends }} # # # # # # # # # # # # # # # # # # # @@ -723,23 +734,32 @@ frontend _front_http {{- "" }} fronting-proxy !{ hdr(X-Forwarded-Proto) https } {{- end }} +{{- /*------------------------------------*/}} +{{- if $cfg.Acme.Enabled }} + acl acme-challenge path_beg {{ $cfg.Acme.Prefix }} +{{- end }} + {{- /*------------------------------------*/}} http-request set-var(req.base) base,lower,regsub(:[0-9]+/,/) {{- /*------------------------------------*/}} +{{- $acmeexclusive := and $cfg.Acme.Enabled (not $cfg.Acme.Shared) }} {{- if $fgroup.HTTPSRedirMap.HasRegex }} http-request set-var(req.redir) {{- "" }} var(req.base),map_beg({{ $fgroup.HTTPSRedirMap.MatchFile }}) {{- if $hasFrontingProxy }} if !fronting-proxy{{ end }} - http-request redirect scheme https if + http-request redirect scheme https + {{- "" }} if{{ if $acmeexclusive }} !acme-challenge{{ end }} {{- if $hasFrontingProxy }} !fronting-proxy{{ end }} {{- "" }} { var(req.redir) yes } - http-request redirect scheme https if + http-request redirect scheme https + {{- "" }} if{{ if $acmeexclusive }} !acme-challenge{{ end }} {{- if $hasFrontingProxy }} !fronting-proxy{{ end }} {{- "" }} !{ var(req.redir) -m found } {{- "" }} { var(req.base),map_reg({{ $fgroup.HTTPSRedirMap.RegexFile }}) yes } {{- else }} - http-request redirect scheme https if + http-request redirect scheme https + {{- "" }} if{{ if $acmeexclusive }} !acme-challenge{{ end }} {{- if $hasFrontingProxy }} !fronting-proxy{{ end }} {{- "" }} { var(req.base),map_beg({{ $fgroup.HTTPSRedirMap.MatchFile }}) yes } {{- end }} @@ -753,7 +773,9 @@ frontend _front_http http-request set-var(req.rootredir) {{- "" }} var(req.host),map_reg({{ $fgroup.HTTPRootRedirMap.RegexFile }}) if !{ var(req.rootredir) -m found } {{- end }} - http-request redirect location %[var(req.rootredir)] if { path / } { var(req.rootredir) -m found } + http-request redirect location %[var(req.rootredir)] + {{- "" }} if{{ if $acmeexclusive }} !acme-challenge{{ end }} + {{- "" }} { path / } { var(req.rootredir) -m found } {{- end }} {{- /*------------------------------------*/}} @@ -793,7 +815,13 @@ frontend _front_http {{- end }} {{- /*------------------------------------*/}} +{{- if $acmeexclusive }} + use_backend _acme_challenge if acme-challenge +{{- end }} use_backend %[var(req.backend)] if { var(req.backend) -m found } +{{- if and $cfg.Acme.Enabled $cfg.Acme.Shared }} + use_backend _acme_challenge if acme-challenge +{{- end }} {{- template "defaultbackend" map $cfg }}