From 09095b92c5df354a935fe4b1ee7441bcd623b6ef Mon Sep 17 00:00:00 2001 From: Joao Morais Date: Mon, 2 Dec 2019 15:17:35 -0300 Subject: [PATCH 01/14] golang support for acme-v2 / RFC 8555 From https://golang.org/cl/86635 --- pkg/acme/x/acme/acme.go | 965 ++++++++++++++++++++++ pkg/acme/x/acme/acme_test.go | 1148 +++++++++++++++++++++++++++ pkg/acme/x/acme/integration_test.go | 146 ++++ pkg/acme/x/acme/jws.go | 160 ++++ pkg/acme/x/acme/jws_test.go | 320 ++++++++ pkg/acme/x/acme/types.go | 411 ++++++++++ pkg/acme/x/acme/types_test.go | 63 ++ 7 files changed, 3213 insertions(+) create mode 100644 pkg/acme/x/acme/acme.go create mode 100644 pkg/acme/x/acme/acme_test.go create mode 100644 pkg/acme/x/acme/integration_test.go create mode 100644 pkg/acme/x/acme/jws.go create mode 100644 pkg/acme/x/acme/jws_test.go create mode 100644 pkg/acme/x/acme/types.go create mode 100644 pkg/acme/x/acme/types_test.go diff --git a/pkg/acme/x/acme/acme.go b/pkg/acme/x/acme/acme.go new file mode 100644 index 000000000..b6c1fee83 --- /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.get(ctx, url) + 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.get(ctx, url) + 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.get(ctx, url) + 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.get(ctx, url) + 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.get(ctx, url) + 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..59f369cf2 --- /dev/null +++ b/pkg/acme/x/acme/acme_test.go @@ -0,0 +1,1148 @@ +// 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/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 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 != "GET" { + t.Errorf("r.Method = %q; want GET", 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 := Client{Key: testKeyEC, dir: &Directory{NewNonceURL: ts.URL}} + 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) { + 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() { + var client Client + 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) { + fmt.Fprintf(w, `{"status":"invalid"}`) + })) + defer ts.Close() + + res := make(chan error) + defer close(res) + go func() { + var client Client + _, 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() { + var client Client + _, 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() { + var client Client + 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 != "GET" { + t.Errorf("r.Method = %q; want GET", 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 := Client{Key: testKeyEC} + 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 == "GET" { + 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() + + var client Client + _, 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) { + 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() + + var client Client + 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..c84f4cb98 --- /dev/null +++ b/pkg/acme/x/acme/jws.go @@ -0,0 +1,160 @@ +// 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)) + 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) + } + } +} From f7a51a7060669f80b38104c52a0d17e5a6bfe7cf Mon Sep 17 00:00:00 2001 From: Joao Morais Date: Mon, 2 Dec 2019 15:41:01 -0300 Subject: [PATCH 02/14] initial implementation of acme-v2 protocol This initial version has the following features: - a client that authorises new certificate sign - a server that answers challenges to the requested domains - a work queue in order to serialize requests, avoiding flood the acme-v2 server - a leader election controls which haproxy-ingress instance is the leader and should sign certificates - a rate limiting queue which exponentially increase the time between requests of failed domains Now there are two event loops in place: one which parses ingress resources, build haproxy config and, dynamic update or reload the instance. Now another event loop is also running to process acme challenges and certificate generation/storage - if acme is enabled. There is also a goroutine which does a full check looking for invalid or expiring certificates - by default every 24h. There are some pieces of the code that need to be configured before starting the ingress event loop, because of that some configurations need to be done via command-line config, which means read only. Most of them are related with event loop control. The haproxy config "incorrectly" stores the acme data - now the config struct has a new session "external state", which means configurations that shouldn't trigger a haproxy reload. The leader elector defines which haproxy-ingress instance should start the acme-v2 challenge and sign new certificates. Every time the leader changes, the new leader does a full check in order to see if any certificate generation is still pending - this can happens on restarts or when the actual leader dies/is stopped. --- pkg/acme/client.go | 206 ++++++++++++++++++ pkg/acme/server.go | 86 ++++++++ pkg/acme/signer.go | 170 +++++++++++++++ pkg/acme/signer_test.go | 140 ++++++++++++ pkg/common/ingress/controller/controller.go | 12 + pkg/common/ingress/controller/launch.go | 30 +++ pkg/common/ingress/controller/listers.go | 2 + pkg/common/ingress/store/main.go | 25 +++ pkg/controller/cache.go | 158 +++++++++++++- pkg/controller/controller.go | 39 +++- pkg/controller/leaderelection.go | 18 +- pkg/converters/ingress/annotations/global.go | 26 +++ pkg/converters/ingress/annotations/host.go | 17 ++ pkg/converters/ingress/annotations/updater.go | 62 +++--- pkg/converters/ingress/defaults.go | 1 + pkg/converters/ingress/ingress.go | 13 +- pkg/converters/ingress/ingress_test.go | 2 +- pkg/converters/ingress/types/annotations.go | 2 + pkg/converters/ingress/types/global.go | 5 + pkg/haproxy/config.go | 19 +- pkg/haproxy/dynupdate_test.go | 2 +- pkg/haproxy/instance.go | 73 +++++++ pkg/haproxy/instance_test.go | 73 +++++++ pkg/haproxy/types/global.go | 15 ++ pkg/haproxy/types/global_test.go | 69 ++++++ pkg/haproxy/types/types.go | 17 ++ pkg/types/leaderelector.go | 24 ++ rootfs/etc/haproxy/template/haproxy.tmpl | 36 ++- 28 files changed, 1283 insertions(+), 59 deletions(-) create mode 100644 pkg/acme/client.go create mode 100644 pkg/acme/server.go create mode 100644 pkg/acme/signer.go create mode 100644 pkg/acme/signer_test.go create mode 100644 pkg/haproxy/types/global_test.go create mode 100644 pkg/types/leaderelector.go diff --git a/pkg/acme/client.go b/pkg/acme/client.go new file mode 100644 index 000000000..83bcb7da9 --- /dev/null +++ b/pkg/acme/client.go @@ -0,0 +1,206 @@ +/* +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) + 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/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..679e04008 --- /dev/null +++ b/pkg/acme/signer.go @@ -0,0 +1,170 @@ +/* +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 + 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 why=\"%s\"", + s.verifyCount, secretName, strdomains, 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..3efc255a5 --- /dev/null +++ b/pkg/acme/signer_test.go @@ -0,0 +1,140 @@ +/* +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 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 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 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.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/common/ingress/controller/controller.go b/pkg/common/ingress/controller/controller.go index 393e2e579..0a44825d8 100644 --- a/pkg/common/ingress/controller/controller.go +++ b/pkg/common/ingress/controller/controller.go @@ -135,6 +135,13 @@ type Configuration struct { DisableNodeList bool AnnPrefix string + AcmeServer bool + AcmeCheckPeriod time.Duration + AcmeFailInitialDuration time.Duration + AcmeFailMaxDuration time.Duration + AcmeSecretKeyName string + AcmeTokenConfigmapName string + // optional TCPConfigMapName string // optional @@ -212,6 +219,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..9dbbfc8bb 100644 --- a/pkg/common/ingress/controller/launch.go +++ b/pkg/common/ingress/controller/launch.go @@ -49,6 +49,30 @@ 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`) + + acmeFailInitialDuration = flags.Duration("acme-fail-initial-duration", 10*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", 24*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`) + 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 +276,12 @@ func NewIngressController(backend ingress.Controller) *GenericController { UpdateStatus: *updateStatus, ElectionID: *electionID, Client: kubeClient, + AcmeServer: *acmeServer, + AcmeCheckPeriod: *acmeCheckPeriod, + AcmeFailInitialDuration: *acmeFailInitialDuration, + AcmeFailMaxDuration: *acmeFailMaxDuration, + AcmeSecretKeyName: *acmeSecretKeyName, + AcmeTokenConfigmapName: *acmeTokenConfigmapName, 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..cd6e31372 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,116 @@ 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) + } + config.Data[domain] = uri + "=" + token + return c.listers.ConfigMap.CreateOrUpdate(config) +} diff --git a/pkg/controller/controller.go b/pkg/controller/controller.go index 65b110ea5..835fce4cd 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,30 @@ 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) + if hc.cfg.AcmeServer || false /* waiting status sync, which also uses leader election */ { + // TODO move status sync + fix electorID + electorID := fmt.Sprintf("tmp-%s-acme-elector", hc.cfg.IngressClass) hc.leaderelector = NewLeaderElector(electorID, hc.logger, hc.cache, hc) } + var acmeSigner acme.Signer + if hc.cfg.AcmeServer { + 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, @@ -135,9 +152,22 @@ func (hc *HAProxyController) configController() { } 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 +191,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..4f3b43499 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,19 @@ func (c *converter) syncIngress(ing *extensions.Ingress) { } } } + for _, tls := range ing.Spec.TLS { + if annHost[ingtypes.HostCertSigner] == "acme" { + 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..eb251abd9 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: {}, 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/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_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..e8351c882 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,46 @@ 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") + for storage, domains := range i.oldConfig.Acme().Certs { + i.acmeAddCert(storage, domains) + } + i.logger.Info("finish adding certificates to the work queue") +} + +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) acmeAddCert(storage string, domains map[string]struct{}) { + 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] + }) + strcert := strings.Join(cert, ",") + i.logger.Info("enqueue certificate for processing: storage=%s domain(s)=%s", + storage, strcert) + i.options.AcmeQueue.Add(storage + "," + strcert) +} + func (i *instance) ParseTemplates() error { i.templates.ClearTemplates() i.mapsTemplate.ClearTemplates() @@ -107,6 +155,31 @@ 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.logger.Info("skipping acme update, leader is %s", le.LeaderName()) + return + } + i.acmeEnsureConfig(i.curConfig.Acme()) + oldCerts := i.oldConfig.Acme().Certs + curCerts := i.curConfig.Acme().Certs + for storage, domains := range curCerts { + olddomains, found := oldCerts[storage] + if !found || !reflect.DeepEqual(domains, olddomains) { + i.acmeAddCert(storage, domains) + } + } +} + +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/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 }} From 1c54e09e133a7225b200186d867a0e4bd0cf6017 Mon Sep 17 00:00:00 2001 From: Joao Morais Date: Thu, 5 Dec 2019 16:30:27 -0300 Subject: [PATCH 03/14] add remove func to the work queue An item will continue in the work queue until its processing returns nil as the error object. Remove() is a way to stop waiting it to successfully execute, removing it from the queue. This is used on invalid domains on the acme queue - if the dev remove the domain from the ingress, the old request will be removed from the queue. A new request would be added if the domain is just changed. --- pkg/utils/queue.go | 20 +++++++++++++++++- pkg/utils/queue_test.go | 45 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 64 insertions(+), 1 deletion(-) 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 From cceb55da1e782341d00ca622bb609edde86afac6 Mon Sep 17 00:00:00 2001 From: Joao Morais Date: Thu, 5 Dec 2019 16:40:01 -0300 Subject: [PATCH 04/14] remove certs removed from the config An invalid cert request will continue in the error queue until it is successfully processed, which means forever for an invalid request. This change removes a request in the error queue whenever the request is removed from the configuration. A changed request will be removed (old version) and added again (new version). --- pkg/haproxy/instance.go | 21 +++++++++++++++++++-- 1 file changed, 19 insertions(+), 2 deletions(-) diff --git a/pkg/haproxy/instance.go b/pkg/haproxy/instance.go index e8351c882..2102a3456 100644 --- a/pkg/haproxy/instance.go +++ b/pkg/haproxy/instance.go @@ -96,7 +96,7 @@ func (i *instance) acmeEnsureConfig(acmeConfig *hatypes.Acme) { i.options.AcmeSigner.AcmeAccount(acmeConfig.Endpoint, acmeConfig.Emails, acmeConfig.TermsAgreed) } -func (i *instance) acmeAddCert(storage string, domains map[string]struct{}) { +func (i *instance) acmeBuildCert(storage string, domains map[string]struct{}) string { cert := make([]string, len(domains)) n := 0 for dom := range domains { @@ -106,12 +106,21 @@ func (i *instance) acmeAddCert(storage string, domains map[string]struct{}) { sort.Slice(cert, func(i, j int) bool { return cert[i] < cert[j] }) - strcert := strings.Join(cert, ",") + return strings.Join(cert, ",") +} + +func (i *instance) acmeAddCert(storage string, domains map[string]struct{}) { + strcert := i.acmeBuildCert(storage, domains) i.logger.Info("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() @@ -171,6 +180,14 @@ func (i *instance) acmeUpdate() { i.acmeEnsureConfig(i.curConfig.Acme()) 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) { + i.acmeRemoveCert(storage, domains) + } + } + // Add new certs to the work queue for storage, domains := range curCerts { olddomains, found := oldCerts[storage] if !found || !reflect.DeepEqual(domains, olddomains) { From d7055bd3f460befe2b778ae673589a379f52316b Mon Sep 17 00:00:00 2001 From: Joao Morais Date: Thu, 5 Dec 2019 16:45:31 -0300 Subject: [PATCH 05/14] add acme-v2 server endpoint in the authorization logs --- pkg/acme/signer.go | 4 ++-- pkg/acme/signer_test.go | 7 ++++--- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/pkg/acme/signer.go b/pkg/acme/signer.go index 679e04008..b381aca80 100644 --- a/pkg/acme/signer.go +++ b/pkg/acme/signer.go @@ -129,8 +129,8 @@ func (s *signer) verify(secretName string, domains []string) error { 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 why=\"%s\"", - s.verifyCount, secretName, strdomains, why) + 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 { diff --git a/pkg/acme/signer_test.go b/pkg/acme/signer_test.go index 3efc255a5..ed771904f 100644 --- a/pkg/acme/signer_test.go +++ b/pkg/acme/signer_test.go @@ -46,7 +46,7 @@ INFO-V(2) acme: skipping sign, certificate is updated: secret=s1 domain(s)=d1.lo input: "s1,d2.local", expiresIn: -10 * 24 * time.Hour, logging: ` -INFO-V(2) acme: authorizing: id=1 secret=s1 domain(s)=d2.local why="certificate expires in 2020-12-01 16:33:14 +0000 UTC" +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 @@ -54,7 +54,7 @@ INFO acme: new certificate issued: id=1 secret=s1 domain(s)=d2.local`, input: "s1,d3.local", expiresIn: 10 * 24 * time.Hour, logging: ` -INFO-V(2) acme: authorizing: id=1 secret=s1 domain(s)=d3.local why="added one or more domains to an existing certificate" +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 @@ -62,7 +62,7 @@ INFO acme: new certificate issued: id=1 secret=s1 domain(s)=d3.local`, input: "s2,d1.local", expiresIn: 10 * 24 * time.Hour, logging: ` -INFO-V(2) acme: authorizing: id=1 secret=s2 domain(s)=d1.local why="certificate does not exist" +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`, }, } @@ -73,6 +73,7 @@ INFO acme: new certificate issued: id=1 secret=s2 domain(s)=d1.local`, 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) From 6cef4675e25fd13c8aad630be2a1d122018dd34f Mon Sep 17 00:00:00 2001 From: Joao Morais Date: Mon, 9 Dec 2019 11:20:06 -0300 Subject: [PATCH 06/14] change acme client GET to POST-as-GET The final version of RFC 8555 dropped unauthenticated GET method support. This change uses POST-as-GET instead, which fixes certificate generation with Lets Encrypt staging. See also https://community.letsencrypt.org/t/74380 --- pkg/acme/x/acme/acme.go | 10 +++---- pkg/acme/x/acme/acme_test.go | 58 ++++++++++++++++++++++++++++-------- pkg/acme/x/acme/jws.go | 11 ++++--- 3 files changed, 57 insertions(+), 22 deletions(-) diff --git a/pkg/acme/x/acme/acme.go b/pkg/acme/x/acme/acme.go index b6c1fee83..d3a4e4af8 100644 --- a/pkg/acme/x/acme/acme.go +++ b/pkg/acme/x/acme/acme.go @@ -258,7 +258,7 @@ func (c *Client) FinalizeOrder(ctx context.Context, finalizeURL string, csr []by // 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.get(ctx, url) + res, err := c.postWithJWSAccount(ctx, url, nil) if err != nil { return nil, err } @@ -376,7 +376,7 @@ func (c *Client) UpdateAccount(ctx context.Context, a *Account) (*Account, error // 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.get(ctx, url) + res, err := c.postWithJWSAccount(ctx, url, nil) if err != nil { return nil, err } @@ -422,7 +422,7 @@ func (c *Client) DeactivateAuthorization(ctx context.Context, url string) error func (c *Client) WaitAuthorization(ctx context.Context, url string) (*Authorization, error) { sleep := sleeper(ctx) for { - res, err := c.get(ctx, url) + res, err := c.postWithJWSAccount(ctx, url, nil) if err != nil { return nil, err } @@ -466,7 +466,7 @@ func (c *Client) WaitAuthorization(ctx context.Context, url string) (*Authorizat // // A client typically polls a challenge status using this method. func (c *Client) GetChallenge(ctx context.Context, url string) (*Challenge, error) { - res, err := c.get(ctx, url) + res, err := c.postWithJWSAccount(ctx, url, nil) if err != nil { return nil, err } @@ -822,7 +822,7 @@ func nonceFromHeader(h http.Header) string { } func (c *Client) getCert(ctx context.Context, url string) ([][]byte, error) { - res, err := c.get(ctx, url) + res, err := c.postWithJWSAccount(ctx, url, nil) if err != nil { return nil, err } diff --git a/pkg/acme/x/acme/acme_test.go b/pkg/acme/x/acme/acme_test.go index 59f369cf2..e0ad09784 100644 --- a/pkg/acme/x/acme/acme_test.go +++ b/pkg/acme/x/acme/acme_test.go @@ -6,6 +6,7 @@ package acme import ( "context" + "crypto" "crypto/rand" "crypto/x509" "crypto/x509/pkix" @@ -63,6 +64,17 @@ func decodeJWSHead(r *http.Request) (*jwsHead, error) { 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" @@ -327,8 +339,12 @@ func TestCreateOrder(t *testing.T) { func TestGetAuthorization(t *testing.T) { ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.Method != "GET" { - t.Errorf("r.Method = %q; want GET", r.Method) + 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) @@ -353,7 +369,7 @@ func TestGetAuthorization(t *testing.T) { })) defer ts.Close() - cl := Client{Key: testKeyEC, dir: &Directory{NewNonceURL: ts.URL}} + cl := newTestClient(testKeyEC, ts) auth, err := cl.GetAuthorization(context.Background(), ts.URL) if err != nil { t.Fatal(err) @@ -399,6 +415,10 @@ func TestGetAuthorization(t *testing.T) { 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 { @@ -416,7 +436,7 @@ func TestWaitAuthorization(t *testing.T) { done := make(chan res) defer close(done) go func() { - var client Client + client := newTestClient(testKey, ts) a, err := client.WaitAuthorization(context.Background(), ts.URL) done <- res{a, err} }() @@ -436,6 +456,10 @@ func TestWaitAuthorization(t *testing.T) { 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() @@ -443,7 +467,7 @@ func TestWaitAuthorizationInvalid(t *testing.T) { res := make(chan error) defer close(res) go func() { - var client Client + client := newTestClient(testKey, ts) _, err := client.WaitAuthorization(context.Background(), ts.URL) res <- err }() @@ -470,7 +494,7 @@ func TestWaitAuthorizationClientError(t *testing.T) { ch := make(chan error, 1) go func() { - var client Client + client := newTestClient(testKey, ts) _, err := client.WaitAuthorization(context.Background(), ts.URL) ch <- err }() @@ -499,7 +523,7 @@ func TestWaitAuthorizationCancel(t *testing.T) { res := make(chan error) defer close(res) go func() { - var client Client + client := newTestClient(testKey, ts) ctx, cancel := context.WithTimeout(context.Background(), 200*time.Millisecond) defer cancel() _, err := client.WaitAuthorization(ctx, ts.URL) @@ -551,8 +575,12 @@ func TestDeactivateAuthorization(t *testing.T) { func TestGetChallenge(t *testing.T) { ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.Method != "GET" { - t.Errorf("r.Method = %q; want GET", r.Method) + 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) @@ -579,7 +607,7 @@ func TestGetChallenge(t *testing.T) { })) defer ts.Close() - cl := Client{Key: testKeyEC} + cl := newTestClient(testKeyEC, ts) chall, err := cl.GetChallenge(context.Background(), ts.URL) if err != nil { t.Fatal(err) @@ -691,7 +719,7 @@ func TestFinalizeOrder(t *testing.T) { w.Header().Set("Replay-Nonce", "test-nonce") return } - if r.URL.Path == "/cert" && r.Method == "GET" { + if r.URL.Path == "/cert" && r.Method == "POST" { pem.Encode(w, &pem.Block{Type: "CERTIFICATE", Bytes: sampleCert}) return } @@ -786,7 +814,7 @@ func TestWaitOrderInvalid(t *testing.T) { })) defer ts.Close() - var client Client + client := newTestClient(testKey, ts) _, err := client.WaitOrder(context.Background(), ts.URL+"/pending") if e, ok := err.(OrderPendingError); ok { if e.Order == nil { @@ -814,6 +842,10 @@ func TestWaitOrderInvalid(t *testing.T) { 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", @@ -824,7 +856,7 @@ func TestGetOrder(t *testing.T) { })) defer ts.Close() - var client Client + client := newTestClient(testKey, ts) o, err := client.GetOrder(context.Background(), ts.URL) if err != nil { t.Fatal(err) diff --git a/pkg/acme/x/acme/jws.go b/pkg/acme/x/acme/jws.go index c84f4cb98..c00f37eaf 100644 --- a/pkg/acme/x/acme/jws.go +++ b/pkg/acme/x/acme/jws.go @@ -38,11 +38,14 @@ func jwsEncodeJSON(claimset interface{}, key crypto.Signer, accountURL, url, non phead = fmt.Sprintf(`{"alg":%q,"kid":%q,"nonce":%q,"url":%q}`, alg, accountURL, nonce, url) } phead = base64.RawURLEncoding.EncodeToString([]byte(phead)) - cs, err := json.Marshal(claimset) - if err != nil { - return nil, err + var payload string + if claimset != nil { + cs, err := json.Marshal(claimset) + if err != nil { + return nil, err + } + payload = base64.RawURLEncoding.EncodeToString(cs) } - payload := base64.RawURLEncoding.EncodeToString(cs) hash := sha.New() hash.Write([]byte(phead + "." + payload)) sig, err := jwsSign(key, sha, hash.Sum(nil)) From 1a7ae9a079d80a88bef01e5a56b3a1cdadcd454e Mon Sep 17 00:00:00 2001 From: Joao Morais Date: Mon, 9 Dec 2019 11:36:48 -0300 Subject: [PATCH 07/14] add optional test which calls Lets Encrypt staging env This test was used to run an end-to-end test and validate the steps used to authorize certificate signing. It's optional, will only run if an email and a client private key is provided. --- pkg/acme/client_test.go | 86 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 86 insertions(+) create mode 100644 pkg/acme/client_test.go diff --git a/pkg/acme/client_test.go b/pkg/acme/client_test.go new file mode 100644 index 000000000..797421033 --- /dev/null +++ b/pkg/acme/client_test.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 ( + "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 { + 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 +} From df8bfea83d98c899da3f8eed5e1cff545d33194a Mon Sep 17 00:00:00 2001 From: Joao Morais Date: Tue, 10 Dec 2019 21:47:23 -0300 Subject: [PATCH 08/14] acme doc --- .../en/docs/configuration/command-line.md | 32 ++++++- docs/content/en/docs/configuration/keys.md | 90 +++++++++++++++++++ 2 files changed, 120 insertions(+), 2 deletions(-) diff --git a/docs/content/en/docs/configuration/command-line.md b/docs/content/en/docs/configuration/command-line.md index ed51bb46b..9e5f9d39b 100644 --- a/docs/content/en/docs/configuration/command-line.md +++ b/docs/content/en/docs/configuration/command-line.md @@ -10,10 +10,17 @@ 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 | | [`--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 +35,27 @@ 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. + +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..a2e15faad 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,90 @@ 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. + +**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 certificates. 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 | From 627936750b29aab0f351f73e66db4b243fed46f0 Mon Sep 17 00:00:00 2001 From: Joao Morais Date: Tue, 10 Dec 2019 21:50:58 -0300 Subject: [PATCH 09/14] update default acme command-line options --- pkg/common/ingress/controller/launch.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pkg/common/ingress/controller/launch.go b/pkg/common/ingress/controller/launch.go index 9dbbfc8bb..3e74a1757 100644 --- a/pkg/common/ingress/controller/launch.go +++ b/pkg/common/ingress/controller/launch.go @@ -56,11 +56,11 @@ func NewIngressController(backend ingress.Controller) *GenericController { acmeCheckPeriod = flags.Duration("acme-check-period", 24*time.Hour, `Time between checks of invalid or expiring certificates`) - acmeFailInitialDuration = flags.Duration("acme-fail-initial-duration", 10*time.Minute, + 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", 24*time.Hour, + 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", From 860db3174efcdc8879a2b4c3aaf50cf588801c47 Mon Sep 17 00:00:00 2001 From: Joao Morais Date: Tue, 10 Dec 2019 22:07:00 -0300 Subject: [PATCH 10/14] add acme-election-id command-line option --- pkg/common/ingress/controller/controller.go | 1 + pkg/common/ingress/controller/launch.go | 4 ++++ pkg/controller/controller.go | 5 ++--- 3 files changed, 7 insertions(+), 3 deletions(-) diff --git a/pkg/common/ingress/controller/controller.go b/pkg/common/ingress/controller/controller.go index 0a44825d8..9cd6af2fb 100644 --- a/pkg/common/ingress/controller/controller.go +++ b/pkg/common/ingress/controller/controller.go @@ -139,6 +139,7 @@ type Configuration struct { AcmeCheckPeriod time.Duration AcmeFailInitialDuration time.Duration AcmeFailMaxDuration time.Duration + AcmeElectionID string AcmeSecretKeyName string AcmeTokenConfigmapName string diff --git a/pkg/common/ingress/controller/launch.go b/pkg/common/ingress/controller/launch.go index 3e74a1757..e37065461 100644 --- a/pkg/common/ingress/controller/launch.go +++ b/pkg/common/ingress/controller/launch.go @@ -56,6 +56,9 @@ func NewIngressController(backend ingress.Controller) *GenericController { 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'`) @@ -278,6 +281,7 @@ func NewIngressController(backend ingress.Controller) *GenericController { Client: kubeClient, AcmeServer: *acmeServer, AcmeCheckPeriod: *acmeCheckPeriod, + AcmeElectionID: *acmeElectionID, AcmeFailInitialDuration: *acmeFailInitialDuration, AcmeFailMaxDuration: *acmeFailMaxDuration, AcmeSecretKeyName: *acmeSecretKeyName, diff --git a/pkg/controller/controller.go b/pkg/controller/controller.go index 835fce4cd..1f3cd241b 100644 --- a/pkg/controller/controller.go +++ b/pkg/controller/controller.go @@ -113,9 +113,8 @@ func (hc *HAProxyController) configController() { hc.stopCh = hc.controller.GetStopCh() hc.logger = &logger{depth: 1} hc.cache = newCache(hc.cfg.Client, hc.storeLister, hc.controller) - if hc.cfg.AcmeServer || false /* waiting status sync, which also uses leader election */ { - // TODO move status sync + fix electorID - electorID := fmt.Sprintf("tmp-%s-acme-elector", hc.cfg.IngressClass) + if hc.cfg.AcmeServer { + electorID := fmt.Sprintf("%s-%s", hc.cfg.AcmeElectionID, hc.cfg.IngressClass) hc.leaderelector = NewLeaderElector(electorID, hc.logger, hc.cache, hc) } var acmeSigner acme.Signer From 1818fde8fae9050087181dc057680011e61ea191 Mon Sep 17 00:00:00 2001 From: Joao Morais Date: Wed, 11 Dec 2019 16:56:10 -0300 Subject: [PATCH 11/14] add acme-track-tls-annotation command-line option Some acme clients use `kubernetes.io/tls-acme` annotation to identify ingress objects that should generate certificate using acme protocol. This command-line option enables the reading of this annotation in order to provide compatibility with such clients. --- docs/content/en/docs/configuration/command-line.md | 2 ++ docs/content/en/docs/configuration/keys.md | 10 ++++++---- pkg/common/ingress/controller/controller.go | 1 + pkg/common/ingress/controller/launch.go | 4 ++++ pkg/controller/controller.go | 5 ++--- pkg/converters/ingress/ingress.go | 11 ++++++++++- pkg/converters/ingress/types/annotations.go | 5 +++++ pkg/converters/ingress/types/options.go | 1 + 8 files changed, 31 insertions(+), 8 deletions(-) diff --git a/docs/content/en/docs/configuration/command-line.md b/docs/content/en/docs/configuration/command-line.md index 9e5f9d39b..8d24d3f51 100644 --- a/docs/content/en/docs/configuration/command-line.md +++ b/docs/content/en/docs/configuration/command-line.md @@ -17,6 +17,7 @@ The following command-line options are supported: | [`--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 | haproxy's 404 page | | @@ -49,6 +50,7 @@ Supported acme command-line options: * `--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: diff --git a/docs/content/en/docs/configuration/keys.md b/docs/content/en/docs/configuration/keys.md index a2e15faad..d8b7e065e 100644 --- a/docs/content/en/docs/configuration/keys.md +++ b/docs/content/en/docs/configuration/keys.md @@ -261,7 +261,7 @@ Supported acme configuration keys: * `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. +* `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** @@ -292,9 +292,11 @@ 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 certificates. 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. +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 diff --git a/pkg/common/ingress/controller/controller.go b/pkg/common/ingress/controller/controller.go index 9cd6af2fb..cc075bc02 100644 --- a/pkg/common/ingress/controller/controller.go +++ b/pkg/common/ingress/controller/controller.go @@ -142,6 +142,7 @@ type Configuration struct { AcmeElectionID string AcmeSecretKeyName string AcmeTokenConfigmapName string + AcmeTrackTLSAnn bool // optional TCPConfigMapName string diff --git a/pkg/common/ingress/controller/launch.go b/pkg/common/ingress/controller/launch.go index e37065461..9a158c4e2 100644 --- a/pkg/common/ingress/controller/launch.go +++ b/pkg/common/ingress/controller/launch.go @@ -76,6 +76,9 @@ func NewIngressController(backend ingress.Controller) *GenericController { 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 @@ -286,6 +289,7 @@ func NewIngressController(backend ingress.Controller) *GenericController { AcmeFailMaxDuration: *acmeFailMaxDuration, AcmeSecretKeyName: *acmeSecretKeyName, AcmeTokenConfigmapName: *acmeTokenConfigmapName, + AcmeTrackTLSAnn: *acmeTrackTLSAnn, RateLimitUpdate: *rateLimitUpdate, ResyncPeriod: *resyncPeriod, DefaultService: *defaultSvc, diff --git a/pkg/controller/controller.go b/pkg/controller/controller.go index 1f3cd241b..1cbba3108 100644 --- a/pkg/controller/controller.go +++ b/pkg/controller/controller.go @@ -113,12 +113,10 @@ func (hc *HAProxyController) configController() { hc.stopCh = hc.controller.GetStopCh() hc.logger = &logger{depth: 1} hc.cache = newCache(hc.cfg.Client, hc.storeLister, hc.controller) + 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) - } - var acmeSigner acme.Signer - if hc.cfg.AcmeServer { acmeSigner = acme.NewSigner(hc.logger, hc.cache) hc.acmeQueue = utils.NewFailureRateLimitingQueue( hc.cfg.AcmeFailInitialDuration, @@ -147,6 +145,7 @@ func (hc *HAProxyController) configController() { AnnotationPrefix: hc.cfg.AnnPrefix, DefaultBackend: hc.cfg.DefaultService, DefaultSSLFile: hc.createDefaultSSLFile(hc.cache), + AcmeTrackTLSAnn: hc.cfg.AcmeTrackTLSAnn, } } diff --git a/pkg/converters/ingress/ingress.go b/pkg/converters/ingress/ingress.go index 4f3b43499..3ac5ac584 100644 --- a/pkg/converters/ingress/ingress.go +++ b/pkg/converters/ingress/ingress.go @@ -157,7 +157,16 @@ func (c *converter) syncIngress(ing *extensions.Ingress) { } } for _, tls := range ing.Spec.TLS { - if annHost[ingtypes.HostCertSigner] == "acme" { + // 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 { diff --git a/pkg/converters/ingress/types/annotations.go b/pkg/converters/ingress/types/annotations.go index eb251abd9..623c298d2 100644 --- a/pkg/converters/ingress/types/annotations.go +++ b/pkg/converters/ingress/types/annotations.go @@ -126,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/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 } From 19fb1418cc92f28221bc3d568d2a864e566548f9 Mon Sep 17 00:00:00 2001 From: Joao Morais Date: Wed, 11 Dec 2019 18:23:51 -0300 Subject: [PATCH 12/14] remove unused acme tokens Acme challenges happen configuring a token which should be served when the acme environment calls a URL in the domain being validated. Every ingress instance should know this token, so because of that it is stored in k8s. Old tokens wasn't being removed after being used, this change fixes this behaviour. --- pkg/acme/client.go | 1 + pkg/acme/client_test.go | 3 +++ pkg/controller/cache.go | 6 +++++- 3 files changed, 9 insertions(+), 1 deletion(-) diff --git a/pkg/acme/client.go b/pkg/acme/client.go index 83bcb7da9..e919cc5f7 100644 --- a/pkg/acme/client.go +++ b/pkg/acme/client.go @@ -166,6 +166,7 @@ func (c *client) authorize(dnsnames []string, order *acme.Order) error { 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 diff --git a/pkg/acme/client_test.go b/pkg/acme/client_test.go index 797421033..fd3569cd9 100644 --- a/pkg/acme/client_test.go +++ b/pkg/acme/client_test.go @@ -78,6 +78,9 @@ func (c *clientResolver) GetKey() (crypto.Signer, error) { } 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 diff --git a/pkg/controller/cache.go b/pkg/controller/cache.go index cd6e31372..b38217724 100644 --- a/pkg/controller/cache.go +++ b/pkg/controller/cache.go @@ -329,6 +329,10 @@ func (c *cache) SetToken(domain string, uri, token string) error { if config.Data == nil { config.Data = make(map[string]string, 1) } - config.Data[domain] = uri + "=" + token + if token != "" { + config.Data[domain] = uri + "=" + token + } else { + delete(config.Data, domain) + } return c.listers.ConfigMap.CreateOrUpdate(config) } From 68b9b3d2d8c5fdbe647db44c90dcf6589fcb57d0 Mon Sep 17 00:00:00 2001 From: Joao Morais Date: Wed, 11 Dec 2019 18:27:06 -0300 Subject: [PATCH 13/14] fixes dynamic update if acme config changes Dynamic update was using reflect.DeepEquals to compare two configuration objects. Since acme implementation this object has an external state which might differs even on equals configs. Because of that config.Equals() should be used instead. --- pkg/haproxy/dynupdate.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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") From 5f7059f9c22a87bd00345a63d0e4dd17bf9ad300 Mon Sep 17 00:00:00 2001 From: Joao Morais Date: Wed, 11 Dec 2019 18:29:35 -0300 Subject: [PATCH 14/14] acme logging improvements Add some informational logging and changed the noise of "skipping update" on non leader instances. --- pkg/acme/signer.go | 1 + pkg/haproxy/instance.go | 30 ++++++++++++++++++++++-------- 2 files changed, 23 insertions(+), 8 deletions(-) diff --git a/pkg/acme/signer.go b/pkg/acme/signer.go index b381aca80..86b99c051 100644 --- a/pkg/acme/signer.go +++ b/pkg/acme/signer.go @@ -87,6 +87,7 @@ func (s *signer) AcmeAccount(endpoint, emails string, termsAgreed bool) { 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) diff --git a/pkg/haproxy/instance.go b/pkg/haproxy/instance.go index 2102a3456..de0f97c72 100644 --- a/pkg/haproxy/instance.go +++ b/pkg/haproxy/instance.go @@ -85,10 +85,16 @@ func (i *instance) AcmePeriodicCheck() { 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) } - i.logger.Info("finish adding certificates to the work queue") } func (i *instance) acmeEnsureConfig(acmeConfig *hatypes.Acme) { @@ -111,7 +117,7 @@ func (i *instance) acmeBuildCert(storage string, domains map[string]struct{}) st func (i *instance) acmeAddCert(storage string, domains map[string]struct{}) { strcert := i.acmeBuildCert(storage, domains) - i.logger.Info("enqueue certificate for processing: storage=%s domain(s)=%s", + i.logger.InfoV(2, "enqueue certificate for processing: storage=%s domain(s)=%s", storage, strcert) i.options.AcmeQueue.Add(storage + "," + strcert) } @@ -173,27 +179,35 @@ func (i *instance) acmeUpdate() { return } le := i.options.LeaderElector - if !le.IsLeader() { - i.logger.Info("skipping acme update, leader is %s", le.LeaderName()) - return + if le.IsLeader() { + i.acmeEnsureConfig(i.curConfig.Acme()) } - 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) { - i.acmeRemoveCert(storage, domains) + 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) { - i.acmeAddCert(storage, domains) + 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) {