diff --git a/docs/content/en/docs/configuration/keys.md b/docs/content/en/docs/configuration/keys.md index 3c0870080..0511ff02d 100644 --- a/docs/content/en/docs/configuration/keys.md +++ b/docs/content/en/docs/configuration/keys.md @@ -293,6 +293,7 @@ The table below describes all supported configuration keys. | [`acme-emails`](#acme) | email1,email2,... | Global | | | [`acme-endpoint`](#acme) | [`v2-staging`\|`v2`\|`endpoint`] | Global | | | [`acme-expiring`](#acme) | number of days | Global | `30` | +| [`acme-preferred-chain`](#acme) | CN (Common Name) of the issuer | Host | | | [`acme-shared`](#acme) | [true\|false] | Global | `false` | | [`acme-terms-agreed`](#acme) | [true\|false] | Global | `false` | | [`affinity`](#affinity) | affinity type | Backend | | @@ -500,14 +501,15 @@ The table below describes all supported configuration keys. ## 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 | +| Configuration key | Scope | Default | Since | +|------------------------|----------|---------|---------| +| `acme-emails` | `Global` | | v0.9 | +| `acme-endpoint` | `Global` | | v0.9 | +| `acme-expiring` | `Global` | `30` | v0.9 | +| `acme-preferred-chain` | `Host` | | v0.13.5 | +| `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. @@ -520,6 +522,7 @@ 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-preferred-chain`: optional, defines the Issuer's CN (Common Name) of the topmost certificate in the chain, if the acme server offers multiple certificate chains. The default certificate chain will be used if empty or no match is found. Note that changing this option will not force a new certificate to be issued if a valid one is already in place and actual and preferred chains differ. A new certificate can be emitted by changing the secret name in the ingress resource, or removing the secret being referenced. * `acme-shared`: defines if another certificate signer is running in the cluster. If `false`, the default value, any request to `/.well-known/acme-challenge/` is sent to the local acme server despite any ingress object configuration. Otherwise, if `true`, a configured ingress object would take precedence. * `acme-terms-agreed`: mandatory, it should be defined as `true`, otherwise certificates won't be issued. * `cert-signer`: defines the certificate signer that should be used to authorize and sign new certificates. The only supported value is `"acme"`. Add this config as an annotation in the ingress object that should have its certificate managed by haproxy-ingress and signed by the configured acme environment. The annotation `kubernetes.io/tls-acme: "true"` is also supported if the command-line option `--acme-track-tls-annotation` is used. diff --git a/pkg/acme/client.go b/pkg/acme/client.go index e919cc5f7..3a029aa25 100644 --- a/pkg/acme/client.go +++ b/pkg/acme/client.go @@ -86,7 +86,7 @@ type ClientResolver interface { // Client ... type Client interface { - Sign(dnsnames []string) (crt, key []byte, err error) + Sign(dnsnames []string, preferredChain string) (crt, key []byte, err error) } type client struct { @@ -128,7 +128,7 @@ func (c *client) ensureAccount() error { return nil } -func (c *client) Sign(dnsnames []string) (crt, key []byte, err error) { +func (c *client) Sign(dnsnames []string, preferredChain string) (crt, key []byte, err error) { if len(dnsnames) == 0 { return crt, key, fmt.Errorf("dnsnames is empty") } @@ -142,7 +142,7 @@ func (c *client) Sign(dnsnames []string) (crt, key []byte, err error) { csrTemplate := &x509.CertificateRequest{} csrTemplate.Subject.CommonName = dnsnames[0] csrTemplate.DNSNames = dnsnames - return c.signRequest(order, csrTemplate) + return c.signRequest(order, csrTemplate, preferredChain) } func (c *client) authorize(dnsnames []string, order *acme.Order) error { @@ -180,7 +180,7 @@ func (c *client) authorize(dnsnames []string, order *acme.Order) error { return nil } -func (c *client) signRequest(order *acme.Order, csrTemplate *x509.CertificateRequest) (crt, key []byte, err error) { +func (c *client) signRequest(order *acme.Order, csrTemplate *x509.CertificateRequest, preferredChain string) (crt, key []byte, err error) { keys, err := rsa.GenerateKey(rand.Reader, 2048) if err != nil { return crt, key, err @@ -189,8 +189,8 @@ func (c *client) signRequest(order *acme.Order, csrTemplate *x509.CertificateReq if err != nil { return crt, key, err } - rawCerts, err := c.client.FinalizeOrder(c.ctx, order.FinalizeURL, csr) - if err != nil { + rawCerts, err := c.client.FinalizeOrder(c.ctx, order.FinalizeURL, csr, preferredChain) + if err != nil && rawCerts == nil { return crt, key, err } key = pem.EncodeToMemory(&pem.Block{ @@ -203,5 +203,5 @@ func (c *client) signRequest(order *acme.Order, csrTemplate *x509.CertificateReq Bytes: rawCert, })...) } - return crt, key, nil + return crt, key, err } diff --git a/pkg/acme/client_test.go b/pkg/acme/client_test.go index fd3569cd9..e310bbd7b 100644 --- a/pkg/acme/client_test.go +++ b/pkg/acme/client_test.go @@ -22,6 +22,7 @@ import ( "encoding/base64" "fmt" "io/ioutil" + "os" "testing" "time" @@ -36,9 +37,21 @@ const ( // Optional, nothing will be done if any value is missing. // // DO NOT COMMIT+PUSH THE CLIENT KEY! + // + // single line base64 encoded client's private key in DER format + // $ openssl genrsa |openssl rsa -outform der |base64 -w0 >privkey ## omit -w0 on macOS/Darwin/BSD clientkey = `` - email = `` - domain = `` + // email that should be assigned to the account + email = `` + // domain used to execute the challenge + domain = `` + // an optional preferred chain - note that currently (oct/2021) Let's Encrypt + // staging doesn't have an alternate chain + chain = `` + // a local path where the response of the challenge should be writted + // if empty the challenge will be written to /tmp/out and the test will + // wait 20s to continue + wwwpublic = `` ) func TestSign(t *testing.T) { @@ -58,7 +71,7 @@ func TestSign(t *testing.T) { } // TODO test resulting crt // TODO debug/fine logging in the Sign() steps - _, _, err = client.Sign([]string{domain}) + _, _, err = client.Sign([]string{domain}, chain) if err != nil { t.Errorf("error signing certificate: %v", err) } @@ -78,6 +91,16 @@ func (c *clientResolver) GetKey() (crypto.Signer, error) { } func (c *clientResolver) SetToken(domain string, uri, token string) error { + if wwwpublic != "" { + file := wwwpublic + uri + if token == "" { + return os.Remove(file) + } + if err := os.MkdirAll(wwwpublic+"/.well-known/acme-challenge", 0755); err != nil { + return err + } + return ioutil.WriteFile(file, []byte(token), 0644) + } if token == "" { return nil } diff --git a/pkg/acme/signer.go b/pkg/acme/signer.go index 07d2b30ab..cf19fcd59 100644 --- a/pkg/acme/signer.go +++ b/pkg/acme/signer.go @@ -114,12 +114,13 @@ func (s *signer) Notify(item interface{}) error { } cert := strings.Split(item.(string), ",") secretName := cert[0] - domains := cert[1:] - err := s.verify(secretName, domains) + preferredChain := cert[1] + domains := cert[2:] + err := s.verify(secretName, preferredChain, domains) return err } -func (s *signer) verify(secretName string, domains []string) (verifyErr error) { +func (s *signer) verify(secretName, preferredChain string, domains []string) (verifyErr error) { duedate := time.Now().Add(s.expiring) tls, errSecret := s.cache.GetTLSSecretContent(secretName) strdomains := strings.Join(domains, ",") @@ -139,11 +140,14 @@ func (s *signer) verify(secretName string, domains []string) (verifyErr error) { s.verifyCount++ s.logger.Info("acme: authorizing: id=%d secret=%s domain(s)=%s endpoint=%s reason='%s'", s.verifyCount, secretName, strdomains, s.account.Endpoint, reason) - crt, key, err := s.client.Sign(domains) - if err == nil { + crt, key, err := s.client.Sign(domains, preferredChain) + if crt != nil && key != nil { + if err != nil { + s.logger.Warn("warning from client: %v", err) + } 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) + s.logger.Info("acme: new certificate issued: id=%d secret=%s domain(s)=%s preferred-chain=%s", + s.verifyCount, secretName, strdomains, preferredChain) } else { s.logger.Warn("acme: error storing new certificate: id=%d secret=%s domain(s)=%s error=%v", s.verifyCount, secretName, strdomains, errTLS) diff --git a/pkg/acme/signer_test.go b/pkg/acme/signer_test.go index e195067e9..8cba3e48b 100644 --- a/pkg/acme/signer_test.go +++ b/pkg/acme/signer_test.go @@ -34,58 +34,58 @@ func TestNotifyVerify(t *testing.T) { testCases := []struct { input string expiresIn time.Duration - cert string + cert string logging string }{ // 0 { - input: "s1,d1.local", + input: "s1,,d1.local", expiresIn: 10 * 24 * time.Hour, - cert: dumbcrt, + cert: dumbcrt, logging: ` INFO-V(2) acme: skipping sign, certificate is updated: secret=s1 domain(s)=d1.local`, }, // 1 { - input: "s1,d2.local", + input: "s1,,d2.local", expiresIn: -10 * 24 * time.Hour, - cert: dumbcrt, + cert: dumbcrt, logging: ` INFO acme: authorizing: id=1 secret=s1 domain(s)=d2.local endpoint=https://acme-v2.local reason='certificate expires in 2020-12-01 16:33:14 +0000 UTC' -INFO acme: new certificate issued: id=1 secret=s1 domain(s)=d2.local`, +INFO acme: new certificate issued: id=1 secret=s1 domain(s)=d2.local preferred-chain=`, }, // 2 { - input: "s1,d3.local", + input: "s1,,d3.local", expiresIn: 10 * 24 * time.Hour, - cert: dumbcrt, + cert: dumbcrt, logging: ` INFO acme: authorizing: id=1 secret=s1 domain(s)=d3.local endpoint=https://acme-v2.local reason='added one or more domains to an existing certificate' -INFO acme: new certificate issued: id=1 secret=s1 domain(s)=d3.local`, +INFO acme: new certificate issued: id=1 secret=s1 domain(s)=d3.local preferred-chain=`, }, // 3 { - input: "s2,d1.local", + input: "s2,,d1.local", expiresIn: 10 * 24 * time.Hour, - cert: dumbcrt, + cert: dumbcrt, logging: ` INFO acme: authorizing: id=1 secret=s2 domain(s)=d1.local endpoint=https://acme-v2.local reason='certificate does not exist (secret not found: s2)' -INFO acme: new certificate issued: id=1 secret=s2 domain(s)=d1.local`, +INFO acme: new certificate issued: id=1 secret=s2 domain(s)=d1.local preferred-chain=`, }, { - input: "s1,s3.dev.local", + input: "s1,,s3.dev.local", expiresIn: 10 * 24 * time.Hour, - cert: dumbwildcardcrt, + cert: dumbwildcardcrt, logging: ` INFO-V(2) acme: skipping sign, certificate is updated: secret=s1 domain(s)=s3.dev.local`, }, { - input: "s1,other.s3.dev.local", + input: "s1,,other.s3.dev.local", expiresIn: 10 * 24 * time.Hour, - cert: dumbwildcardcrt, + cert: dumbwildcardcrt, logging: ` INFO acme: authorizing: id=1 secret=s1 domain(s)=other.s3.dev.local endpoint=https://acme-v2.local reason='added one or more domains to an existing certificate' -INFO acme: new certificate issued: id=1 secret=s1 domain(s)=other.s3.dev.local`, +INFO acme: new certificate issued: id=1 secret=s1 domain(s)=other.s3.dev.local preferred-chain=`, }, } c := setup(t) @@ -132,8 +132,8 @@ func (c *config) newSigner() *signer { type clientMock struct{} -func (c *clientMock) Sign(domains []string) (crt, key []byte, err error) { - return nil, nil, nil +func (c *clientMock) Sign(domains []string, preferredChain string) (crt, key []byte, err error) { + return []byte("fake-crt"), []byte("fake-key"), nil } type cache struct { diff --git a/pkg/acme/x/acme/acme.go b/pkg/acme/x/acme/acme.go index bc1bcfc58..b41b1aa06 100644 --- a/pkg/acme/x/acme/acme.go +++ b/pkg/acme/x/acme/acme.go @@ -21,6 +21,7 @@ import ( "crypto" "crypto/rand" "crypto/sha256" + "crypto/x509" "encoding/base64" "encoding/json" "encoding/pem" @@ -31,6 +32,7 @@ import ( "math/big" "net/http" "net/url" + "regexp" "strconv" "sync" "time" @@ -212,7 +214,11 @@ func (c *Client) CreateOrder(ctx context.Context, order *Order) (*Order, error) // // 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) { +// +// altcn, if not empty, sbould have the Issuer's CN of the topmost certificate of +// the chain, if the acme server offers multiple certificate chains. If a match +// isn't found, an error will be returned along with the default certificate. +func (c *Client) FinalizeOrder(ctx context.Context, finalizeURL string, csr []byte, altcn string) (der [][]byte, err error) { if _, err := c.Discover(ctx); err != nil { return nil, err } @@ -249,8 +255,32 @@ func (c *Client) FinalizeOrder(ctx context.Context, finalizeURL string, csr []by if o.Status != StatusValid { return nil, fmt.Errorf("acme: unexpected order status %q", o.Status) } + der, altURLs, err := c.getCertAndAlternates(ctx, o.CertificateURL) + if altcn == "" || err != nil || matchCN(der, altcn) { + return der, err + } + defaultDER := der + for _, altURL := range altURLs { + der, _, err = c.getCertAndAlternates(ctx, altURL) + if err != nil { + return defaultDER, err + } + if matchCN(der, altcn) { + return der, err + } + } + return defaultDER, fmt.Errorf("acme: alternate chain not found for Common Name '%s', using default chain", altcn) +} - return c.getCert(ctx, o.CertificateURL) +func matchCN(der [][]byte, cn string) bool { + if len(der) == 0 { + return false + } + crt, err := x509.ParseCertificate(der[len(der)-1]) + if err != nil { + return false + } + return crt.Issuer.CommonName == cn } // GetOrder retrieves an order identified by url. @@ -821,18 +851,20 @@ func nonceFromHeader(h http.Header) string { return h.Get("Replay-Nonce") } -func (c *Client) getCert(ctx context.Context, url string) ([][]byte, error) { +var linkRegex = regexp.MustCompile(`<(.*)>;rel="alternate"`) + +func (c *Client) getCertAndAlternates(ctx context.Context, url string) ([][]byte, []string, error) { res, err := c.postWithJWSAccount(ctx, url, nil) if err != nil { - return nil, err + return nil, 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) + return nil, nil, fmt.Errorf("acme: error getting certificate: %v", err) } if len(data) > maxChainSize { - return nil, errors.New("acme: certificate chain is too big") + return nil, nil, errors.New("acme: certificate chain is too big") } var chain [][]byte for { @@ -840,19 +872,26 @@ func (c *Client) getCert(ctx context.Context, url string) ([][]byte, error) { p, data = pem.Decode(data) if p == nil { if len(chain) == 0 { - return nil, errors.New("acme: invalid PEM certificate chain") + return nil, nil, errors.New("acme: invalid PEM certificate chain") } break } if len(chain) == maxChainLen { - return nil, errors.New("acme: certificate chain is too long") + return nil, 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) + return nil, nil, fmt.Errorf("acme: invalid PEM block type %q", p.Type) } chain = append(chain, p.Bytes) } - return chain, nil + var alts []string + for _, alt := range res.Header["Link"] { + links := linkRegex.FindStringSubmatch(alt) + if len(links) > 0 { + alts = append(alts, links[1]) + } + } + return chain, alts, nil } // responseError creates an error of Error type from resp. diff --git a/pkg/acme/x/acme/acme_test.go b/pkg/acme/x/acme/acme_test.go index e0ad09784..48e9b6100 100644 --- a/pkg/acme/x/acme/acme_test.go +++ b/pkg/acme/x/acme/acme_test.go @@ -710,7 +710,7 @@ func TestFinalizeOrder(t *testing.T) { notBefore := time.Now() notAfter := notBefore.AddDate(0, 2, 0) timeNow = func() time.Time { return notBefore } - var sampleCert []byte + var intermCACert, intermCACertSelfSigned, sampleCert []byte var ts *httptest.Server var orderGets int @@ -720,7 +720,17 @@ func TestFinalizeOrder(t *testing.T) { return } if r.URL.Path == "/cert" && r.Method == "POST" { + w.Header().Set("Link", fmt.Sprintf(`<%s/directory>;rel="index"`, ts.URL)) + w.Header().Set("Link", fmt.Sprintf(`<%s>;rel="alternate"`, ts.URL+"/cert/1")) pem.Encode(w, &pem.Block{Type: "CERTIFICATE", Bytes: sampleCert}) + pem.Encode(w, &pem.Block{Type: "CERTIFICATE", Bytes: intermCACert}) + return + } + if r.URL.Path == "/cert/1" && r.Method == "POST" { + w.Header().Set("Link", fmt.Sprintf(`<%s/directory>;rel="index"`, ts.URL)) + w.Header().Set("Link", fmt.Sprintf(`<%s>;rel="alternate"`, ts.URL+"/cert")) + pem.Encode(w, &pem.Block{Type: "CERTIFICATE", Bytes: sampleCert}) + pem.Encode(w, &pem.Block{Type: "CERTIFICATE", Bytes: intermCACertSelfSigned}) return } if r.URL.Path == "/order" { @@ -750,6 +760,7 @@ func TestFinalizeOrder(t *testing.T) { template := x509.Certificate{ SerialNumber: big.NewInt(int64(1)), Subject: pkix.Name{ + CommonName: "example.com", Organization: []string{"goacme"}, }, NotBefore: notBefore, @@ -760,8 +771,28 @@ func TestFinalizeOrder(t *testing.T) { BasicConstraintsValid: true, } + rootCA := template + rootCA.Subject = pkix.Name{ + CommonName: "ACME Root CA", + Organization: []string{"goacme"}, + } + + intermCA := template + intermCA.Subject = pkix.Name{ + CommonName: "ACME Intermediate CA", + Organization: []string{"goacme"}, + } + var err error - sampleCert, err = x509.CreateCertificate(rand.Reader, &template, &template, &testKeyEC.PublicKey, testKeyEC) + intermCACert, err = x509.CreateCertificate(rand.Reader, &intermCA, &rootCA, &testKeyEC.PublicKey, testKeyEC) + if err != nil { + t.Fatalf("Error creating certificate: %v", err) + } + intermCACertSelfSigned, err = x509.CreateCertificate(rand.Reader, &intermCA, &intermCA, &testKeyEC.PublicKey, testKeyEC) + if err != nil { + t.Fatalf("Error creating certificate: %v", err) + } + sampleCert, err = x509.CreateCertificate(rand.Reader, &template, &intermCA, &testKeyEC.PublicKey, testKeyEC) if err != nil { t.Fatalf("Error creating certificate: %v", err) } @@ -788,13 +819,62 @@ func TestFinalizeOrder(t *testing.T) { 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) + testCases := []struct { + altCN string + errMsg string + issuer []string + subject []string + }{ + { + altCN: "", + issuer: []string{"ACME Intermediate CA", "ACME Root CA"}, + subject: []string{"example.com", "ACME Intermediate CA"}, + }, + { + altCN: "non", + issuer: []string{"ACME Intermediate CA", "ACME Root CA"}, + subject: []string{"example.com", "ACME Intermediate CA"}, + errMsg: "acme: alternate chain not found for Common Name 'non', using default chain", + }, + { + altCN: "ACME Root CA", + issuer: []string{"ACME Intermediate CA", "ACME Root CA"}, + subject: []string{"example.com", "ACME Intermediate CA"}, + }, + { + altCN: "ACME Intermediate CA", + issuer: []string{"ACME Intermediate CA", "ACME Intermediate CA"}, + subject: []string{"example.com", "ACME Intermediate CA"}, + }, } - if cert == nil { - t.Errorf("cert is nil") + + for _, test := range testCases { + c := Client{Key: testKeyEC, accountURL: "https://example.com/acme/account", dir: &Directory{NewNonceURL: ts.URL}} + cert, err := c.FinalizeOrder(context.Background(), ts.URL, csrb, test.altCN) + if err != nil || test.errMsg != "" { + if err == nil { + t.Errorf("expected error message: %s", test.errMsg) + } else if test.errMsg == "" { + t.Error(err) + } else if err.Error() != test.errMsg { + t.Errorf("error differs. expected: '%s'; actual: '%v'", test.errMsg, err) + } + } + var issuer, subject []string + for _, der := range cert { + crt, err := x509.ParseCertificate(der) + if err != nil { + t.Fatal(err) + } + issuer = append(issuer, crt.Issuer.CommonName) + subject = append(subject, crt.Subject.CommonName) + } + if !reflect.DeepEqual(issuer, test.issuer) { + t.Errorf("issuer differs. altCN: '%s'; expected: '%v'; actual: '%v'", test.altCN, test.issuer, issuer) + } + if !reflect.DeepEqual(subject, test.subject) { + t.Errorf("subject differs. altCN: '%s'; expected: '%v'; actual: '%v'", test.altCN, test.subject, subject) + } } } diff --git a/pkg/converters/ingress/ingress.go b/pkg/converters/ingress/ingress.go index 6c76dee01..fb8f48d00 100644 --- a/pkg/converters/ingress/ingress.go +++ b/pkg/converters/ingress/ingress.go @@ -475,7 +475,13 @@ func (c *converter) syncIngressHTTP(source *annotations.Source, ing *networking. if tls.SecretName != "" { secretName := ing.Namespace + "/" + tls.SecretName ingName := ing.Namespace + "/" + ing.Name - c.haproxy.AcmeData().Storages().Acquire(secretName).AddDomains(tls.Hosts) + acmeStorage := c.haproxy.AcmeData().Storages().Acquire(secretName) + acmeStorage.AddDomains(tls.Hosts) + if preferredChain := annHost[ingtypes.HostAcmePreferredChain]; preferredChain != "" { + if err := acmeStorage.AssignPreferredChain(preferredChain); err != nil { + c.logger.Warn("preferred chain ignored on %v due to an error: %v", source, err) + } + } c.tracker.TrackNames(convtypes.ResourceIngress, ingName, convtypes.ResourceAcmeData, secretName) } else { c.logger.Warn("skipping cert signer of %v: missing secret name", source) diff --git a/pkg/converters/ingress/types/annotations.go b/pkg/converters/ingress/types/annotations.go index 2a5d630d2..f12fc7368 100644 --- a/pkg/converters/ingress/types/annotations.go +++ b/pkg/converters/ingress/types/annotations.go @@ -36,6 +36,7 @@ var ( // Host Annotations const ( + HostAcmePreferredChain = "acme-preferred-chain" HostAppRoot = "app-root" HostAuthTLSErrorPage = "auth-tls-error-page" HostAuthTLSSecret = "auth-tls-secret" @@ -59,6 +60,7 @@ const ( var ( // AnnHost ... AnnHost = map[string]struct{}{ + HostAcmePreferredChain: {}, HostAppRoot: {}, HostAuthTLSErrorPage: {}, HostAuthTLSSecret: {}, diff --git a/pkg/haproxy/instance.go b/pkg/haproxy/instance.go index d428adb54..16a5fc83f 100644 --- a/pkg/haproxy/instance.go +++ b/pkg/haproxy/instance.go @@ -132,10 +132,13 @@ func (i *instance) acmeEnsureConfig(acmeConfig *hatypes.AcmeData) bool { func (i *instance) acmeAddStorage(storage string) { // TODO change to a proper entity - index := strings.Index(storage, ",") - name := storage[:index] - domains := storage[index+1:] - i.logger.InfoV(3, "enqueue certificate for processing: storage=%s domain(s)=%s", name, domains) + items := strings.Split(storage, ",") + if len(items) >= 2 { + name := items[0] + prefChain := items[1] + domains := strings.Join(items[2:], ",") + i.logger.InfoV(3, "enqueue certificate for processing: storage=%s domain(s)=%s preferred-chain=%s", name, domains, prefChain) + } i.options.AcmeQueue.Add(storage) } diff --git a/pkg/haproxy/types/global.go b/pkg/haproxy/types/global.go index 1f15c33a1..e407eff6f 100644 --- a/pkg/haproxy/types/global.go +++ b/pkg/haproxy/types/global.go @@ -83,7 +83,7 @@ func buildAcmeStorages(items map[string]*AcmeCerts) []string { j++ } sort.Strings(certs) - storages[i] = name + "," + strings.Join(certs, ",") + storages[i] = name + "," + item.preferredChain + "," + strings.Join(certs, ",") i++ } return storages @@ -121,6 +121,15 @@ func (c *AcmeCerts) AddDomains(domains []string) { } } +// AssignPreferredChain ... +func (c *AcmeCerts) AssignPreferredChain(preferredChain string) error { + if c.preferredChain != "" && c.preferredChain != preferredChain { + return fmt.Errorf("preferred chain already assigned to '%s'", c.preferredChain) + } + c.preferredChain = preferredChain + return nil +} + // IsExternal ... func (e *ExternalConfig) IsExternal() bool { return e.MasterSocket != "" diff --git a/pkg/haproxy/types/global_test.go b/pkg/haproxy/types/global_test.go index 4b153cbb3..9a1db27a2 100644 --- a/pkg/haproxy/types/global_test.go +++ b/pkg/haproxy/types/global_test.go @@ -24,50 +24,82 @@ import ( func TestBuildAcmeStorages(t *testing.T) { testCases := []struct { - certs [][]string - expected []string + certs [][]string + expected []string + expErrors []string }{ // 0 { certs: [][]string{ - {"cert1", "d1.local"}, + {"cert1", "", "d1.local"}, }, expected: []string{ - "cert1,d1.local", + "cert1,,d1.local", }, }, // 1 { certs: [][]string{ - {"cert1", "d1.local", "d2.local"}, - {"cert1", "d2.local", "d3.local"}, + {"cert1", "", "d1.local", "d2.local"}, + {"cert1", "", "d2.local", "d3.local"}, }, expected: []string{ - "cert1,d1.local,d2.local,d3.local", + "cert1,,d1.local,d2.local,d3.local", }, }, // 2 { certs: [][]string{ - {"cert1", "d1.local", "d2.local"}, - {"cert2", "d2.local", "d3.local"}, + {"cert1", "", "d1.local", "d2.local"}, + {"cert2", "", "d2.local", "d3.local"}, }, expected: []string{ - "cert1,d1.local,d2.local", - "cert2,d2.local,d3.local", + "cert1,,d1.local,d2.local", + "cert2,,d2.local,d3.local", + }, + }, + // 3 + { + certs: [][]string{ + {"cert1", "", "d1.local", "d2.local"}, + {"cert1", "Alt Root CA", "d2.local", "d3.local"}, + }, + expected: []string{ + "cert1,Alt Root CA,d1.local,d2.local,d3.local", + }, + }, + // 4 + { + certs: [][]string{ + {"cert1", "New Root CA", "d1.local", "d2.local"}, + {"cert1", "Alt Root CA", "d2.local", "d3.local"}, + }, + expected: []string{ + "cert1,New Root CA,d1.local,d2.local,d3.local", + }, + expErrors: []string{ + "preferred chain already assigned to 'New Root CA'", }, }, } for i, test := range testCases { acme := AcmeData{} + var errors []string for _, cert := range test.certs { - acme.Storages().Acquire(cert[0]).AddDomains(cert[1:]) + storage := acme.Storages().Acquire(cert[0]) + if err := storage.AssignPreferredChain(cert[1]); err != nil { + errors = append(errors, err.Error()) + } + storage.AddDomains(cert[2:]) } storages := acme.Storages().BuildAcmeStorages() sort.Strings(storages) if !reflect.DeepEqual(storages, test.expected) { t.Errorf("acme certs differs on %d - expected: %+v, actual: %+v", i, test.expected, storages) } + if !reflect.DeepEqual(errors, test.expErrors) { + t.Errorf("assignment errors differ on %d - expected: %v, actual: %v", i, test.expErrors, errors) + } } } @@ -85,45 +117,45 @@ func TestShrink(t *testing.T) { }, // 1 { - itemAdd: map[string]*AcmeCerts{"cert1": {d1}}, - expAdd: map[string]*AcmeCerts{"cert1": {d1}}, + itemAdd: map[string]*AcmeCerts{"cert1": {d1, ""}}, + expAdd: map[string]*AcmeCerts{"cert1": {d1, ""}}, expDel: map[string]*AcmeCerts{}, }, // 2 { - itemAdd: map[string]*AcmeCerts{"cert1": {d1}}, - itemDel: map[string]*AcmeCerts{"cert1": {d1}}, + itemAdd: map[string]*AcmeCerts{"cert1": {d1, ""}}, + itemDel: map[string]*AcmeCerts{"cert1": {d1, ""}}, expAdd: map[string]*AcmeCerts{}, expDel: map[string]*AcmeCerts{}, }, // 3 { itemAdd: map[string]*AcmeCerts{ - "cert1": {d1}, - "cert2": {d1}, + "cert1": {d1, ""}, + "cert2": {d1, ""}, }, itemDel: map[string]*AcmeCerts{ - "cert1": {d1}, - "cert2": {d2}, + "cert1": {d1, ""}, + "cert2": {d2, ""}, }, expAdd: map[string]*AcmeCerts{ - "cert2": {d1}, + "cert2": {d1, ""}, }, expDel: map[string]*AcmeCerts{ - "cert2": {d2}, + "cert2": {d2, ""}, }, }, // 4 { itemAdd: map[string]*AcmeCerts{ - "cert1": {d1}, - "cert2": {d1}, + "cert1": {d1, ""}, + "cert2": {d1, ""}, }, itemDel: map[string]*AcmeCerts{ - "cert1": {d1}, + "cert1": {d1, ""}, }, expAdd: map[string]*AcmeCerts{ - "cert2": {d1}, + "cert2": {d1, ""}, }, expDel: map[string]*AcmeCerts{}, }, diff --git a/pkg/haproxy/types/types.go b/pkg/haproxy/types/types.go index 3c41ac915..8cfb226f6 100644 --- a/pkg/haproxy/types/types.go +++ b/pkg/haproxy/types/types.go @@ -38,7 +38,8 @@ type AcmeStorages struct { // AcmeCerts ... type AcmeCerts struct { - certs map[string]struct{} + certs map[string]struct{} + preferredChain string } // Acme ...