diff --git a/acme/account.go b/acme/account.go index 9b7194bda..4730641b2 100644 --- a/acme/account.go +++ b/acme/account.go @@ -14,6 +14,7 @@ import ( "time" "github.com/containous/traefik/log" + "github.com/containous/traefik/types" "github.com/xenolf/lego/acme" ) @@ -34,7 +35,7 @@ type ChallengeCert struct { certificate *tls.Certificate } -// Init inits account struct +// Init account struct func (a *Account) Init() error { err := a.DomainsCertificate.Init() if err != nil { @@ -49,6 +50,7 @@ func (a *Account) Init() error { } cert.certificate = &certificate } + if cert.certificate.Leaf == nil { leaf, err := x509.ParseCertificate(cert.certificate.Certificate[0]) if err != nil { @@ -67,8 +69,14 @@ func NewAccount(email string) (*Account, error) { if err != nil { return nil, err } + domainsCerts := DomainsCertificates{Certs: []*DomainsCertificate{}} - domainsCerts.Init() + + err = domainsCerts.Init() + if err != nil { + return nil, err + } + return &Account{ Email: email, PrivateKey: x509.MarshalPKCS1PrivateKey(privateKey), @@ -91,6 +99,7 @@ func (a *Account) GetPrivateKey() crypto.PrivateKey { if privateKey, err := x509.ParsePKCS1PrivateKey(a.PrivateKey); err == nil { return privateKey } + log.Errorf("Cannot unmarshall private key %+v", a.PrivateKey) return nil } @@ -122,9 +131,11 @@ func (dc *DomainsCertificates) Less(i, j int) bool { if reflect.DeepEqual(dc.Certs[i].Domains, dc.Certs[j].Domains) { return dc.Certs[i].tlsCert.Leaf.NotAfter.After(dc.Certs[j].tlsCert.Leaf.NotAfter) } + if dc.Certs[i].Domains.Main == dc.Certs[j].Domains.Main { return strings.Join(dc.Certs[i].Domains.SANs, ",") < strings.Join(dc.Certs[j].Domains.SANs, ",") } + return dc.Certs[i].Domains.Main < dc.Certs[j].Domains.Main } @@ -142,29 +153,34 @@ func (dc *DomainsCertificates) removeDuplicates() { } } -// Init inits DomainsCertificates +// Init DomainsCertificates func (dc *DomainsCertificates) Init() error { dc.lock.Lock() defer dc.lock.Unlock() + for _, domainsCertificate := range dc.Certs { tlsCert, err := tls.X509KeyPair(domainsCertificate.Certificate.Certificate, domainsCertificate.Certificate.PrivateKey) if err != nil { return err } + domainsCertificate.tlsCert = &tlsCert + if domainsCertificate.tlsCert.Leaf == nil { leaf, err := x509.ParseCertificate(domainsCertificate.tlsCert.Certificate[0]) if err != nil { return err } + domainsCertificate.tlsCert.Leaf = leaf } } + dc.removeDuplicates() return nil } -func (dc *DomainsCertificates) renewCertificates(acmeCert *Certificate, domain Domain) error { +func (dc *DomainsCertificates) renewCertificates(acmeCert *Certificate, domain types.Domain) error { dc.lock.Lock() defer dc.lock.Unlock() @@ -174,15 +190,17 @@ func (dc *DomainsCertificates) renewCertificates(acmeCert *Certificate, domain D if err != nil { return err } + domainsCertificate.Certificate = acmeCert domainsCertificate.tlsCert = &tlsCert return nil } } + return fmt.Errorf("certificate to renew not found for domain %s", domain.Main) } -func (dc *DomainsCertificates) addCertificateForDomains(acmeCert *Certificate, domain Domain) (*DomainsCertificate, error) { +func (dc *DomainsCertificates) addCertificateForDomains(acmeCert *Certificate, domain types.Domain) (*DomainsCertificate, error) { dc.lock.Lock() defer dc.lock.Unlock() @@ -190,18 +208,21 @@ func (dc *DomainsCertificates) addCertificateForDomains(acmeCert *Certificate, d if err != nil { return nil, err } + cert := DomainsCertificate{Domains: domain, Certificate: acmeCert, tlsCert: &tlsCert} dc.Certs = append(dc.Certs, &cert) + return &cert, nil } func (dc *DomainsCertificates) getCertificateForDomain(domainToFind string) (*DomainsCertificate, bool) { dc.lock.RLock() defer dc.lock.RUnlock() + for _, domainsCertificate := range dc.Certs { - domains := []string{} - domains = append(domains, domainsCertificate.Domains.Main) + domains := []string{domainsCertificate.Domains.Main} domains = append(domains, domainsCertificate.Domains.SANs...) + for _, domain := range domains { if domain == domainToFind { return domainsCertificate, true @@ -211,9 +232,10 @@ func (dc *DomainsCertificates) getCertificateForDomain(domainToFind string) (*Do return nil, false } -func (dc *DomainsCertificates) exists(domainToFind Domain) (*DomainsCertificate, bool) { +func (dc *DomainsCertificates) exists(domainToFind types.Domain) (*DomainsCertificate, bool) { dc.lock.RLock() defer dc.lock.RUnlock() + for _, domainsCertificate := range dc.Certs { if reflect.DeepEqual(domainToFind, domainsCertificate.Domains) { return domainsCertificate, true @@ -224,16 +246,18 @@ func (dc *DomainsCertificates) exists(domainToFind Domain) (*DomainsCertificate, func (dc *DomainsCertificates) toDomainsMap() map[string]*tls.Certificate { domainsCertificatesMap := make(map[string]*tls.Certificate) + for _, domainCertificate := range dc.Certs { certKey := domainCertificate.Domains.Main + if domainCertificate.Domains.SANs != nil { sort.Strings(domainCertificate.Domains.SANs) + for _, dnsName := range domainCertificate.Domains.SANs { if dnsName != domainCertificate.Domains.Main { certKey += fmt.Sprintf(",%s", dnsName) } } - } domainsCertificatesMap[certKey] = domainCertificate.tlsCert } @@ -242,7 +266,7 @@ func (dc *DomainsCertificates) toDomainsMap() map[string]*tls.Certificate { // DomainsCertificate contains a certificate for multiple domains type DomainsCertificate struct { - Domains Domain + Domains types.Domain Certificate *Certificate tlsCert *tls.Certificate } @@ -254,6 +278,7 @@ func (dc *DomainsCertificate) needRenew() bool { // If there's an error, we assume the cert is broken, and needs update return true } + // <= 30 days left, renew certificate if crt.NotAfter.Before(time.Now().Add(24 * 30 * time.Hour)) { return true diff --git a/acme/acme.go b/acme/acme.go index f9592ac72..837824792 100644 --- a/acme/acme.go +++ b/acme/acme.go @@ -21,6 +21,7 @@ import ( "github.com/containous/staert" "github.com/containous/traefik/cluster" "github.com/containous/traefik/log" + acmeprovider "github.com/containous/traefik/provider/acme" "github.com/containous/traefik/safe" traefikTls "github.com/containous/traefik/tls" "github.com/containous/traefik/tls/generate" @@ -37,19 +38,19 @@ var ( // ACME allows to connect to lets encrypt and retrieve certs type ACME struct { - Email string `description:"Email address used for registration"` - Domains []Domain `description:"SANs (alternative domains) to each main domain using format: --acme.domains='main.com,san1.com,san2.com' --acme.domains='main.net,san1.net,san2.net'"` - Storage string `description:"File or key used for certificates storage."` - StorageFile string // deprecated - OnDemand bool `description:"Enable on demand certificate generation. This will request a certificate from Let's Encrypt during the first TLS handshake for a hostname that does not yet have a certificate."` //deprecated - OnHostRule bool `description:"Enable certificate generation on frontends Host rules."` - CAServer string `description:"CA server to use."` - EntryPoint string `description:"Entrypoint to proxy acme challenge to."` - DNSChallenge *DNSChallenge `description:"Activate DNS-01 Challenge"` - HTTPChallenge *HTTPChallenge `description:"Activate HTTP-01 Challenge"` - DNSProvider string `description:"Use a DNS-01 acme challenge rather than TLS-SNI-01 challenge."` // deprecated - DelayDontCheckDNS flaeg.Duration `description:"Assume DNS propagates after a delay in seconds rather than finding and querying nameservers."` // deprecated - ACMELogging bool `description:"Enable debug logging of ACME actions."` + Email string `description:"Email address used for registration"` + Domains []types.Domain `description:"SANs (alternative domains) to each main domain using format: --acme.domains='main.com,san1.com,san2.com' --acme.domains='main.net,san1.net,san2.net'"` + Storage string `description:"File or key used for certificates storage."` + StorageFile string // deprecated + OnDemand bool `description:"Enable on demand certificate generation. This will request a certificate from Let's Encrypt during the first TLS handshake for a hostname that does not yet have a certificate."` //deprecated + OnHostRule bool `description:"Enable certificate generation on frontends Host rules."` + CAServer string `description:"CA server to use."` + EntryPoint string `description:"Entrypoint to proxy acme challenge to."` + DNSChallenge *acmeprovider.DNSChallenge `description:"Activate DNS-01 Challenge"` + HTTPChallenge *acmeprovider.HTTPChallenge `description:"Activate HTTP-01 Challenge"` + DNSProvider string `description:"Activate DNS-01 Challenge (Deprecated)"` // deprecated + DelayDontCheckDNS flaeg.Duration `description:"Assume DNS propagates after a delay in seconds rather than finding and querying nameservers."` // deprecated + ACMELogging bool `description:"Enable debug logging of ACME actions."` client *acme.Client defaultCertificate *tls.Certificate store cluster.Store @@ -61,58 +62,6 @@ type ACME struct { dynamicCerts *safe.Safe } -// DNSChallenge contains DNS challenge Configuration -type DNSChallenge struct { - Provider string `description:"Use a DNS-01 based challenge provider rather than HTTPS."` - DelayBeforeCheck flaeg.Duration `description:"Assume DNS propagates after a delay in seconds rather than finding and querying nameservers."` -} - -// HTTPChallenge contains HTTP challenge Configuration -type HTTPChallenge struct { - EntryPoint string `description:"HTTP challenge EntryPoint"` -} - -//Domains parse []Domain -type Domains []Domain - -//Set []Domain -func (ds *Domains) Set(str string) error { - fargs := func(c rune) bool { - return c == ',' || c == ';' - } - // get function - slice := strings.FieldsFunc(str, fargs) - if len(slice) < 1 { - return fmt.Errorf("Parse error ACME.Domain. Imposible to parse %s", str) - } - d := Domain{ - Main: slice[0], - SANs: []string{}, - } - if len(slice) > 1 { - d.SANs = slice[1:] - } - *ds = append(*ds, d) - return nil -} - -//Get []Domain -func (ds *Domains) Get() interface{} { return []Domain(*ds) } - -//String returns []Domain in string -func (ds *Domains) String() string { return fmt.Sprintf("%+v", *ds) } - -//SetValue sets []Domain into the parser -func (ds *Domains) SetValue(val interface{}) { - *ds = Domains(val.([]Domain)) -} - -// Domain holds a domain name with SANs -type Domain struct { - Main string - SANs []string -} - func (a *ACME) init() error { // FIXME temporary fix, waiting for https://github.com/xenolf/lego/pull/478 acme.HTTPClient = http.Client{ @@ -293,100 +242,6 @@ func (a *ACME) leadershipListener(elected bool) error { return nil } -// CreateLocalConfig creates a tls.config using local ACME configuration -func (a *ACME) CreateLocalConfig(tlsConfig *tls.Config, certs *safe.Safe, checkOnDemandDomain func(domain string) bool) error { - defer a.runJobs() - err := a.init() - if err != nil { - return err - } - if len(a.Storage) == 0 { - return errors.New("Empty Store, please provide a filename for certs storage") - } - a.checkOnDemandDomain = checkOnDemandDomain - a.dynamicCerts = certs - tlsConfig.Certificates = append(tlsConfig.Certificates, *a.defaultCertificate) - tlsConfig.GetCertificate = a.getCertificate - a.TLSConfig = tlsConfig - localStore := NewLocalStore(a.Storage) - a.store = localStore - a.challengeTLSProvider = &challengeTLSProvider{store: a.store} - - var needRegister bool - var account *Account - - if fileInfo, fileErr := os.Stat(a.Storage); fileErr == nil && fileInfo.Size() != 0 { - log.Info("Loading ACME Account...") - // load account - object, err := localStore.Load() - if err != nil { - return err - } - account = object.(*Account) - } else { - log.Info("Generating ACME Account...") - account, err = NewAccount(a.Email) - if err != nil { - return err - } - needRegister = true - } - - a.client, err = a.buildACMEClient(account) - if err != nil { - log.Errorf(`Failed to build ACME client: %s -Let's Encrypt functionality will be limited until traefik is restarted.`, err) - return nil - } - - if needRegister { - // New users will need to register; be sure to save it - log.Info("Register...") - reg, err := a.client.Register() - if err != nil { - return err - } - account.Registration = reg - } - - // The client has a URL to the current Let's Encrypt Subscriber - // Agreement. The user will need to agree to it. - log.Debug("AgreeToTOS...") - err = a.client.AgreeToTOS() - if err != nil { - // Let's Encrypt Subscriber Agreement renew ? - reg, err := a.client.QueryRegistration() - if err != nil { - return err - } - account.Registration = reg - err = a.client.AgreeToTOS() - if err != nil { - log.Errorf("Error sending ACME agreement to TOS: %+v: %s", account, err.Error()) - } - } - // save account - transaction, _, err := a.store.Begin() - if err != nil { - return err - } - err = transaction.Commit(account) - if err != nil { - return err - } - - a.retrieveCertificates() - a.renewCertificates() - - ticker := time.NewTicker(24 * time.Hour) - safe.Go(func() { - for range ticker.C { - a.renewCertificates() - } - }) - return nil -} - func (a *ACME) getCertificate(clientHello *tls.ClientHelloInfo) (*tls.Certificate, error) { domain := types.CanonicalDomain(clientHello.ServerName) account := a.store.Get().(*Account) @@ -572,10 +427,12 @@ func (a *ACME) buildACMEClient(account *Account) (*acme.Client, error) { client.ExcludeChallenges([]acme.Challenge{acme.HTTP01, acme.TLSSNI01}) err = client.SetChallengeProvider(acme.DNS01, provider) } else if a.HTTPChallenge != nil && len(a.HTTPChallenge.EntryPoint) > 0 { + log.Debug("Using HTTP Challenge provider.") client.ExcludeChallenges([]acme.Challenge{acme.DNS01, acme.TLSSNI01}) a.challengeHTTPProvider = &challengeHTTPProvider{store: a.store} err = client.SetChallengeProvider(acme.HTTP01, a.challengeHTTPProvider) } else { + log.Debug("Using TLS Challenge provider.") client.ExcludeChallenges([]acme.Challenge{acme.HTTP01, acme.DNS01}) err = client.SetChallengeProvider(acme.TLSSNI01, a.challengeTLSProvider) } @@ -603,7 +460,7 @@ func (a *ACME) loadCertificateOnDemand(clientHello *tls.ClientHelloInfo) (*tls.C return nil, err } account = object.(*Account) - cert, err := account.DomainsCertificate.addCertificateForDomains(certificate, Domain{Main: domain}) + cert, err := account.DomainsCertificate.addCertificateForDomains(certificate, types.Domain{Main: domain}) if err != nil { return nil, err } @@ -660,11 +517,11 @@ func (a *ACME) LoadCertificateForDomains(domains []string) { log.Errorf("Error creating transaction %+v : %v", uncheckedDomains, err) return } - var domain Domain + var domain types.Domain if len(uncheckedDomains) > 1 { - domain = Domain{Main: uncheckedDomains[0], SANs: uncheckedDomains[1:]} + domain = types.Domain{Main: uncheckedDomains[0], SANs: uncheckedDomains[1:]} } else { - domain = Domain{Main: uncheckedDomains[0]} + domain = types.Domain{Main: uncheckedDomains[0]} } account = object.(*Account) _, err = account.DomainsCertificate.addCertificateForDomains(certificate, domain) @@ -685,7 +542,7 @@ func (a *ACME) getProvidedCertificate(domains string) *tls.Certificate { log.Debugf("Looking for provided certificate to validate %s...", domains) cert := searchProvidedCertificateForDomains(domains, a.TLSConfig.NameToCertificate) if cert == nil && a.dynamicCerts != nil && a.dynamicCerts.Get() != nil { - cert = searchProvidedCertificateForDomains(domains, a.dynamicCerts.Get().(*traefikTls.DomainsCertificates).Get().(map[string]*tls.Certificate)) + cert = searchProvidedCertificateForDomains(domains, a.dynamicCerts.Get().(map[string]*tls.Certificate)) } if cert == nil { log.Debugf("No provided certificate found for domains %s, get ACME certificate.", domains) diff --git a/acme/acme_test.go b/acme/acme_test.go index db828e708..4ecc77c5f 100644 --- a/acme/acme_test.go +++ b/acme/acme_test.go @@ -10,76 +10,122 @@ import ( "testing" "time" + acmeprovider "github.com/containous/traefik/provider/acme" "github.com/containous/traefik/tls/generate" + "github.com/containous/traefik/types" "github.com/stretchr/testify/assert" "github.com/xenolf/lego/acme" ) func TestDomainsSet(t *testing.T) { - checkMap := map[string]Domains{ - "": {}, - "foo.com": {Domain{Main: "foo.com", SANs: []string{}}}, - "foo.com,bar.net": {Domain{Main: "foo.com", SANs: []string{"bar.net"}}}, - "foo.com,bar1.net,bar2.net,bar3.net": {Domain{Main: "foo.com", SANs: []string{"bar1.net", "bar2.net", "bar3.net"}}}, + testCases := []struct { + input string + expected types.Domains + }{ + { + input: "", + expected: types.Domains{}, + }, + { + input: "foo1.com", + expected: types.Domains{ + types.Domain{Main: "foo1.com"}, + }, + }, + { + input: "foo2.com,bar.net", + expected: types.Domains{ + types.Domain{ + Main: "foo2.com", + SANs: []string{"bar.net"}, + }, + }, + }, + { + input: "foo3.com,bar1.net,bar2.net,bar3.net", + expected: types.Domains{ + types.Domain{ + Main: "foo3.com", + SANs: []string{"bar1.net", "bar2.net", "bar3.net"}, + }, + }, + }, } - for in, check := range checkMap { - ds := Domains{} - ds.Set(in) - if !reflect.DeepEqual(check, ds) { - t.Errorf("Expected %+v\nGot %+v", check, ds) - } + + for _, test := range testCases { + test := test + t.Run(test.input, func(t *testing.T) { + t.Parallel() + + domains := types.Domains{} + domains.Set(test.input) + assert.Exactly(t, test.expected, domains) + }) } } func TestDomainsSetAppend(t *testing.T) { - inSlice := []string{ - "", - "foo1.com", - "foo2.com,bar.net", - "foo3.com,bar1.net,bar2.net,bar3.net", + testCases := []struct { + input string + expected types.Domains + }{ + { + input: "", + expected: types.Domains{}, + }, + { + input: "foo1.com", + expected: types.Domains{ + types.Domain{Main: "foo1.com"}, + }, + }, + { + input: "foo2.com,bar.net", + expected: types.Domains{ + types.Domain{Main: "foo1.com"}, + types.Domain{ + Main: "foo2.com", + SANs: []string{"bar.net"}, + }, + }, + }, + { + input: "foo3.com,bar1.net,bar2.net,bar3.net", + expected: types.Domains{ + types.Domain{Main: "foo1.com"}, + types.Domain{ + Main: "foo2.com", + SANs: []string{"bar.net"}, + }, + types.Domain{ + Main: "foo3.com", + SANs: []string{"bar1.net", "bar2.net", "bar3.net"}, + }, + }, + }, } - checkSlice := []Domains{ - {}, - { - Domain{ - Main: "foo1.com", - SANs: []string{}}}, - { - Domain{ - Main: "foo1.com", - SANs: []string{}}, - Domain{ - Main: "foo2.com", - SANs: []string{"bar.net"}}}, - { - Domain{ - Main: "foo1.com", - SANs: []string{}}, - Domain{ - Main: "foo2.com", - SANs: []string{"bar.net"}}, - Domain{Main: "foo3.com", - SANs: []string{"bar1.net", "bar2.net", "bar3.net"}}}, - } - ds := Domains{} - for i, in := range inSlice { - ds.Set(in) - if !reflect.DeepEqual(checkSlice[i], ds) { - t.Errorf("Expected %s %+v\nGot %+v", in, checkSlice[i], ds) - } + + // append to + domains := types.Domains{} + for _, test := range testCases { + t.Run(test.input, func(t *testing.T) { + + domains.Set(test.input) + assert.Exactly(t, test.expected, domains) + }) } } func TestCertificatesRenew(t *testing.T) { foo1Cert, foo1Key, _ := generate.KeyPair("foo1.com", time.Now()) foo2Cert, foo2Key, _ := generate.KeyPair("foo2.com", time.Now()) + domainsCertificates := DomainsCertificates{ lock: sync.RWMutex{}, Certs: []*DomainsCertificate{ { - Domains: Domain{ - Main: "foo1.com", - SANs: []string{}}, + Domains: types.Domain{ + Main: "foo1.com"}, Certificate: &Certificate{ Domain: "foo1.com", CertURL: "url", @@ -89,9 +135,8 @@ func TestCertificatesRenew(t *testing.T) { }, }, { - Domains: Domain{ - Main: "foo2.com", - SANs: []string{}}, + Domains: types.Domain{ + Main: "foo2.com"}, Certificate: &Certificate{ Domain: "foo2.com", CertURL: "url", @@ -102,6 +147,7 @@ func TestCertificatesRenew(t *testing.T) { }, }, } + foo1Cert, foo1Key, _ = generate.KeyPair("foo1.com", time.Now()) newCertificate := &Certificate{ Domain: "foo1.com", @@ -111,17 +157,15 @@ func TestCertificatesRenew(t *testing.T) { Certificate: foo1Cert, } - err := domainsCertificates.renewCertificates( - newCertificate, - Domain{ - Main: "foo1.com", - SANs: []string{}}) + err := domainsCertificates.renewCertificates(newCertificate, types.Domain{Main: "foo1.com"}) if err != nil { t.Errorf("Error in renewCertificates :%v", err) } + if len(domainsCertificates.Certs) != 2 { t.Errorf("Expected domainsCertificates length %d %+v\nGot %+v", 2, domainsCertificates.Certs, len(domainsCertificates.Certs)) } + if !reflect.DeepEqual(domainsCertificates.Certs[0].Certificate, newCertificate) { t.Errorf("Expected new certificate %+v \nGot %+v", newCertificate, domainsCertificates.Certs[0].Certificate) } @@ -137,9 +181,8 @@ func TestRemoveDuplicates(t *testing.T) { lock: sync.RWMutex{}, Certs: []*DomainsCertificate{ { - Domains: Domain{ - Main: "foo.com", - SANs: []string{}}, + Domains: types.Domain{ + Main: "foo.com"}, Certificate: &Certificate{ Domain: "foo.com", CertURL: "url", @@ -149,9 +192,8 @@ func TestRemoveDuplicates(t *testing.T) { }, }, { - Domains: Domain{ - Main: "foo.com", - SANs: []string{}}, + Domains: types.Domain{ + Main: "foo.com"}, Certificate: &Certificate{ Domain: "foo.com", CertURL: "url", @@ -161,9 +203,8 @@ func TestRemoveDuplicates(t *testing.T) { }, }, { - Domains: Domain{ - Main: "foo.com", - SANs: []string{}}, + Domains: types.Domain{ + Main: "foo.com"}, Certificate: &Certificate{ Domain: "foo.com", CertURL: "url", @@ -173,9 +214,8 @@ func TestRemoveDuplicates(t *testing.T) { }, }, { - Domains: Domain{ - Main: "bar.com", - SANs: []string{}}, + Domains: types.Domain{ + Main: "bar.com"}, Certificate: &Certificate{ Domain: "bar.com", CertURL: "url", @@ -185,9 +225,8 @@ func TestRemoveDuplicates(t *testing.T) { }, }, { - Domains: Domain{ - Main: "foo.com", - SANs: []string{}}, + Domains: types.Domain{ + Main: "foo.com"}, Certificate: &Certificate{ Domain: "foo.com", CertURL: "url", @@ -267,7 +306,7 @@ cijFkALeQp/qyeXdFld2v9gUN3eCgljgcl0QweRoIc=---`) }`)) })) defer ts.Close() - a := ACME{DNSChallenge: &DNSChallenge{Provider: "manual", DelayBeforeCheck: 10}, CAServer: ts.URL} + a := ACME{DNSChallenge: &acmeprovider.DNSChallenge{Provider: "manual", DelayBeforeCheck: 10}, CAServer: ts.URL} client, err := a.buildACMEClient(account) if err != nil { @@ -297,7 +336,7 @@ func TestAcme_getUncheckedCertificates(t *testing.T) { domainsCertificates := DomainsCertificates{Certs: []*DomainsCertificate{ { tlsCert: &tls.Certificate{}, - Domains: Domain{ + Domains: types.Domain{ Main: "*.acme.wtf", SANs: []string{"trae.acme.io"}, }, diff --git a/acme/localStore.go b/acme/localStore.go index b65bf50a8..0822fd3c8 100644 --- a/acme/localStore.go +++ b/acme/localStore.go @@ -2,22 +2,16 @@ package acme import ( "encoding/json" - "fmt" "io/ioutil" "os" - "sync" - "github.com/containous/traefik/cluster" "github.com/containous/traefik/log" + "github.com/containous/traefik/provider/acme" ) -var _ cluster.Store = (*LocalStore)(nil) - // LocalStore is a store using a file as storage type LocalStore struct { - file string - storageLock sync.RWMutex - account *Account + file string } // NewLocalStore create a LocalStore @@ -27,71 +21,105 @@ func NewLocalStore(file string) *LocalStore { } } -// Get atomically a struct from the file storage -func (s *LocalStore) Get() cluster.Object { - s.storageLock.RLock() - defer s.storageLock.RUnlock() - return s.account -} - -// Load loads file into store -func (s *LocalStore) Load() (cluster.Object, error) { - s.storageLock.Lock() - defer s.storageLock.Unlock() +// Get loads file into store and returns the Account +func (s *LocalStore) Get() (*Account, error) { account := &Account{} - err := checkPermissions(s.file) + hasData, err := checkFile(s.file) if err != nil { return nil, err } - f, err := os.Open(s.file) - if err != nil { - return nil, err + + if hasData { + f, err := os.Open(s.file) + if err != nil { + return nil, err + } + defer f.Close() + + file, err := ioutil.ReadAll(f) + if err != nil { + return nil, err + } + + if err := json.Unmarshal(file, &account); err != nil { + return nil, err + } } - defer f.Close() - file, err := ioutil.ReadAll(f) - if err != nil { - return nil, err - } - if err := json.Unmarshal(file, &account); err != nil { - return nil, err - } - account.Init() - s.account = account - log.Infof("Loaded ACME config from store %s", s.file) return account, nil } -// Begin creates a transaction with the KV store. -func (s *LocalStore) Begin() (cluster.Transaction, cluster.Object, error) { - s.storageLock.Lock() - return &localTransaction{LocalStore: s}, s.account, nil -} - -var _ cluster.Transaction = (*localTransaction)(nil) - -type localTransaction struct { - *LocalStore - dirty bool -} - -// Commit allows to set an object in the file storage -func (t *localTransaction) Commit(object cluster.Object) error { - t.LocalStore.account = object.(*Account) - defer t.storageLock.Unlock() - if t.dirty { - return fmt.Errorf("transaction already used, please begin a new one") - } - - // write account to file - data, err := json.MarshalIndent(object, "", " ") +// ConvertToNewFormat converts old acme.json format to the new one and store the result into the file (used for the backward compatibility) +func ConvertToNewFormat(fileName string) { + localStore := acme.NewLocalStore(fileName) + storeAccount, err := localStore.GetAccount() if err != nil { - return err + log.Warnf("Failed to read new account, ACME data conversion is not available : %v", err) + return } - err = ioutil.WriteFile(t.file, data, 0600) - if err != nil { - return err + + if storeAccount == nil { + localStore := NewLocalStore(fileName) + + account, err := localStore.Get() + if err != nil { + log.Warnf("Failed to read old account, ACME data conversion is not available : %v", err) + return + } + + if account != nil { + newAccount := &acme.Account{ + PrivateKey: account.PrivateKey, + Registration: account.Registration, + Email: account.Email, + } + + var newCertificates []*acme.Certificate + for _, cert := range account.DomainsCertificate.Certs { + newCertificates = append(newCertificates, &acme.Certificate{ + Certificate: cert.Certificate.Certificate, + Key: cert.Certificate.PrivateKey, + Domain: cert.Domains, + }) + } + newLocalStore := acme.NewLocalStore(fileName) + newLocalStore.SaveDataChan <- &acme.StoredData{Account: newAccount, Certificates: newCertificates} + } } - t.dirty = true - return nil +} + +// FromNewToOldFormat converts new acme.json format to the old one (used for the backward compatibility) +func FromNewToOldFormat(fileName string) (*Account, error) { + localStore := acme.NewLocalStore(fileName) + + storeAccount, err := localStore.GetAccount() + if err != nil { + return nil, err + } + + storeCertificates, err := localStore.GetCertificates() + if err != nil { + return nil, err + } + + if storeAccount != nil { + account := &Account{} + account.Email = storeAccount.Email + account.PrivateKey = storeAccount.PrivateKey + account.Registration = storeAccount.Registration + account.DomainsCertificate = DomainsCertificates{} + + for _, cert := range storeCertificates { + _, err = account.DomainsCertificate.addCertificateForDomains(&Certificate{ + Domain: cert.Domain.Main, + Certificate: cert.Certificate, + PrivateKey: cert.Key, + }, cert.Domain) + if err != nil { + return nil, err + } + } + return account, nil + } + return nil, nil } diff --git a/acme/localStore_test.go b/acme/localStore_test.go index a2bfce742..00d5847b7 100644 --- a/acme/localStore_test.go +++ b/acme/localStore_test.go @@ -5,37 +5,27 @@ import ( "os" "path/filepath" "testing" + + "github.com/stretchr/testify/assert" ) -func TestLoad(t *testing.T) { +func TestGet(t *testing.T) { acmeFile := "./acme_example.json" folder, prefix := filepath.Split(acmeFile) tmpFile, err := ioutil.TempFile(folder, prefix) defer os.Remove(tmpFile.Name()) - if err != nil { - t.Error(err) - } + assert.NoError(t, err) fileContent, err := ioutil.ReadFile(acmeFile) - if err != nil { - t.Error(err) - } + assert.NoError(t, err) tmpFile.Write(fileContent) localStore := NewLocalStore(tmpFile.Name()) - obj, err := localStore.Load() - if err != nil { - t.Error(err) - } - account, ok := obj.(*Account) - if !ok { - t.Error("Object is not an ACME Account") - } + account, err := localStore.Get() + assert.NoError(t, err) - if len(account.DomainsCertificate.Certs) != 1 { - t.Errorf("Must found %d and found %d certificates in Account", 3, len(account.DomainsCertificate.Certs)) - } + assert.Len(t, account.DomainsCertificate.Certs, 1) } diff --git a/acme/localStore_unix.go b/acme/localStore_unix.go index e53592e5d..a0d7e1a13 100644 --- a/acme/localStore_unix.go +++ b/acme/localStore_unix.go @@ -7,19 +7,22 @@ import ( "os" ) -// Check file permissions -func checkPermissions(name string) error { +// Check file permissions and content size +func checkFile(name string) (bool, error) { f, err := os.Open(name) if err != nil { - return err + return false, err } defer f.Close() + fi, err := f.Stat() if err != nil { - return err + return false, err } + if fi.Mode().Perm()&0077 != 0 { - return fmt.Errorf("permissions %o for %s are too open, please use 600", fi.Mode().Perm(), name) + return false, fmt.Errorf("permissions %o for %s are too open, please use 600", fi.Mode().Perm(), name) } - return nil + + return fi.Size() > 0, nil } diff --git a/acme/localStore_windows.go b/acme/localStore_windows.go index 131896371..06df0cc5b 100644 --- a/acme/localStore_windows.go +++ b/acme/localStore_windows.go @@ -1,6 +1,20 @@ package acme +import "os" + +// Check file content size // Do not check file permissions on Windows right now -func checkPermissions(name string) error { - return nil +func checkFile(name string) (bool, error) { + f, err := os.Open(name) + if err != nil { + return false, err + } + defer f.Close() + + fi, err := f.Stat() + if err != nil { + return false, err + } + + return fi.Size() > 0, nil } diff --git a/anonymize/anonymize_config_test.go b/anonymize/anonymize_config_test.go index f44c88b26..837b8390f 100644 --- a/anonymize/anonymize_config_test.go +++ b/anonymize/anonymize_config_test.go @@ -9,6 +9,7 @@ import ( "github.com/containous/traefik/acme" "github.com/containous/traefik/configuration" "github.com/containous/traefik/provider" + acmeprovider "github.com/containous/traefik/provider/acme" "github.com/containous/traefik/provider/boltdb" "github.com/containous/traefik/provider/consul" "github.com/containous/traefik/provider/consulcatalog" @@ -155,7 +156,7 @@ func TestDo_globalConfiguration(t *testing.T) { } config.ACME = &acme.ACME{ Email: "acme Email", - Domains: []acme.Domain{ + Domains: []types.Domain{ { Main: "Domains Main", SANs: []string{"Domains acme SANs 1", "Domains acme SANs 2", "Domains acme SANs 3"}, @@ -167,7 +168,7 @@ func TestDo_globalConfiguration(t *testing.T) { OnHostRule: true, CAServer: "CAServer", EntryPoint: "EntryPoint", - DNSChallenge: &acme.DNSChallenge{Provider: "DNSProvider"}, + DNSChallenge: &acmeprovider.DNSChallenge{Provider: "DNSProvider"}, DelayDontCheckDNS: 666, ACMELogging: true, TLSConfig: &tls.Config{ diff --git a/cmd/storeconfig/storeconfig.go b/cmd/storeconfig/storeconfig.go index a5686fe86..1baeca92e 100644 --- a/cmd/storeconfig/storeconfig.go +++ b/cmd/storeconfig/storeconfig.go @@ -3,7 +3,9 @@ package storeconfig import ( "encoding/json" "fmt" + "io/ioutil" stdlog "log" + "os" "github.com/abronan/valkeyrie/store" "github.com/containous/flaeg" @@ -11,6 +13,7 @@ import ( "github.com/containous/traefik/acme" "github.com/containous/traefik/cluster" "github.com/containous/traefik/cmd" + "github.com/containous/traefik/log" ) // NewCmd builds a new StoreConfig command @@ -72,49 +75,78 @@ func Run(kv *staert.KvSource, traefikConfiguration *cmd.TraefikConfiguration) fu } if traefikConfiguration.GlobalConfiguration.ACME != nil { - var object cluster.Object if len(traefikConfiguration.GlobalConfiguration.ACME.StorageFile) > 0 { - // convert ACME json file to KV store - localStore := acme.NewLocalStore(traefikConfiguration.GlobalConfiguration.ACME.StorageFile) - object, err = localStore.Load() - if err != nil { - return err - } - } else { - // Create an empty account to create all the keys into the KV store - account := &acme.Account{} - err = account.Init() - if err != nil { - return err - } - - object = account - } - - meta := cluster.NewMetadata(object) - err = meta.Marshall() - if err != nil { - return err - } - - source := staert.KvSource{ - Store: kv, - Prefix: traefikConfiguration.GlobalConfiguration.ACME.Storage, - } - err = source.StoreConfig(meta) - if err != nil { - return err - } - // Force to delete storagefile - err = kv.Delete(kv.Prefix + "/acme/storagefile") - if err != nil { - return err + return migrateACMEData(traefikConfiguration.GlobalConfiguration.ACME.StorageFile, traefikConfiguration.GlobalConfiguration.ACME.Storage, kv) } } return nil } } +// migrateACMEData allows migrating data from acme.json file to KV store in function of the file format +func migrateACMEData(fileName, storageKey string, kv *staert.KvSource) error { + var object cluster.Object + + f, err := os.Open(fileName) + if err != nil { + return err + } + defer f.Close() + + file, err := ioutil.ReadAll(f) + if err != nil { + return err + } + + // Create an empty account to create all the keys into the KV store + account := &acme.Account{} + // Check if the storage file is not empty before to get data + if len(file) > 0 { + accountFromNewFormat, err := acme.FromNewToOldFormat(fileName) + if err != nil { + return err + } + + if accountFromNewFormat == nil { + // convert ACME json file to KV store (used for backward compatibility) + localStore := acme.NewLocalStore(fileName) + account, err = localStore.Get() + if err != nil { + return err + } + } else { + account = accountFromNewFormat + } + } else { + log.Warnf("No data will be imported from the storageFile %q because it is empty.", fileName) + } + + err = account.Init() + if err != nil { + return err + } + + object = account + meta := cluster.NewMetadata(object) + err = meta.Marshall() + if err != nil { + return err + } + + source := staert.KvSource{ + Store: kv, + Prefix: storageKey, + } + + err = source.StoreConfig(meta) + if err != nil { + return err + } + + // Force to delete storagefile + return kv.Delete(kv.Prefix + "/acme/storagefile") +} + // CreateKvSource creates KvSource // TLS support is enable for Consul and Etcd backends func CreateKvSource(traefikConfiguration *cmd.TraefikConfiguration) (*staert.KvSource, error) { diff --git a/cmd/traefik/traefik.go b/cmd/traefik/traefik.go index e19d8d8b2..ce783d10a 100644 --- a/cmd/traefik/traefik.go +++ b/cmd/traefik/traefik.go @@ -13,7 +13,6 @@ import ( "github.com/cenk/backoff" "github.com/containous/flaeg" "github.com/containous/staert" - "github.com/containous/traefik/acme" "github.com/containous/traefik/cmd" "github.com/containous/traefik/cmd/bug" "github.com/containous/traefik/cmd/healthcheck" @@ -23,6 +22,7 @@ import ( "github.com/containous/traefik/configuration" "github.com/containous/traefik/job" "github.com/containous/traefik/log" + "github.com/containous/traefik/provider/acme" "github.com/containous/traefik/provider/ecs" "github.com/containous/traefik/provider/kubernetes" "github.com/containous/traefik/safe" @@ -66,7 +66,7 @@ Complete documentation is available at https://traefik.io`, f.AddParser(reflect.TypeOf(types.Constraints{}), &types.Constraints{}) f.AddParser(reflect.TypeOf(kubernetes.Namespaces{}), &kubernetes.Namespaces{}) f.AddParser(reflect.TypeOf(ecs.Clusters{}), &ecs.Clusters{}) - f.AddParser(reflect.TypeOf([]acme.Domain{}), &acme.Domains{}) + f.AddParser(reflect.TypeOf([]types.Domain{}), &types.Domains{}) f.AddParser(reflect.TypeOf(types.Buckets{}), &types.Buckets{}) // add commands @@ -164,7 +164,15 @@ func runCmd(globalConfiguration *configuration.GlobalConfiguration, configFile s stats(globalConfiguration) log.Debugf("Global configuration loaded %s", string(jsonConf)) + if acme.IsEnabled() { + store := acme.NewLocalStore(acme.Get().Storage) + acme.Get().Store = &store + } svr := server.NewServer(*globalConfiguration, configuration.NewProviderAggregator(globalConfiguration)) + if acme.IsEnabled() && acme.Get().OnHostRule { + acme.Get().SetConfigListenerChan(make(chan types.Configuration)) + svr.AddListener(acme.Get().ListenConfiguration) + } svr.Start() defer svr.Close() diff --git a/configuration/configuration.go b/configuration/configuration.go index 705719a23..eaeaf94bb 100644 --- a/configuration/configuration.go +++ b/configuration/configuration.go @@ -12,6 +12,7 @@ import ( "github.com/containous/traefik/log" "github.com/containous/traefik/middlewares/tracing" "github.com/containous/traefik/ping" + acmeprovider "github.com/containous/traefik/provider/acme" "github.com/containous/traefik/provider/boltdb" "github.com/containous/traefik/provider/consul" "github.com/containous/traefik/provider/consulcatalog" @@ -244,6 +245,10 @@ func (gc *GlobalConfiguration) SetEffectiveConfiguration(configFile string) { } } + gc.initACMEProvider() +} + +func (gc *GlobalConfiguration) initACMEProvider() { if gc.ACME != nil { // TODO: to remove in the futurs if len(gc.ACME.StorageFile) > 0 && len(gc.ACME.Storage) == 0 { @@ -253,12 +258,30 @@ func (gc *GlobalConfiguration) SetEffectiveConfiguration(configFile string) { if len(gc.ACME.DNSProvider) > 0 { log.Warn("ACME.DNSProvider is deprecated, use ACME.DNSChallenge instead") - gc.ACME.DNSChallenge = &acme.DNSChallenge{Provider: gc.ACME.DNSProvider, DelayBeforeCheck: gc.ACME.DelayDontCheckDNS} + gc.ACME.DNSChallenge = &acmeprovider.DNSChallenge{Provider: gc.ACME.DNSProvider, DelayBeforeCheck: gc.ACME.DelayDontCheckDNS} } if gc.ACME.OnDemand { log.Warn("ACME.OnDemand is deprecated") } + + // TODO: Remove when Provider ACME will replace totally ACME + // If provider file, use Provider ACME instead of ACME + if gc.Cluster == nil { + acmeprovider.Get().Configuration = &acmeprovider.Configuration{ + OnHostRule: gc.ACME.OnHostRule, + OnDemand: gc.ACME.OnDemand, + Email: gc.ACME.Email, + Storage: gc.ACME.Storage, + HTTPChallenge: gc.ACME.HTTPChallenge, + DNSChallenge: gc.ACME.DNSChallenge, + Domains: gc.ACME.Domains, + ACMELogging: gc.ACME.ACMELogging, + CAServer: gc.ACME.CAServer, + EntryPoint: gc.ACME.EntryPoint, + } + gc.ACME = nil + } } } @@ -272,6 +295,14 @@ func (gc *GlobalConfiguration) ValidateConfiguration() { log.Fatalf("Entrypoint without TLS %q for ACME configuration", gc.ACME.EntryPoint) } } + } else if acmeprovider.IsEnabled() { + if _, ok := gc.EntryPoints[acmeprovider.Get().EntryPoint]; !ok { + log.Fatalf("Unknown entrypoint %q for provider ACME configuration", gc.ACME.EntryPoint) + } else { + if gc.EntryPoints[acmeprovider.Get().EntryPoint].TLS == nil { + log.Fatalf("Entrypoint without TLS %q for provider ACME configuration", gc.ACME.EntryPoint) + } + } } } diff --git a/configuration/provider_aggregator.go b/configuration/provider_aggregator.go index 198d2f502..48377fb08 100644 --- a/configuration/provider_aggregator.go +++ b/configuration/provider_aggregator.go @@ -4,8 +4,10 @@ import ( "encoding/json" "reflect" + "github.com/containous/traefik/acme" "github.com/containous/traefik/log" "github.com/containous/traefik/provider" + acmeprovider "github.com/containous/traefik/provider/acme" "github.com/containous/traefik/safe" "github.com/containous/traefik/types" ) @@ -65,6 +67,10 @@ func NewProviderAggregator(gc *GlobalConfiguration) provider.Provider { if gc.ServiceFabric != nil { provider.providers = append(provider.providers, gc.ServiceFabric) } + if acmeprovider.IsEnabled() { + provider.providers = append(provider.providers, acmeprovider.Get()) + acme.ConvertToNewFormat(acmeprovider.Get().Storage) + } if len(provider.providers) == 1 { return provider.providers[0] } diff --git a/contrib/scripts/dumpcerts.sh b/contrib/scripts/dumpcerts.sh index 700c5cbad..e763b63fc 100755 --- a/contrib/scripts/dumpcerts.sh +++ b/contrib/scripts/dumpcerts.sh @@ -66,7 +66,7 @@ ${USAGE}" >&2 bad_acme() { echo " -There was a problem parsing your acme.json file. +There was a problem parsing your acme.json file. $1 ${USAGE}" >&2 exit 2 @@ -104,7 +104,7 @@ fi jq=$(command -v jq) || exit_jq -priv=$(${jq} -e -r '.PrivateKey' "${acmefile}") || bad_acme +priv=$(${jq} -e -r '.Account.PrivateKey' "${acmefile}") || bad_acme if [ ! -n "${priv}" ]; then echo " @@ -155,16 +155,16 @@ echo -e "-----BEGIN RSA PRIVATE KEY-----\n${priv}\n-----END RSA PRIVATE KEY----- | openssl rsa -inform pem -out "${pdir}/letsencrypt.key" # Process the certificates for each of the domains in acme.json -for domain in $(jq -r '.DomainsCertificate.Certs[].Certificate.Domain' ${acmefile}); do +for domain in $(jq -r '.Certificates[].Domain.Main' ${acmefile}); do # Traefik stores a cert bundle for each domain. Within this cert # bundle there is both proper the certificate and the Let's Encrypt CA echo "Extracting cert bundle for ${domain}" - cert=$(jq -e -r --arg domain "$domain" '.DomainsCertificate.Certs[].Certificate | - select (.Domain == $domain )| .Certificate' ${acmefile}) || bad_acme + cert=$(jq -e -r --arg domain "$domain" '.Certificates[] | + select (.Domain.Main == $domain )| .Certificate' ${acmefile}) || bad_acme echo "${cert}" | ${CMD_DECODE_BASE64} > "${cdir}/${domain}.crt" echo "Extracting private key for ${domain}" - key=$(jq -e -r --arg domain "$domain" '.DomainsCertificate.Certs[].Certificate | - select (.Domain == $domain )| .PrivateKey' ${acmefile}) || bad_acme + key=$(jq -e -r --arg domain "$domain" '.Certificates[] | + select (.Domain.Main == $domain )| .Key' ${acmefile}) || bad_acme echo "${key}" | ${CMD_DECODE_BASE64} > "${pdir}/${domain}.key" done diff --git a/examples/acme/Docker_Acme.md b/examples/acme/Docker_Acme.md index f8c3e2b14..243a25256 100644 --- a/examples/acme/Docker_Acme.md +++ b/examples/acme/Docker_Acme.md @@ -11,7 +11,7 @@ The provided Boulder stack is based on the environment used during integration t ## Directory content -* **compose-acme.yml** : Docker-Compose file which contains the description of Traefik and all the boulder stack containers to get, +* **docker-compose.yml** : Docker-Compose file which contains the description of Traefik and all the boulder stack containers to get, * **acme.toml** : Traefik configuration file used by the Traefik container described above, * **manage_acme_docker_environment.sh** Shell script which does all needed checks and manages the docker-compose environment. @@ -25,6 +25,7 @@ To work fine, boulder needs a domain name, with a related IP and storage file. T The script **manage_acme_docker_environment.sh** requires one argument. This argument can have 3 values : -* **--start** : Check environment and launch a new Docker environment. +* **--start** : Launch a new Docker environment Boulder + Traefik. * **--stop** : Stop and delete the current Docker environment. -* **--restart--** : Concatenate **--stop** and **--start** actions. \ No newline at end of file +* **--restart--** : Concatenate **--stop** and **--start** actions. +* **--dev** : Launch a new Boulder Docker environment. \ No newline at end of file diff --git a/examples/acme/acme.toml b/examples/acme/acme.toml index 151ffb54c..fef0c9d7f 100644 --- a/examples/acme/acme.toml +++ b/examples/acme/acme.toml @@ -18,7 +18,7 @@ storage = "/etc/traefik/conf/acme.json" entryPoint = "https" onDemand = false OnHostRule = true -caServer = "http://traefik.localhost.com:4000/directory" +caServer = "http://traefik.boulder.com:4000/directory" [acme.httpChallenge] entryPoint="http" diff --git a/examples/acme/compose-acme.yml b/examples/acme/compose-acme.yml deleted file mode 100644 index 174da6227..000000000 --- a/examples/acme/compose-acme.yml +++ /dev/null @@ -1,92 +0,0 @@ -version: "2" - -# IP_HOST : Docker host IP (not 127.0.0.1) - -services : - boulder: - image: containous/boulder:release - environment: - FAKE_DNS: $IP_HOST - PKCS11_PROXY_SOCKET: tcp://boulder-hsm:5657 - extra_hosts: - - le.wtf:127.0.0.1 - - boulder:127.0.0.1 - ports: - - 4000:4000 # ACME - - 4002:4002 # OCSP - - 4003:4003 # OCSP - - 4500:4500 # ct-test-srv - - 8000:8000 # debug ports - - 8001:8001 - - 8002:8002 - - 8003:8003 - - 8004:8004 - - 8055:8055 # dns-test-srv updates - - 9380:9380 # mail-test-srv - - 9381:9381 # mail-test-srv - restart: unless-stopped - depends_on: - - bhsm - - bmysql - - brabbitmq - volumes: - - "./rate-limit-policies.yml:/go/src/github.com/letsencrypt/boulder/test/rate-limit-policies.yml:ro" - - bhsm: - image: letsencrypt/boulder-tools:2016-11-02 - hostname: boulder-hsm - networks: - default: - aliases: - - boulder-hsm - environment: - PKCS11_DAEMON_SOCKET: tcp://0.0.0.0:5657 - command: /usr/local/bin/pkcs11-daemon /usr/lib/softhsm/libsofthsm.so - expose: - - 5657 - bmysql: - image: mariadb:10.1 - hostname: boulder-mysql - networks: - default: - aliases: - - boulder-mysql - environment: - MYSQL_ALLOW_EMPTY_PASSWORD: "yes" - - brabbitmq: - image: rabbitmq:3-alpine - hostname: boulder-rabbitmq - networks: - default: - aliases: - - boulder-rabbitmq - environment: - RABBITMQ_NODE_IP_ADDRESS: "0.0.0.0" - - traefik: - build: - context: ../.. - image: containous/traefik:latest - command: --configFile=/etc/traefik/conf/acme.toml - restart: unless-stopped - extra_hosts: - - traefik.localhost.com:$IP_HOST - volumes: - - "./acme.toml:/etc/traefik/conf/acme.toml:ro" - - "/var/run/docker.sock:/var/run/docker.sock:ro" - - "./acme.json:/etc/traefik/conf/acme.json:rw" - ports: - - "80:80" - - "443:443" - - "5001:443" # Needed for SNI challenge - - "5002:80" # Needed for HTTP challenge - expose: - - "8080" - labels: - - "traefik.port=8080" - - "traefik.backend=traefikception" - - "traefik.frontend.rule=Host:traefik.localhost.com" - - "traefik.enable=true" - depends_on: - - boulder \ No newline at end of file diff --git a/examples/acme/docker-compose.yml b/examples/acme/docker-compose.yml new file mode 100644 index 000000000..096524aff --- /dev/null +++ b/examples/acme/docker-compose.yml @@ -0,0 +1,94 @@ +version: "2" + +services : + + boulder: + image: containous/boulder:containous-fork + environment: + FAKE_DNS: 172.17.0.1 + PKCS11_PROXY_SOCKET: tcp://boulder-hsm:5657 + extra_hosts: + - le.wtf:127.0.0.1 + - boulder:127.0.0.1 + ports: + - 4000:4000 # ACME + - 4002:4002 # OCSP + - 4003:4003 # OCSP + - 4500:4500 # ct-test-srv + - 8000:8000 # debug ports + - 8001:8001 + - 8002:8002 + - 8003:8003 + - 8004:8004 + - 8055:8055 # dns-test-srv updates + - 9380:9380 # mail-test-srv + - 9381:9381 # mail-test-srv + restart: unless-stopped + depends_on: + - bhsm + - bmysql + - brabbitmq + networks: + - default + + bhsm: + image: letsencrypt/boulder-tools:2016-11-02 + hostname: boulder-hsm + environment: + PKCS11_DAEMON_SOCKET: tcp://0.0.0.0:5657 + command: /usr/local/bin/pkcs11-daemon /usr/lib/softhsm/libsofthsm.so + expose: + - 5657 + networks: + default: + aliases: + - boulder-hsm + + bmysql: + image: mariadb:10.1 + hostname: boulder-mysql + environment: + MYSQL_ALLOW_EMPTY_PASSWORD: "yes" + networks: + default: + aliases: + - boulder-mysql + + brabbitmq: + image: rabbitmq:3-alpine + hostname: boulder-rabbitmq + environment: + RABBITMQ_NODE_IP_ADDRESS: "0.0.0.0" + networks: + default: + aliases: + - boulder-rabbitmq + + ## TRAEFIK part ## + + traefik: + build: + context: ../.. + image: containous/traefik:latest + command: --configFile=/etc/traefik/conf/acme.toml + restart: unless-stopped + extra_hosts: + - traefik.boulder.com:172.17.0.1 + volumes: + - "./acme.toml:/etc/traefik/conf/acme.toml:ro" + - "/var/run/docker.sock:/var/run/docker.sock:ro" + - "./acme.json:/etc/traefik/conf/acme.json:rw" + ports: + - "80:80" + - "443:443" + - "5001:443" # Needed for SNI challenge + - "5002:80" # Needed for HTTP challenge + expose: + - "8080" + labels: + - "traefik.port=8080" + - "traefik.backend=traefikception" + - "traefik.frontend.rule=Host:traefik.localhost.com" + - "traefik.enable=true" + depends_on: + - boulder \ No newline at end of file diff --git a/examples/acme/manage_acme_docker_environment.sh b/examples/acme/manage_acme_docker_environment.sh index 2ed97843c..8e21da667 100755 --- a/examples/acme/manage_acme_docker_environment.sh +++ b/examples/acme/manage_acme_docker_environment.sh @@ -3,7 +3,7 @@ # Initialize variables readonly traefik_url="traefik.localhost.com" readonly basedir=$(dirname $0) -readonly doc_file=$basedir"/compose-acme.yml" +readonly doc_file=$basedir"/docker-compose.yml" # Stop and remove Docker environment down_environment() { @@ -22,21 +22,6 @@ up_environment() { # Init the environment : get IP address and create needed files init_environment() { - for netw in $(ip addr show | grep -v "LOOPBACK" | grep -v docker | grep -oE "^[0-9]{1}: .*:" | cut -d ':' -f2); do - ip_addr=$(ip addr show $netw | grep -E "inet " | grep -Eo "[0-9]*\.[0-9]*\.[0-9]*\.[0-9]*" | head -n 1) - [[ ! -z $ip_addr ]] && break - done - - [[ -z $ip_addr ]] && \ - echo "[ERROR] Impossible to find an IP address for the Docker host" && exit 31 - - # The $traefik_url entry must exist into /etc/hosts file - # It has to refer to the $ip_addr IP address - [[ $(cat /etc/hosts | grep $traefik_url | grep -vE "^#" | grep -oE "([0-9]+(\.)?){4}") != $ip_addr ]] && \ - echo "[ERROR] Domain ${traefik_url} has to refer to ${ip_addr} into /etc/hosts file." && exit 32 - # Export IP_HOST to use it in the DOcker COmpose file - export IP_HOST=$ip_addr - echo "CREATE empty acme.json file" rm -f $basedir/acme.json && \ touch $basedir/acme.json && \ @@ -44,14 +29,14 @@ init_environment() { } # Start all the environement -start() { +start_boulder() { init_environment echo "Start boulder environment" up_environment bmysql brabbitmq bhsm boulder waiting_counter=12 # Not start Traefik if boulder is not started echo "WAIT for boulder..." - while [[ -z $(curl -s http://$traefik_url:4000/directory) ]]; do + while [[ -z $(curl -s http://127.0.0.1:4000/directory) ]]; do sleep 5 let waiting_counter-=1 if [[ $waiting_counter -eq 0 ]]; then @@ -60,8 +45,6 @@ start() { exit 41 fi done - echo "START Traefik container" - up_environment traefik } # Script usage @@ -78,9 +61,14 @@ main() { [[ $# -ne 1 ]] && show_usage && exit 1 case $1 in + "--dev") + start_boulder + ;; "--start") # Start boulder environment - start + start_boulder + echo "START Traefik container" + up_environment traefik echo "ENVIRONMENT SUCCESSFULLY STARTED" ;; "--stop") @@ -89,8 +77,10 @@ main() { ;; "--restart") down_environment - start - echo "ENVIRONMENT SUCCESSFULLY STARTED" + start_boulder + echo "START Traefik container" + up_environment traefik + echo "ENVIRONMENT SUCCESSFULLY RESTARTED" ;; *) show_usage && exit 2 diff --git a/examples/acme/rate-limit-policies.yml b/examples/acme/rate-limit-policies.yml deleted file mode 100644 index 80431a773..000000000 --- a/examples/acme/rate-limit-policies.yml +++ /dev/null @@ -1,42 +0,0 @@ -totalCertificates: - window: 1h - threshold: 100000 -certificatesPerName: - window: 1h - threshold: 100000 - overrides: - ratelimit.me: 1 - lim.it: 0 - # Hostnames used by the letsencrypt client integration test. - le.wtf: 10000 - le1.wtf: 10000 - le2.wtf: 10000 - le3.wtf: 10000 - nginx.wtf: 10000 - good-caa-reserved.com: 10000 - bad-caa-reserved.com: 10000 - ecdsa.le.wtf: 10000 - must-staple.le.wtf: 10000 - registrationOverrides: - 101: 1000 -registrationsPerIP: - window: 1h - threshold: 100000 - overrides: - 127.0.0.1: 1000000 -pendingAuthorizationsPerAccount: - window: 1h - threshold: 100000 -certificatesPerFQDNSet: - window: 1h - threshold: 100000 - overrides: - le.wtf: 10000 - le1.wtf: 10000 - le2.wtf: 10000 - le3.wtf: 10000 - le.wtf,le1.wtf: 10000 - good-caa-reserved.com: 10000 - nginx.wtf: 10000 - ecdsa.le.wtf: 10000 - must-staple.le.wtf: 10000 diff --git a/examples/cluster/docker-compose.yml b/examples/cluster/docker-compose.yml index 5a6d53718..ad76ccc3b 100644 --- a/examples/cluster/docker-compose.yml +++ b/examples/cluster/docker-compose.yml @@ -48,7 +48,7 @@ services: ## BOULDER part ## boulder: - image: containous/boulder:release + image: containous/boulder:containous-fork environment: FAKE_DNS: 172.17.0.1 PKCS11_PROXY_SOCKET: tcp://boulder-hsm:5657 @@ -73,8 +73,6 @@ services: - bhsm - bmysql - brabbitmq - volumes: - - "./rate-limit-policies.yml:/go/src/github.com/letsencrypt/boulder/test/rate-limit-policies.yml:ro" networks: net: ipv4_address: 10.0.1.3 diff --git a/examples/cluster/rate-limit-policies.yml b/examples/cluster/rate-limit-policies.yml deleted file mode 100644 index 80431a773..000000000 --- a/examples/cluster/rate-limit-policies.yml +++ /dev/null @@ -1,42 +0,0 @@ -totalCertificates: - window: 1h - threshold: 100000 -certificatesPerName: - window: 1h - threshold: 100000 - overrides: - ratelimit.me: 1 - lim.it: 0 - # Hostnames used by the letsencrypt client integration test. - le.wtf: 10000 - le1.wtf: 10000 - le2.wtf: 10000 - le3.wtf: 10000 - nginx.wtf: 10000 - good-caa-reserved.com: 10000 - bad-caa-reserved.com: 10000 - ecdsa.le.wtf: 10000 - must-staple.le.wtf: 10000 - registrationOverrides: - 101: 1000 -registrationsPerIP: - window: 1h - threshold: 100000 - overrides: - 127.0.0.1: 1000000 -pendingAuthorizationsPerAccount: - window: 1h - threshold: 100000 -certificatesPerFQDNSet: - window: 1h - threshold: 100000 - overrides: - le.wtf: 10000 - le1.wtf: 10000 - le2.wtf: 10000 - le3.wtf: 10000 - le.wtf,le1.wtf: 10000 - good-caa-reserved.com: 10000 - nginx.wtf: 10000 - ecdsa.le.wtf: 10000 - must-staple.le.wtf: 10000 diff --git a/integration/acme_test.go b/integration/acme_test.go index 59fac40a1..2f7f3f96b 100644 --- a/integration/acme_test.go +++ b/integration/acme_test.go @@ -52,20 +52,30 @@ func (s *AcmeSuite) TearDownSuite(c *check.C) { } } -// Test OnDemand option with none provided certificate -func (s *AcmeSuite) TestOnDemandRetrieveAcmeCertificate(c *check.C) { +// Test ACME provider with certificate at start +func (s *AcmeSuite) TestACMEProviderAtStart(c *check.C) { testCase := AcmeTestCase{ - traefikConfFilePath: "fixtures/acme/acme.toml", - onDemand: true, + traefikConfFilePath: "fixtures/provideracme/acme.toml", + onDemand: false, domainToCheck: acmeDomain} s.retrieveAcmeCertificate(c, testCase) } -// Test OnHostRule option with none provided certificate -func (s *AcmeSuite) TestOnHostRuleRetrieveAcmeCertificate(c *check.C) { +// Test ACME provider with certificate at start +func (s *AcmeSuite) TestACMEProviderAtStartInSAN(c *check.C) { testCase := AcmeTestCase{ - traefikConfFilePath: "fixtures/acme/acme.toml", + traefikConfFilePath: "fixtures/provideracme/acme_insan.toml", + onDemand: false, + domainToCheck: "acme.wtf"} + + s.retrieveAcmeCertificate(c, testCase) +} + +// Test ACME provider with certificate at start +func (s *AcmeSuite) TestACMEProviderOnHost(c *check.C) { + testCase := AcmeTestCase{ + traefikConfFilePath: "fixtures/provideracme/acme_onhost.toml", onDemand: false, domainToCheck: acmeDomain} @@ -216,7 +226,7 @@ func (s *AcmeSuite) retrieveAcmeCertificate(c *check.C, testCase AcmeTestCase) { cn := resp.TLS.PeerCertificates[0].Subject.CommonName if cn != testCase.domainToCheck { - return fmt.Errorf("domain %s found in place of %s", cn, testCase.domainToCheck) + return fmt.Errorf("domain %s found instead of %s", cn, testCase.domainToCheck) } return nil diff --git a/integration/fixtures/access_log_config.toml b/integration/fixtures/access_log_config.toml index 93947f0b8..64a4b3db9 100644 --- a/integration/fixtures/access_log_config.toml +++ b/integration/fixtures/access_log_config.toml @@ -34,7 +34,6 @@ checkNewVersion = false users = ["test:traefik:a2688e031edb4be6a3797f3882655c05", "test2:traefik:518845800f9e2bfb1f1f740ec24f074e"] [api] - dashboard = true [docker] exposedByDefault = false diff --git a/integration/fixtures/acme/acme_http01_web.toml b/integration/fixtures/acme/acme_http01_web.toml index 346af1e63..c0633a2e2 100644 --- a/integration/fixtures/acme/acme_http01_web.toml +++ b/integration/fixtures/acme/acme_http01_web.toml @@ -9,10 +9,6 @@ defaultEntryPoints = ["http", "https"] address = ":5001" [entryPoints.https.tls] - -[web] -path="/traefik" - [acme] email = "test@traefik.io" storage = "/dev/null" @@ -23,6 +19,9 @@ caServer = "http://{{.BoulderHost}}:4000/directory" [acme.httpchallenge] entrypoint="http" +[web] +path="/traefik" + [file] [backends] @@ -30,9 +29,8 @@ entrypoint="http" [backends.backend.servers.server1] url = "http://127.0.0.1:9010" - [frontends] [frontends.frontend] backend = "backend" [frontends.frontend.routes.test] - rule = "Host:traefik.acme.wtf" + rule = "Host:traefik.acme.wtf" \ No newline at end of file diff --git a/integration/fixtures/acme/acme_provided.toml b/integration/fixtures/acme/acme_provided.toml index a478722b0..81dd2bc52 100644 --- a/integration/fixtures/acme/acme_provided.toml +++ b/integration/fixtures/acme/acme_provided.toml @@ -4,7 +4,7 @@ defaultEntryPoints = ["http", "https"] [entryPoints] [entryPoints.http] - address = ":8080" + address = ":5002" [entryPoints.https] address = ":5001" [entryPoints.https.tls] @@ -19,6 +19,8 @@ entryPoint = "https" onDemand = {{.OnDemand}} OnHostRule = {{.OnHostRule}} caServer = "http://{{.BoulderHost}}:4000/directory" +[acme.httpChallenge] +entryPoint="http" [file] diff --git a/integration/fixtures/acme/acme_provided_dynamic.toml b/integration/fixtures/acme/acme_provided_dynamic.toml index a849b7a92..cf2e15642 100644 --- a/integration/fixtures/acme/acme_provided_dynamic.toml +++ b/integration/fixtures/acme/acme_provided_dynamic.toml @@ -4,7 +4,7 @@ defaultEntryPoints = ["http", "https"] [entryPoints] [entryPoints.http] - address = ":8080" + address = ":5002" [entryPoints.https] address = ":5001" [entryPoints.https.tls] @@ -17,6 +17,8 @@ entryPoint = "https" onDemand = {{.OnDemand}} OnHostRule = {{.OnHostRule}} caServer = "http://{{.BoulderHost}}:4000/directory" +[acme.httpChallenge] +entryPoint="http" [file] filename = "fixtures/acme/certificates.toml" diff --git a/integration/fixtures/acme/acme.toml b/integration/fixtures/provideracme/acme.toml similarity index 79% rename from integration/fixtures/acme/acme.toml rename to integration/fixtures/provideracme/acme.toml index 2c15e8636..c8d5097b2 100644 --- a/integration/fixtures/acme/acme.toml +++ b/integration/fixtures/provideracme/acme.toml @@ -4,7 +4,7 @@ defaultEntryPoints = ["http", "https"] [entryPoints] [entryPoints.http] - address = ":8080" + address = ":5002" [entryPoints.https] address = ":5001" [entryPoints.https.tls] @@ -17,6 +17,13 @@ entryPoint = "https" onDemand = {{.OnDemand}} OnHostRule = {{.OnHostRule}} caServer = "http://{{.BoulderHost}}:4000/directory" +[acme.httpChallenge] +entryPoint="http" +[[acme.domains]] +main = "traefik.acme.wtf" + + +[api] [file] @@ -25,9 +32,8 @@ caServer = "http://{{.BoulderHost}}:4000/directory" [backends.backend.servers.server1] url = "http://127.0.0.1:9010" - [frontends] [frontends.frontend] backend = "backend" [frontends.frontend.routes.test] - rule = "Host:traefik.acme.wtf" + rule = "Host:traefik.acme.wtf" \ No newline at end of file diff --git a/integration/fixtures/provideracme/acme_insan.toml b/integration/fixtures/provideracme/acme_insan.toml new file mode 100644 index 000000000..32c8cfdcd --- /dev/null +++ b/integration/fixtures/provideracme/acme_insan.toml @@ -0,0 +1,40 @@ +logLevel = "DEBUG" + +defaultEntryPoints = ["http", "https"] + +[entryPoints] + [entryPoints.http] + address = ":5002" + [entryPoints.https] + address = ":5001" + [entryPoints.https.tls] + + +[acme] +email = "test@traefik.io" +storage = "/dev/null" +entryPoint = "https" +onDemand = false +OnHostRule = false +caServer = "http://{{.BoulderHost}}:4000/directory" +[acme.httpChallenge] +entryPoint="http" +[[acme.domains]] +main = "acme.wtf" +sans = [ "traefik.acme.wtf" ] + + +[api] + +[file] + +[backends] + [backends.backend] + [backends.backend.servers.server1] + url = "http://127.0.0.1:9010" + +[frontends] + [frontends.frontend] + backend = "backend" + [frontends.frontend.routes.test] + rule = "Host:traefik.acme.wtf" \ No newline at end of file diff --git a/integration/fixtures/provideracme/acme_onhost.toml b/integration/fixtures/provideracme/acme_onhost.toml new file mode 100644 index 000000000..ce33e1afa --- /dev/null +++ b/integration/fixtures/provideracme/acme_onhost.toml @@ -0,0 +1,36 @@ +logLevel = "DEBUG" + +defaultEntryPoints = ["http", "https"] + +[entryPoints] + [entryPoints.http] + address = ":5002" + [entryPoints.https] + address = ":5001" + [entryPoints.https.tls] + + +[acme] +email = "test@traefik.io" +storage = "/dev/null" +entryPoint = "https" +onDemand = {{.OnDemand}} +OnHostRule = {{.OnHostRule}} +caServer = "http://{{.BoulderHost}}:4000/directory" +[acme.httpChallenge] +entryPoint="http" + +[api] + +[file] + +[backends] + [backends.backend] + [backends.backend.servers.server1] + url = "http://127.0.0.1:9010" + +[frontends] + [frontends.frontend] + backend = "backend" + [frontends.frontend.routes.test] + rule = "Host:traefik.acme.wtf" \ No newline at end of file diff --git a/integration/https_test.go b/integration/https_test.go index 533b16fc2..a5fbb2707 100644 --- a/integration/https_test.go +++ b/integration/https_test.go @@ -604,7 +604,7 @@ func (s *HTTPSSuite) TestWithSNIDynamicConfigRouteWithTlsConfigurationDeletion(c cn := resp.TLS.PeerCertificates[0].Subject.CommonName if cn == tr2.TLSClientConfig.ServerName { - return fmt.Errorf("domain %s found in place of default one", tr2.TLSClientConfig.ServerName) + return fmt.Errorf("domain %s found instead of the default one", tr2.TLSClientConfig.ServerName) } return nil diff --git a/integration/resources/compose/boulder.yml b/integration/resources/compose/boulder.yml index f1f954377..cb77823ab 100644 --- a/integration/resources/compose/boulder.yml +++ b/integration/resources/compose/boulder.yml @@ -1,5 +1,5 @@ boulder: - image: containous/boulder:release + image: containous/boulder:containous-fork environment: FAKE_DNS: ${DOCKER_HOST_IP} PKCS11_PROXY_SOCKET: tcp://boulder-hsm:5657 diff --git a/provider/acme/account.go b/provider/acme/account.go new file mode 100644 index 000000000..7f19642dc --- /dev/null +++ b/provider/acme/account.go @@ -0,0 +1,52 @@ +package acme + +import ( + "crypto" + "crypto/rand" + "crypto/rsa" + "crypto/x509" + + "github.com/containous/traefik/log" + "github.com/xenolf/lego/acme" +) + +// Account is used to store lets encrypt registration info +type Account struct { + Email string + Registration *acme.RegistrationResource + PrivateKey []byte +} + +// NewAccount creates an account +func NewAccount(email string) (*Account, error) { + // Create a user. New accounts need an email and private key to start + privateKey, err := rsa.GenerateKey(rand.Reader, 4096) + if err != nil { + return nil, err + } + + return &Account{ + Email: email, + PrivateKey: x509.MarshalPKCS1PrivateKey(privateKey), + }, nil +} + +// GetEmail returns email +func (a *Account) GetEmail() string { + return a.Email +} + +// GetRegistration returns lets encrypt registration resource +func (a *Account) GetRegistration() *acme.RegistrationResource { + return a.Registration +} + +// GetPrivateKey returns private key +func (a *Account) GetPrivateKey() crypto.PrivateKey { + if privateKey, err := x509.ParsePKCS1PrivateKey(a.PrivateKey); err == nil { + return privateKey + } + + log.Errorf("Cannot unmarshal private key %+v", a.PrivateKey) + return nil +} diff --git a/provider/acme/challenge.go b/provider/acme/challenge.go new file mode 100644 index 000000000..91a526d22 --- /dev/null +++ b/provider/acme/challenge.go @@ -0,0 +1,129 @@ +package acme + +import ( + "crypto" + "crypto/rand" + "crypto/rsa" + "crypto/sha256" + "encoding/hex" + "fmt" + "time" + + "github.com/cenk/backoff" + "github.com/containous/flaeg" + "github.com/containous/traefik/log" + "github.com/containous/traefik/safe" + tlsgenerate "github.com/containous/traefik/tls/generate" + "github.com/xenolf/lego/acme" +) + +func dnsOverrideDelay(delay flaeg.Duration) error { + if delay == 0 { + return nil + } + + if delay > 0 { + log.Debugf("Delaying %d rather than validating DNS propagation now.", delay) + + acme.PreCheckDNS = func(_, _ string) (bool, error) { + time.Sleep(time.Duration(delay)) + return true, nil + } + } else { + return fmt.Errorf("delayBeforeCheck: %d cannot be less than 0", delay) + } + return nil +} + +func presentTLSChallenge(domain, keyAuth string) ([]byte, []byte, error) { + log.Debugf("TLS Challenge Present temp certificate for %s", domain) + + var tempPrivKey crypto.PrivateKey + tempPrivKey, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + return nil, nil, err + } + + rsaPrivKey := tempPrivKey.(*rsa.PrivateKey) + rsaPrivPEM := tlsgenerate.PemEncode(rsaPrivKey) + + zBytes := sha256.Sum256([]byte(keyAuth)) + z := hex.EncodeToString(zBytes[:sha256.Size]) + domainCert := fmt.Sprintf("%s.%s.acme.invalid", z[:32], z[32:]) + + tempCertPEM, err := tlsgenerate.PemCert(rsaPrivKey, domainCert, time.Time{}) + if err != nil { + return nil, nil, err + } + + return tempCertPEM, rsaPrivPEM, nil +} + +func getTokenValue(token, domain string, store Store) []byte { + log.Debugf("Looking for an existing ACME challenge for token %v...", token) + var result []byte + + operation := func() error { + var ok bool + httpChallenges, err := store.GetHTTPChallenges() + if err != nil { + return fmt.Errorf("HTTPChallenges not available : %s", err) + } + if result, ok = httpChallenges[token][domain]; !ok { + return fmt.Errorf("cannot find challenge for token %v", token) + } + return nil + } + + notify := func(err error, time time.Duration) { + log.Errorf("Error getting challenge for token retrying in %s", time) + } + + ebo := backoff.NewExponentialBackOff() + ebo.MaxElapsedTime = 60 * time.Second + err := backoff.RetryNotify(safe.OperationWithRecover(operation), ebo, notify) + if err != nil { + log.Errorf("Error getting challenge for token: %v", err) + return []byte{} + } + return result +} + +func presentHTTPChallenge(domain, token, keyAuth string, store Store) error { + httpChallenges, err := store.GetHTTPChallenges() + if err != nil { + return fmt.Errorf("unable to get HTTPChallenges : %s", err) + } + + if httpChallenges == nil { + httpChallenges = map[string]map[string][]byte{} + } + + if _, ok := httpChallenges[token]; !ok { + httpChallenges[token] = map[string][]byte{} + } + + httpChallenges[token][domain] = []byte(keyAuth) + + return store.SaveHTTPChallenges(httpChallenges) +} + +func cleanUpHTTPChallenge(domain, token string, store Store) error { + httpChallenges, err := store.GetHTTPChallenges() + if err != nil { + return fmt.Errorf("unable to get HTTPChallenges : %s", err) + } + + log.Debugf("Challenge CleanUp for domain %s", domain) + + if _, ok := httpChallenges[token]; ok { + if _, domainOk := httpChallenges[token][domain]; domainOk { + delete(httpChallenges[token], domain) + } + if len(httpChallenges[token]) == 0 { + delete(httpChallenges, token) + } + return store.SaveHTTPChallenges(httpChallenges) + } + return nil +} diff --git a/provider/acme/local_store.go b/provider/acme/local_store.go new file mode 100644 index 000000000..0bb1dd7c6 --- /dev/null +++ b/provider/acme/local_store.go @@ -0,0 +1,125 @@ +package acme + +import ( + "encoding/json" + "io/ioutil" + "os" + + "github.com/containous/traefik/log" + "github.com/containous/traefik/safe" +) + +var _ Store = (*LocalStore)(nil) + +// LocalStore Store implementation for local file +type LocalStore struct { + filename string + storedData *StoredData + SaveDataChan chan *StoredData +} + +// NewLocalStore initializes a new LocalStore with a file name +func NewLocalStore(filename string) LocalStore { + store := LocalStore{filename: filename, SaveDataChan: make(chan *StoredData)} + store.listenSaveAction() + return store +} + +func (s *LocalStore) get() (*StoredData, error) { + if s.storedData == nil { + s.storedData = &StoredData{HTTPChallenges: make(map[string]map[string][]byte)} + + f, err := os.Open(s.filename) + if err != nil { + return nil, err + } + defer f.Close() + + file, err := ioutil.ReadAll(f) + if err != nil { + return nil, err + } + + if len(file) > 0 { + if err := json.Unmarshal(file, s.storedData); err != nil { + return nil, err + } + } + } + + return s.storedData, nil +} + +// listenSaveAction listens to a chan to store ACME data in json format into LocalStore.filename +func (s *LocalStore) listenSaveAction() { + safe.Go(func() { + for object := range s.SaveDataChan { + data, err := json.MarshalIndent(object, "", " ") + if err != nil { + log.Error(err) + } + + err = ioutil.WriteFile(s.filename, data, 0600) + if err != nil { + log.Error(err) + } + } + }) +} + +// GetAccount returns ACME Account +func (s *LocalStore) GetAccount() (*Account, error) { + storedData, err := s.get() + if err != nil { + return nil, err + } + + return storedData.Account, nil +} + +// SaveAccount stores ACME Account +func (s *LocalStore) SaveAccount(account *Account) error { + storedData, err := s.get() + if err != nil { + return err + } + + storedData.Account = account + s.SaveDataChan <- storedData + + return nil +} + +// GetCertificates returns ACME Certificates list +func (s *LocalStore) GetCertificates() ([]*Certificate, error) { + storedData, err := s.get() + if err != nil { + return nil, err + } + + return storedData.Certificates, nil +} + +// SaveCertificates stores ACME Certificates list +func (s *LocalStore) SaveCertificates(certificates []*Certificate) error { + storedData, err := s.get() + if err != nil { + return err + } + + storedData.Certificates = certificates + s.SaveDataChan <- storedData + + return nil +} + +// GetHTTPChallenges returns ACME HTTP Challenges list +func (s *LocalStore) GetHTTPChallenges() (map[string]map[string][]byte, error) { + return s.storedData.HTTPChallenges, nil +} + +// SaveHTTPChallenges stores ACME HTTP Challenges list +func (s *LocalStore) SaveHTTPChallenges(httpChallenges map[string]map[string][]byte) error { + s.storedData.HTTPChallenges = httpChallenges + return nil +} diff --git a/provider/acme/provider.go b/provider/acme/provider.go new file mode 100644 index 000000000..575e5d07f --- /dev/null +++ b/provider/acme/provider.go @@ -0,0 +1,565 @@ +package acme + +import ( + "crypto/tls" + "crypto/x509" + "fmt" + "io/ioutil" + fmtlog "log" + "net" + "net/http" + "os" + "reflect" + "regexp" + "strings" + "sync" + "time" + + "github.com/BurntSushi/ty/fun" + "github.com/containous/flaeg" + "github.com/containous/mux" + "github.com/containous/traefik/log" + "github.com/containous/traefik/rules" + "github.com/containous/traefik/safe" + traefikTLS "github.com/containous/traefik/tls" + "github.com/containous/traefik/types" + "github.com/pkg/errors" + "github.com/xenolf/lego/acme" + "github.com/xenolf/lego/providers/dns" +) + +var ( + // OSCPMustStaple enables OSCP stapling as from https://github.com/xenolf/lego/issues/270 + OSCPMustStaple = false + provider = &Provider{} +) + +// Configuration holds ACME configuration provided by users +type Configuration struct { + Email string `description:"Email address used for registration"` + ACMELogging bool `description:"Enable debug logging of ACME actions."` + CAServer string `description:"CA server to use."` + Storage string `description:"Storage to use."` + EntryPoint string `description:"EntryPoint to use."` + OnHostRule bool `description:"Enable certificate generation on frontends Host rules."` + OnDemand bool `description:"Enable on demand certificate generation. This will request a certificate from Let's Encrypt during the first TLS handshake for a hostname that does not yet have a certificate."` //deprecated + DNSChallenge *DNSChallenge `description:"Activate DNS-01 Challenge"` + HTTPChallenge *HTTPChallenge `description:"Activate HTTP-01 Challenge"` + Domains []types.Domain `description:"SANs (alternative domains) to each main domain using format: --acme.domains='main.com,san1.com,san2.com' --acme.domains='main.net,san1.net,san2.net'"` +} + +// Provider holds configurations of the provider. +type Provider struct { + *Configuration + Store Store + certificates []*Certificate + account *Account + client *acme.Client + certsChan chan *Certificate + configurationChan chan<- types.ConfigMessage + dynamicCerts *safe.Safe + staticCerts map[string]*tls.Certificate + clientMutex sync.Mutex + configFromListenerChan chan types.Configuration + pool *safe.Pool +} + +// Certificate is a struct which contains all data needed from an ACME certificate +type Certificate struct { + Domain types.Domain + Certificate []byte + Key []byte +} + +// DNSChallenge contains DNS challenge Configuration +type DNSChallenge struct { + Provider string `description:"Use a DNS-01 based challenge provider rather than HTTPS."` + DelayBeforeCheck flaeg.Duration `description:"Assume DNS propagates after a delay in seconds rather than finding and querying nameservers."` +} + +// HTTPChallenge contains HTTP challenge Configuration +type HTTPChallenge struct { + EntryPoint string `description:"HTTP challenge EntryPoint"` +} + +// Get returns the provider instance +func Get() *Provider { + return provider +} + +// IsEnabled returns true if the provider instance and its configuration are not nil, otherwise false +func IsEnabled() bool { + return provider != nil && provider.Configuration != nil +} + +// SetConfigListenerChan initializes the configFromListenerChan +func (p *Provider) SetConfigListenerChan(configFromListenerChan chan types.Configuration) { + p.configFromListenerChan = configFromListenerChan +} + +func (p *Provider) init() error { + if p.ACMELogging { + acme.Logger = fmtlog.New(os.Stderr, "legolog: ", fmtlog.LstdFlags) + } else { + acme.Logger = fmtlog.New(ioutil.Discard, "", 0) + } + + var err error + if p.Store == nil { + err = errors.New("no store found for the ACME provider") + return err + } + + p.account, err = p.Store.GetAccount() + if err != nil { + return err + } + + p.certificates, err = p.Store.GetCertificates() + if err != nil { + return err + } + + p.watchCertificate() + p.watchNewDomains() + + return nil +} + +func (p *Provider) initAccount() (*Account, error) { + if p.account == nil || len(p.account.Email) == 0 { + var err error + p.account, err = NewAccount(p.Email) + if err != nil { + return nil, err + } + } + return p.account, nil +} + +// ListenConfiguration sets a new Configuration into the configFromListenerChan +func (p *Provider) ListenConfiguration(config types.Configuration) { + p.configFromListenerChan <- config +} + +// ListenRequest resolves new certificates for a domain from an incoming request and retrun a valid Certificate to serve (onDemand option) +func (p *Provider) ListenRequest(domain string) (*tls.Certificate, error) { + acmeCert, err := p.resolveCertificate(types.Domain{Main: domain}) + if acmeCert == nil || err != nil { + return nil, err + } + + certificate, err := tls.X509KeyPair(acmeCert.Certificate, acmeCert.PrivateKey) + + return &certificate, err +} + +func (p *Provider) watchNewDomains() { + p.pool.Go(func(stop chan bool) { + for { + select { + case config := <-p.configFromListenerChan: + for _, frontend := range config.Frontends { + for _, route := range frontend.Routes { + domainRules := rules.Rules{} + domains, err := domainRules.ParseDomains(route.Rule) + if err != nil { + log.Errorf("Error parsing domains in provider ACME: %v", err) + continue + } + + if len(domains) == 0 { + log.Debugf("No domain parsed in rule %q", route.Rule) + continue + } + + log.Debugf("Try to challenge certificate for domain %v founded in Host rule", domains) + + var domain types.Domain + if len(domains) > 0 { + domain = types.Domain{Main: domains[0]} + if len(domains) > 1 { + domain.SANs = domains[1:] + } + + safe.Go(func() { + if _, err := p.resolveCertificate(domain); err != nil { + log.Errorf("Unable to obtain ACME certificate for domains %q detected thanks to rule %q : %v", strings.Join(domains, ","), route.Rule, err) + } + }) + } + } + } + case <-stop: + return + } + } + }) +} + +// SetDynamicCertificates allow to initialize dynamicCerts map +func (p *Provider) SetDynamicCertificates(safe *safe.Safe) { + p.dynamicCerts = safe +} + +// SetStaticCertificates allow to initialize staticCerts map +func (p *Provider) SetStaticCertificates(staticCerts map[string]*tls.Certificate) { + p.staticCerts = staticCerts +} + +func (p *Provider) resolveCertificate(domain types.Domain) (*acme.CertificateResource, error) { + domains := []string{domain.Main} + domains = append(domains, domain.SANs...) + if len(domains) == 0 { + return nil, nil + } + domains = fun.Map(types.CanonicalDomain, domains).([]string) + + log.Debugf("Looking for provided certificate to validate %s...", domains) + cert := searchProvidedCertificateForDomains(domains, p.staticCerts) + if cert != nil { + return nil, nil + } + if p.dynamicCerts != nil && p.dynamicCerts.Get() != nil && p.dynamicCerts.Get().(*traefikTLS.DomainsCertificates).Get() != nil { + cert = searchProvidedCertificateForDomains(domains, p.dynamicCerts.Get().(*traefikTLS.DomainsCertificates).Get().(map[string]*tls.Certificate)) + } + if cert != nil { + return nil, nil + } + + log.Debugf("Loading ACME certificates %+v...", domains) + client, err := p.getClient() + if err != nil { + return nil, fmt.Errorf("cannot get ACME client %v", err) + } + + bundle := true + certificate, failures := client.ObtainCertificate(domains, bundle, nil, OSCPMustStaple) + if len(failures) > 0 { + return nil, fmt.Errorf("cannot obtain certificates %+v", failures) + } + log.Debugf("Certificates obtained for domain %+v", domains) + p.addCertificateForDomain(domain, certificate.Certificate, certificate.PrivateKey) + + return &certificate, nil +} + +func (p *Provider) getClient() (*acme.Client, error) { + p.clientMutex.Lock() + defer p.clientMutex.Unlock() + var account *Account + if p.client == nil { + var err error + account, err = p.initAccount() + if err != nil { + return nil, err + } + + log.Debug("Building ACME client...") + caServer := "https://acme-v01.api.letsencrypt.org/directory" + if len(p.CAServer) > 0 { + caServer = p.CAServer + } + log.Debugf(caServer) + client, err := acme.NewClient(caServer, account, acme.RSA4096) + if err != nil { + return nil, err + } + if account.GetRegistration() == nil { + // New users will need to register; be sure to save it + log.Info("Register...") + reg, err := client.Register() + if err != nil { + return nil, err + } + account.Registration = reg + } + + log.Debug("AgreeToTOS...") + err = client.AgreeToTOS() + if err != nil { + // Let's Encrypt Subscriber Agreement renew ? + reg, err := client.QueryRegistration() + if err != nil { + return nil, err + } + account.Registration = reg + err = client.AgreeToTOS() + if err != nil { + return nil, err + } + } + + // Save the account once before all the certificates generation/storing + // No certificate can be generated if account is not initialized + err = p.Store.SaveAccount(account) + if err != nil { + return nil, err + } + + if p.DNSChallenge != nil && len(p.DNSChallenge.Provider) > 0 { + log.Debugf("Using DNS Challenge provider: %s", p.DNSChallenge.Provider) + + err = dnsOverrideDelay(p.DNSChallenge.DelayBeforeCheck) + if err != nil { + return nil, err + } + + var provider acme.ChallengeProvider + provider, err = dns.NewDNSChallengeProviderByName(p.DNSChallenge.Provider) + if err != nil { + return nil, err + } + + client.ExcludeChallenges([]acme.Challenge{acme.HTTP01, acme.TLSSNI01}) + err = client.SetChallengeProvider(acme.DNS01, provider) + if err != nil { + return nil, err + } + } else if p.HTTPChallenge != nil && len(p.HTTPChallenge.EntryPoint) > 0 { + log.Debug("Using HTTP Challenge provider.") + client.ExcludeChallenges([]acme.Challenge{acme.DNS01, acme.TLSSNI01}) + err = client.SetChallengeProvider(acme.HTTP01, p) + if err != nil { + return nil, err + } + } else { + log.Debug("Using TLS Challenge provider.") + client.ExcludeChallenges([]acme.Challenge{acme.HTTP01, acme.DNS01}) + err = client.SetChallengeProvider(acme.TLSSNI01, p) + if err != nil { + return nil, err + } + } + p.client = client + } + + return p.client, nil +} + +// Present presents a challenge to obtain new ACME certificate +func (p *Provider) Present(domain, token, keyAuth string) error { + if p.HTTPChallenge != nil { + return presentHTTPChallenge(domain, token, keyAuth, p.Store) + } else if p.DNSChallenge == nil { + log.Debugf("TLS Challenge CleanUp temp certificate for %s", domain) + tempCertPEM, rsaPrivPEM, err := presentTLSChallenge(domain, keyAuth) + if err != nil { + return err + } + p.addCertificateForDomain(types.Domain{Main: "TEMP-" + domain}, tempCertPEM, rsaPrivPEM) + } + + return nil +} + +// CleanUp cleans the challenges when certificate is obtained +func (p *Provider) CleanUp(domain, token, keyAuth string) error { + if p.HTTPChallenge != nil { + return cleanUpHTTPChallenge(domain, token, p.Store) + } else if p.DNSChallenge == nil { + log.Debugf("TLS Challenge CleanUp temp certificate for %s", domain) + p.deleteCertificateForDomain(types.Domain{Main: "TEMP-" + domain}) + } + return nil +} + +// Provide allows the file provider to provide configurations to traefik +// using the given Configuration channel. +func (p *Provider) Provide(configurationChan chan<- types.ConfigMessage, pool *safe.Pool, constraints types.Constraints) error { + p.pool = pool + err := p.init() + if err != nil { + return err + } + + p.configurationChan = configurationChan + p.refreshCertificates() + + for _, domain := range p.Domains { + safe.Go(func() { + if _, err := p.resolveCertificate(domain); err != nil { + domains := []string{domain.Main} + domains = append(domains, domain.SANs...) + log.Errorf("Unable to obtain ACME certificate for domains %q : %v", domains, err) + } + }) + } + + p.renewCertificates() + + ticker := time.NewTicker(24 * time.Hour) + pool.Go(func(stop chan bool) { + for { + select { + case <-ticker.C: + p.renewCertificates() + case <-stop: + ticker.Stop() + return + } + } + }) + + return nil +} + +func (p *Provider) addCertificateForDomain(domain types.Domain, certificate []byte, key []byte) { + p.certsChan <- &Certificate{Certificate: certificate, Key: key, Domain: domain} +} + +func (p *Provider) watchCertificate() { + p.certsChan = make(chan *Certificate) + p.pool.Go(func(stop chan bool) { + for { + select { + case cert := <-p.certsChan: + certUpdated := false + for _, domainsCertificate := range p.certificates { + if reflect.DeepEqual(cert.Domain, domainsCertificate.Domain) { + domainsCertificate.Certificate = cert.Certificate + domainsCertificate.Key = cert.Key + certUpdated = true + break + } + } + if !certUpdated { + p.certificates = append(p.certificates, cert) + } + p.saveCertificates() + + case <-stop: + return + } + } + }) +} + +func (p *Provider) deleteCertificateForDomain(domain types.Domain) { + for k, cert := range p.certificates { + if reflect.DeepEqual(cert.Domain, domain) { + p.certificates = append(p.certificates[:k], p.certificates[k+1:]...) + } + } + p.saveCertificates() +} + +func (p *Provider) saveCertificates() { + err := p.Store.SaveCertificates(p.certificates) + if err != nil { + log.Error(err) + } + p.refreshCertificates() +} + +func (p *Provider) refreshCertificates() { + config := types.ConfigMessage{ + ProviderName: "ACME", + Configuration: &types.Configuration{ + Backends: map[string]*types.Backend{}, + Frontends: map[string]*types.Frontend{}, + TLS: []*traefikTLS.Configuration{}, + }, + } + + for _, cert := range p.certificates { + certificate := &traefikTLS.Certificate{CertFile: traefikTLS.FileOrContent(cert.Certificate), KeyFile: traefikTLS.FileOrContent(cert.Key)} + config.Configuration.TLS = append(config.Configuration.TLS, &traefikTLS.Configuration{Certificate: certificate}) + } + p.configurationChan <- config +} + +// Timeout calculates the maximum of time allowed to resolved an ACME challenge +func (p *Provider) Timeout() (timeout, interval time.Duration) { + return 60 * time.Second, 5 * time.Second +} + +func (p *Provider) renewCertificates() { + log.Info("Testing certificate renew...") + for _, certificate := range p.certificates { + crt, err := getX509Certificate(certificate) + // If there's an error, we assume the cert is broken, and needs update + // <= 30 days left, renew certificate + if err != nil || crt == nil || crt.NotAfter.Before(time.Now().Add(24*30*time.Hour)) { + client, err := p.getClient() + if err != nil { + log.Infof("Error renewing certificate from LE : %+v, %v", certificate.Domain, err) + continue + } + log.Infof("Renewing certificate from LE : %+v", certificate.Domain) + renewedCert, err := client.RenewCertificate(acme.CertificateResource{ + Domain: certificate.Domain.Main, + PrivateKey: certificate.Key, + Certificate: certificate.Certificate, + }, true, OSCPMustStaple) + if err != nil { + log.Errorf("Error renewing certificate from LE: %v, %v", certificate.Domain, err) + continue + } + p.addCertificateForDomain(certificate.Domain, renewedCert.Certificate, renewedCert.PrivateKey) + } + } +} + +// AddRoutes add routes on internal router +func (p *Provider) AddRoutes(router *mux.Router) { + router.Methods(http.MethodGet). + Path(acme.HTTP01ChallengePath("{token}")). + Handler(http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) { + vars := mux.Vars(req) + if token, ok := vars["token"]; ok { + domain, _, err := net.SplitHostPort(req.Host) + if err != nil { + log.Debugf("Unable to split host and port: %v. Fallback to request host.", err) + domain = req.Host + } + tokenValue := getTokenValue(token, domain, p.Store) + if len(tokenValue) > 0 { + rw.WriteHeader(http.StatusOK) + _, err = rw.Write(tokenValue) + if err != nil { + log.Errorf("Unable to write token : %v", err) + } + return + } + } + rw.WriteHeader(http.StatusNotFound) + })) +} + +func searchProvidedCertificateForDomains(domains []string, certs map[string]*tls.Certificate) *tls.Certificate { + // Use regex to test for provided certs that might have been added into TLSConfig + providedCertMatch := false + for k := range certs { + selector := "^" + strings.Replace(k, "*.", "[^\\.]*\\.?", -1) + "$" + for _, domainToCheck := range domains { + providedCertMatch, _ = regexp.MatchString(selector, domainToCheck) + if !providedCertMatch { + break + } + } + if providedCertMatch { + log.Debugf("Got provided certificate for domains %s", domains) + return certs[k] + + } + } + return nil +} + +func getX509Certificate(certificate *Certificate) (*x509.Certificate, error) { + var crt *x509.Certificate + tlsCert, err := tls.X509KeyPair(certificate.Certificate, certificate.Key) + if err != nil { + log.Errorf("Failed to load TLS keypair from ACME certificate for domain %q (SAN : %q), certificate will be renewed : %v", certificate.Domain.Main, strings.Join(certificate.Domain.SANs, ","), err) + return nil, err + } + crt = tlsCert.Leaf + if crt == nil { + crt, err = x509.ParseCertificate(tlsCert.Certificate[0]) + if err != nil { + log.Errorf("Failed to parse TLS keypair from ACME certificate for domain %q (SAN : %q), certificate will be renewed : %v", certificate.Domain.Main, strings.Join(certificate.Domain.SANs, ","), err) + } + } + return crt, err +} diff --git a/provider/acme/store.go b/provider/acme/store.go new file mode 100644 index 000000000..77062d722 --- /dev/null +++ b/provider/acme/store.go @@ -0,0 +1,18 @@ +package acme + +// StoredData represents the data managed by the Store +type StoredData struct { + Account *Account + Certificates []*Certificate + HTTPChallenges map[string]map[string][]byte +} + +// Store is a generic interface to represents a storage +type Store interface { + GetAccount() (*Account, error) + SaveAccount(*Account) error + GetCertificates() ([]*Certificate, error) + SaveCertificates([]*Certificate) error + GetHTTPChallenges() (map[string]map[string][]byte, error) + SaveHTTPChallenges(map[string]map[string][]byte) error +} diff --git a/server/server.go b/server/server.go index f86eb98b7..1b18049d3 100644 --- a/server/server.go +++ b/server/server.go @@ -34,6 +34,7 @@ import ( "github.com/containous/traefik/middlewares/redirect" "github.com/containous/traefik/middlewares/tracing" "github.com/containous/traefik/provider" + "github.com/containous/traefik/provider/acme" "github.com/containous/traefik/rules" "github.com/containous/traefik/safe" "github.com/containous/traefik/server/cookie" @@ -75,15 +76,17 @@ type Server struct { defaultForwardingRoundTripper http.RoundTripper metricsRegistry metrics.Registry provider provider.Provider + configurationListeners []func(types.Configuration) } type serverEntryPoints map[string]*serverEntryPoint type serverEntryPoint struct { - httpServer *http.Server - listener net.Listener - httpRouter *middlewares.HandlerSwitcher - certs safe.Safe + httpServer *http.Server + listener net.Listener + httpRouter *middlewares.HandlerSwitcher + certs safe.Safe + onDemandListener func(string) (*tls.Certificate, error) } // NewServer returns an initialized Server. @@ -452,6 +455,9 @@ func (s *Server) loadConfiguration(configMsg types.ConfigMessage) { log.Infof("Server configuration reloaded on %s", s.serverEntryPoints[newServerEntryPointName].httpServer.Addr) } s.currentConfigurations.Set(newConfigurations) + for _, listener := range s.configurationListeners { + listener(*configMsg.Configuration) + } s.postLoadConfiguration() } else { s.metricsRegistry.ConfigReloadsFailureCounter().Add(1) @@ -460,6 +466,19 @@ func (s *Server) loadConfiguration(configMsg types.ConfigMessage) { } } +// AddListener adds a new listener function used when new configuration is provided +func (s *Server) AddListener(listener func(types.Configuration)) { + if s.configurationListeners == nil { + s.configurationListeners = make([]func(types.Configuration), 0) + } + s.configurationListeners = append(s.configurationListeners, listener) +} + +// SetOnDemandListener adds a new listener function used when a request is caught +func (s *serverEntryPoint) SetOnDemandListener(listener func(string) (*tls.Certificate, error)) { + s.onDemandListener = listener +} + // loadHTTPSConfiguration add/delete HTTPS certificate managed dynamically func (s *Server) loadHTTPSConfiguration(configurations types.Configurations, defaultEntryPoints configuration.DefaultEntryPoints) (map[string]*traefikTls.DomainsCertificates, error) { newEPCertificates := make(map[string]*traefikTls.DomainsCertificates) @@ -476,8 +495,8 @@ func (s *Server) loadHTTPSConfiguration(configurations types.Configurations, def // getCertificate allows to customize tlsConfig.Getcertificate behaviour to get the certificates inserted dynamically func (s *serverEntryPoint) getCertificate(clientHello *tls.ClientHelloInfo) (*tls.Certificate, error) { + domainToCheck := types.CanonicalDomain(clientHello.ServerName) if s.certs.Get() != nil { - domainToCheck := types.CanonicalDomain(clientHello.ServerName) for domains, cert := range *s.certs.Get().(*traefikTls.DomainsCertificates) { for _, domain := range strings.Split(domains, ",") { selector := "^" + strings.Replace(domain, "*.", "[^\\.]*\\.?", -1) + "$" @@ -489,18 +508,19 @@ func (s *serverEntryPoint) getCertificate(clientHello *tls.ClientHelloInfo) (*tl } log.Debugf("No certificate provided dynamically can check the domain %q, a per default certificate will be used.", domainToCheck) } + if s.onDemandListener != nil { + return s.onDemandListener(domainToCheck) + } return nil, nil } func (s *Server) postLoadConfiguration() { metrics.OnConfigurationUpdate() - if s.globalConfiguration.ACME == nil { - return - } - if s.leadership != nil && !s.leadership.IsLeader() { + if s.globalConfiguration.ACME == nil || s.leadership == nil || !s.leadership.IsLeader() { return } + if s.globalConfiguration.ACME.OnHostRule { currentConfigurations := s.currentConfigurations.Get().(types.Configurations) for _, config := range currentConfigurations { @@ -554,7 +574,7 @@ func createClientTLSConfig(entryPointName string, tlsOption *traefikTls.TLS) (*t return nil, errors.New("no TLS provided") } - config, _, err := tlsOption.Certificates.CreateTLSConfig(entryPointName) + config, err := tlsOption.Certificates.CreateTLSConfig(entryPointName) if err != nil { return nil, err } @@ -587,16 +607,12 @@ func (s *Server) createTLSConfig(entryPointName string, tlsOption *traefikTls.TL return nil, nil } - config, epDomainsCertificates, err := tlsOption.Certificates.CreateTLSConfig(entryPointName) + config, err := tlsOption.Certificates.CreateTLSConfig(entryPointName) if err != nil { return nil, err } epDomainsCertificatesTmp := new(traefikTls.DomainsCertificates) - if epDomainsCertificates[entryPointName] != nil { - epDomainsCertificatesTmp = epDomainsCertificates[entryPointName] - } else { - *epDomainsCertificatesTmp = make(map[string]*tls.Certificate) - } + *epDomainsCertificatesTmp = make(map[string]*tls.Certificate) s.serverEntryPoints[entryPointName].certs.Set(epDomainsCertificatesTmp) // ensure http2 enabled config.NextProtos = []string{"h2", "http/1.1"} @@ -637,16 +653,10 @@ func (s *Server) createTLSConfig(entryPointName string, tlsOption *traefikTls.TL } return false } - if s.leadership == nil { - err := s.globalConfiguration.ACME.CreateLocalConfig(config, &s.serverEntryPoints[entryPointName].certs, checkOnDemandDomain) - if err != nil { - return nil, err - } - } else { - err := s.globalConfiguration.ACME.CreateClusterConfig(s.leadership, config, &s.serverEntryPoints[entryPointName].certs, checkOnDemandDomain) - if err != nil { - return nil, err - } + + err := s.globalConfiguration.ACME.CreateClusterConfig(s.leadership, config, &s.serverEntryPoints[entryPointName].certs, checkOnDemandDomain) + if err != nil { + return nil, err } } } else { @@ -658,20 +668,31 @@ func (s *Server) createTLSConfig(entryPointName string, tlsOption *traefikTls.TL // BuildNameToCertificate parses the CommonName and SubjectAlternateName fields // in each certificate and populates the config.NameToCertificate map. config.BuildNameToCertificate() - //Set the minimum TLS version if set in the config TOML + + if acme.IsEnabled() { + if entryPointName == acme.Get().EntryPoint { + acme.Get().SetStaticCertificates(config.NameToCertificate) + acme.Get().SetDynamicCertificates(&s.serverEntryPoints[entryPointName].certs) + if acme.Get().OnDemand { + s.serverEntryPoints[entryPointName].SetOnDemandListener(acme.Get().ListenRequest) + } + } + } + + // Set the minimum TLS version if set in the config TOML if minConst, exists := traefikTls.MinVersion[s.globalConfiguration.EntryPoints[entryPointName].TLS.MinVersion]; exists { config.PreferServerCipherSuites = true config.MinVersion = minConst } - //Set the list of CipherSuites if set in the config TOML + // Set the list of CipherSuites if set in the config TOML if s.globalConfiguration.EntryPoints[entryPointName].TLS.CipherSuites != nil { - //if our list of CipherSuites is defined in the entrypoint config, we can re-initilize the suites list as empty + // if our list of CipherSuites is defined in the entrypoint config, we can re-initilize the suites list as empty config.CipherSuites = make([]uint16, 0) for _, cipher := range s.globalConfiguration.EntryPoints[entryPointName].TLS.CipherSuites { if cipherConst, exists := traefikTls.CipherSuites[cipher]; exists { config.CipherSuites = append(config.CipherSuites, cipherConst) } else { - //CipherSuite listed in the toml does not exist in our listed + // CipherSuite listed in the toml does not exist in our listed return nil, errors.New("Invalid CipherSuite: " + cipher) } } @@ -715,6 +736,8 @@ func (s *Server) addInternalPublicRoutes(entryPointName string, router *mux.Rout func (s *Server) addACMERoutes(entryPointName string, router *mux.Router) { if s.globalConfiguration.ACME != nil && s.globalConfiguration.ACME.HTTPChallenge != nil && s.globalConfiguration.ACME.HTTPChallenge.EntryPoint == entryPointName { s.globalConfiguration.ACME.AddRoutes(router) + } else if acme.IsEnabled() && acme.Get().HTTPChallenge != nil && acme.Get().HTTPChallenge.EntryPoint == entryPointName { + acme.Get().AddRoutes(router) } } @@ -1183,7 +1206,7 @@ func (s *Server) loadConfig(configurations types.Configurations, globalConfigura // Get new certificates list sorted per entrypoints // Update certificates entryPointsCertificates, err := s.loadHTTPSConfiguration(configurations, globalConfiguration.DefaultEntryPoints) - //sort routes and update certificates + // Sort routes and update certificates for serverEntryPointName, serverEntryPoint := range serverEntryPoints { serverEntryPoint.httpRouter.GetHandler().SortRoutes() _, exists := entryPointsCertificates[serverEntryPointName] diff --git a/tls/certificate.go b/tls/certificate.go index ccff7bc4f..101674fc4 100644 --- a/tls/certificate.go +++ b/tls/certificate.go @@ -87,14 +87,14 @@ func (f FileOrContent) Read() ([]byte, error) { } // CreateTLSConfig creates a TLS config from Certificate structures -func (c *Certificates) CreateTLSConfig(entryPointName string) (*tls.Config, map[string]*DomainsCertificates, error) { +func (c *Certificates) CreateTLSConfig(entryPointName string) (*tls.Config, error) { config := &tls.Config{} domainsCertificates := make(map[string]*DomainsCertificates) if c.isEmpty() { - config.Certificates = make([]tls.Certificate, 0) + config.Certificates = []tls.Certificate{} cert, err := generate.DefaultCertificate() if err != nil { - return nil, nil, err + return nil, err } config.Certificates = append(config.Certificates, *cert) } else { @@ -111,7 +111,7 @@ func (c *Certificates) CreateTLSConfig(entryPointName string) (*tls.Config, map[ } } } - return config, domainsCertificates, nil + return config, nil } // isEmpty checks if the certificates list is empty @@ -139,7 +139,7 @@ func (c *Certificate) AppendCertificates(certs map[string]*DomainsCertificates, keyContent, err := c.KeyFile.Read() if err != nil { - return fmt.Errorf("uUnable to read KeyFile : %v", err) + return fmt.Errorf("unable to read KeyFile : %v", err) } tlsCert, err := tls.X509KeyPair(certContent, keyContent) if err != nil { diff --git a/tls/generate/generate.go b/tls/generate/generate.go index 842327dab..8aa8bfbdb 100644 --- a/tls/generate/generate.go +++ b/tls/generate/generate.go @@ -1,6 +1,7 @@ package generate import ( + "crypto/ecdsa" "crypto/rand" "crypto/rsa" "crypto/sha256" @@ -89,3 +90,21 @@ func derCert(privKey *rsa.PrivateKey, expiration time.Time, domain string) ([]by return x509.CreateCertificate(rand.Reader, &template, &template, &privKey.PublicKey, privKey) } + +// PemEncode encodes date in PEM format +func PemEncode(data interface{}) []byte { + var pemBlock *pem.Block + switch key := data.(type) { + case *ecdsa.PrivateKey: + keyBytes, _ := x509.MarshalECPrivateKey(key) + pemBlock = &pem.Block{Type: "EC PRIVATE KEY", Bytes: keyBytes} + case *rsa.PrivateKey: + pemBlock = &pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(key)} + case *x509.CertificateRequest: + pemBlock = &pem.Block{Type: "CERTIFICATE REQUEST", Bytes: key.Raw} + case []byte: + pemBlock = &pem.Block{Type: "CERTIFICATE", Bytes: data.([]byte)} + } + + return pem.EncodeToMemory(pemBlock) +} diff --git a/types/domains.go b/types/domains.go new file mode 100644 index 000000000..fee857fc0 --- /dev/null +++ b/types/domains.go @@ -0,0 +1,50 @@ +package types + +import ( + "fmt" + "strings" +) + +// Domain holds a domain name with SANs +type Domain struct { + Main string + SANs []string +} + +// Domains parse []Domain +type Domains []Domain + +// Set []Domain +func (ds *Domains) Set(str string) error { + fargs := func(c rune) bool { + return c == ',' || c == ';' + } + + // get function + slice := strings.FieldsFunc(str, fargs) + if len(slice) < 1 { + return fmt.Errorf("parse error ACME.Domain. Unable to parse %s", str) + } + + d := Domain{ + Main: slice[0], + } + + if len(slice) > 1 { + d.SANs = slice[1:] + } + + *ds = append(*ds, d) + return nil +} + +// Get []Domain +func (ds *Domains) Get() interface{} { return []Domain(*ds) } + +// String returns []Domain in string +func (ds *Domains) String() string { return fmt.Sprintf("%+v", *ds) } + +// SetValue sets []Domain into the parser +func (ds *Domains) SetValue(val interface{}) { + *ds = val.([]Domain) +} diff --git a/types/types.go b/types/types.go index e1ce3fe28..a9954d7b6 100644 --- a/types/types.go +++ b/types/types.go @@ -309,7 +309,7 @@ func (c *Constraint) MatchConstraintWithAtLeastOneTag(tags []string) bool { return false } -//Set []*Constraint +// Set []*Constraint func (cs *Constraints) Set(str string) error { exps := strings.Split(str, ",") if len(exps) == 0 { @@ -328,13 +328,13 @@ func (cs *Constraints) Set(str string) error { // Constraints holds a Constraint parser type Constraints []*Constraint -//Get []*Constraint +// Get []*Constraint func (cs *Constraints) Get() interface{} { return []*Constraint(*cs) } -//String returns []*Constraint in string +// String returns []*Constraint in string func (cs *Constraints) String() string { return fmt.Sprintf("%+v", *cs) } -//SetValue sets []*Constraint into the parser +// SetValue sets []*Constraint into the parser func (cs *Constraints) SetValue(val interface{}) { *cs = val.(Constraints) } @@ -432,8 +432,8 @@ type InfluxDB struct { // Buckets holds Prometheus Buckets type Buckets []float64 -//Set adds strings elem into the the parser -//it splits str on "," and ";" and apply ParseFloat to string +// Set adds strings elem into the the parser +// it splits str on "," and ";" and apply ParseFloat to string func (b *Buckets) Set(str string) error { fargs := func(c rune) bool { return c == ',' || c == ';' @@ -450,13 +450,13 @@ func (b *Buckets) Set(str string) error { return nil } -//Get []float64 +// Get []float64 func (b *Buckets) Get() interface{} { return *b } -//String return slice in a string +// String return slice in a string func (b *Buckets) String() string { return fmt.Sprintf("%v", *b) } -//SetValue sets []float64 into the parser +// SetValue sets []float64 into the parser func (b *Buckets) SetValue(val interface{}) { *b = val.(Buckets) }