diff --git a/cmd/controller/main.go b/cmd/controller/main.go index f444307fd..cb3d8dca0 100644 --- a/cmd/controller/main.go +++ b/cmd/controller/main.go @@ -29,6 +29,7 @@ import ( _ "github.com/jetstack/cert-manager/pkg/apis/certmanager/install" "github.com/jetstack/cert-manager/pkg/client" "github.com/jetstack/cert-manager/pkg/controller" + "github.com/jetstack/cert-manager/pkg/controller/certificates" "github.com/jetstack/cert-manager/pkg/controller/issuers" "github.com/jetstack/cert-manager/pkg/informers/externalversions" logpkg "github.com/jetstack/cert-manager/pkg/log" @@ -75,14 +76,14 @@ func main() { } issuerCtrl := issuers.New(ctx) - // certificatesCtrl := certificates.New(ctx) + certificatesCtrl := certificates.New(ctx) stopCh := make(chan struct{}) factory.Start(stopCh) cmFactory.Start(stopCh) go issuerCtrl.Run(5, stopCh) - // go certificatesCtrl.Run(5, stopCh) + go certificatesCtrl.Run(5, stopCh) <-stopCh } diff --git a/pkg/controller/certificates/controller.go b/pkg/controller/certificates/controller.go index 1efb6365e..d35eb5cd2 100644 --- a/pkg/controller/certificates/controller.go +++ b/pkg/controller/certificates/controller.go @@ -38,7 +38,6 @@ func processNextWorkItem(ctx controller.Context, obj interface{}) error { return err } case *api.Secret: - ctx.Logger.Printf("unhandled change to Secret resource: %+v", v) default: ctx.Logger.Errorf("unexpected resource type (%T) in work queue", obj) } diff --git a/pkg/controller/certificates/sync.go b/pkg/controller/certificates/sync.go index 2be9700cb..fd09ab047 100644 --- a/pkg/controller/certificates/sync.go +++ b/pkg/controller/certificates/sync.go @@ -1,17 +1,36 @@ package certificates import ( + "fmt" + "github.com/jetstack/cert-manager/pkg/apis/certmanager/v1alpha1" "github.com/jetstack/cert-manager/pkg/controller" + "github.com/jetstack/cert-manager/pkg/issuer" ) func sync(ctx *controller.Context, crt *v1alpha1.Certificate) error { - // // step zero: check if the referenced issuer exists and is ready - // issuer, err := ctx.CertManagerInformerFactory.Certmanager().V1alpha1().Issuers().Lister().Issuers(crt.Namespace).Get(crt.Spec.Issuer) + // step zero: check if the referenced issuer exists and is ready + issuerObj, err := ctx.CertManagerInformerFactory.Certmanager().V1alpha1().Issuers().Lister().Issuers(crt.Namespace).Get(crt.Spec.Issuer) - // if err != nil { - // return fmt.Errorf("issuer '%s' for certificate '%s' does not exist", crt.Spec.Issuer, crt.Name) - // } + if err != nil { + return fmt.Errorf("issuer '%s' for certificate '%s' does not exist", crt.Spec.Issuer, crt.Name) + } + + if !issuerObj.Status.Ready { + return fmt.Errorf("issuer '%s/%s' for certificate '%s' not ready", issuerObj.Namespace, issuerObj.Name, crt.Name) + } + + i, err := issuer.IssuerFor(*ctx, issuerObj) + + if err != nil { + return fmt.Errorf("error getting issuer implementation for issuer '%s': %s", issuerObj.Name, err.Error()) + } + + err = i.Prepare(crt) + + if err != nil { + return err + } // // step one: check if referenced secret exists, if not, trigger issue event // secret, err := ctx.InformerFactory.Core().V1().Secrets().Lister().Secrets(crt.Namespace).Get(crt.Spec.SecretName) diff --git a/pkg/issuer/acme/acme.go b/pkg/issuer/acme/acme.go index 308b700ea..dc84bda82 100644 --- a/pkg/issuer/acme/acme.go +++ b/pkg/issuer/acme/acme.go @@ -3,7 +3,6 @@ package acme import ( "fmt" "reflect" - "strings" "github.com/jetstack/cert-manager/pkg/apis/certmanager/v1alpha1" "github.com/jetstack/cert-manager/pkg/client/scheme" @@ -75,25 +74,6 @@ func (a *Acme) ensureSetup() error { return nil } -func (a *Acme) Issue(crt *v1alpha1.Certificate) ([]byte, []byte, error) { - if crt.Spec.ACME == nil { - return nil, nil, fmt.Errorf("acme config must be specified") - } - - // TODO (@munnerz): tidy this weird horrible line up - switch v1alpha1.ACMEChallengeType(strings.ToUpper(string(crt.Spec.ACME.Challenge))) { - case v1alpha1.ACMEChallengeTypeHTTP01: - a.ctx.Logger.Printf("Obtaining certificates for %+v", crt.Spec.Domains) - // todo: use acme library to obtain challenge details and pass them to the solver - case v1alpha1.ACMEChallengeTypeTLSSNI01: - case v1alpha1.ACMEChallengeTypeDNS01: - default: - return nil, nil, fmt.Errorf("invalid acme challenge type '%s'", crt.Spec.ACME.Challenge) - } - - return nil, nil, nil -} - func (a *Acme) Renew(crt *v1alpha1.Certificate) ([]byte, []byte, error) { return nil, nil, nil } diff --git a/pkg/issuer/acme/interface.go b/pkg/issuer/acme/interface.go new file mode 100644 index 000000000..1b1a8f61f --- /dev/null +++ b/pkg/issuer/acme/interface.go @@ -0,0 +1,17 @@ +package acme + +import ( + "fmt" + + "github.com/jetstack/cert-manager/pkg/apis/certmanager/v1alpha1" + "github.com/jetstack/cert-manager/pkg/controller" +) + +type solver interface { + Present(ctx controller.Context, crt *v1alpha1.Certificate, domain, token, key string) error + Cleanup(ctx controller.Context, crt *v1alpha1.Certificate, domain, token string) error +} + +func solverFor(challengeType string) (solver, error) { + return nil, fmt.Errorf("no solver implemented") +} diff --git a/pkg/issuer/acme/issue.go b/pkg/issuer/acme/issue.go new file mode 100644 index 000000000..b109c97c3 --- /dev/null +++ b/pkg/issuer/acme/issue.go @@ -0,0 +1,11 @@ +package acme + +import ( + "fmt" + + "github.com/jetstack/cert-manager/pkg/apis/certmanager/v1alpha1" +) + +func (a *Acme) Issue(crt *v1alpha1.Certificate) ([]byte, []byte, error) { + return nil, nil, fmt.Errorf("not implemented") +} diff --git a/pkg/issuer/acme/prepare.go b/pkg/issuer/acme/prepare.go new file mode 100644 index 000000000..06c72b21b --- /dev/null +++ b/pkg/issuer/acme/prepare.go @@ -0,0 +1,232 @@ +package acme + +import ( + "context" + "fmt" + + "golang.org/x/crypto/acme" + + "github.com/jetstack/cert-manager/pkg/apis/certmanager/v1alpha1" + "github.com/jetstack/cert-manager/pkg/log" + "github.com/jetstack/cert-manager/pkg/util" +) + +func authorizationsMap(list []v1alpha1.ACMEDomainAuthorization) map[string]v1alpha1.ACMEDomainAuthorization { + out := make(map[string]v1alpha1.ACMEDomainAuthorization, len(list)) + for _, a := range list { + out[a.Domain] = a + } + return out +} + +func authorizationsToObtain(cl *acme.Client, crt v1alpha1.Certificate) ([]string, error) { + authMap := authorizationsMap(crt.Status.ACME.Authorizations) + toAuthorize := util.StringFilter(func(domain string) (bool, error) { + auth, ok := authMap[domain] + if !ok { + return false, nil + } + return checkAuthorization(cl, auth.URI) + }, crt.Spec.Domains...) + + domains := make([]string, len(toAuthorize)) + for i, v := range toAuthorize { + if v.Err != nil { + return nil, fmt.Errorf("error checking authorization status for %s: %s", v.String, v.Err) + } + domains[i] = v.String + } + + return domains, nil +} + +// Prepare will ensure the issuer has been initialised and is ready to issue +// certificates for the domains listed on the Certificate resource. +// +// It will send the appropriate Letsencrypt authorizations, and complete +// challenge requests if neccessary. +func (a *Acme) Prepare(crt *v1alpha1.Certificate) error { + if crt.Spec.ACME == nil { + return fmt.Errorf("acme config must be specified") + } + + privKey, err := a.account.privateKey() + + if err != nil { + return fmt.Errorf("error getting acme account private key: %s", err.Error()) + } + + cl := &acme.Client{ + Key: privKey, + DirectoryURL: a.account.server(), + } + + // step one: check issuer to see if we already have authorizations + toAuthorize, err := authorizationsToObtain(cl, *crt) + + if err != nil { + return err + } + + a.ctx.Logger.Printf("need to get authorizations for %v", toAuthorize) + + // step two: if there are any domains that we don't have authorization for, + // we should attempt to authorize those domains + if len(toAuthorize) == 0 { + return nil + } + + auths, err := getAuthorizations(cl, toAuthorize...) + + if err != nil { + return err + } + + a.ctx.Logger.Printf("requested authorizations for %v", toAuthorize) + + // todo: parallelize this + // todo: refactor into own function + for _, auth := range auths { + a.ctx.Logger.Printf("picking challenge type for domain '%s'", auth.domain) + challengeType, err := pickChallengeType(a.ctx.Logger, auth.domain, auth.auth, crt.Spec.ACME.Config) + if err != nil { + return fmt.Errorf("error challenge type to use for domain '%s': %s", auth.domain, err.Error()) + } + a.ctx.Logger.Printf("using challenge type %s for domain '%s'", challengeType, auth.domain) + challenge, err := challengeForAuthorization(cl, auth.auth, challengeType) + if err != nil { + return fmt.Errorf("error getting challenge for domain '%s': %s", auth.domain, err.Error()) + } + + token := challenge.Token + var key string + switch challenge.Type { + case "http-01": + key, err = cl.HTTP01ChallengeResponse(challenge.Token) + case "dns-01": + key, err = cl.DNS01ChallengeRecord(challenge.Token) + default: + err = fmt.Errorf("unsupported challenge type %s", challenge.Type) + } + + if err != nil { + return fmt.Errorf("error getting key for acme challenge for domain '%s': %s", auth.domain, err.Error()) + } + + solver, err := solverFor(challengeType) + if err != nil { + return fmt.Errorf("error getting solver for challenge type '%s': %s", challengeType, err.Error()) + } + + err = solver.Present(*a.ctx, crt, auth.domain, token, key) + if err != nil { + return fmt.Errorf("error presenting acme authorization for domain '%s': %s", auth.domain, err.Error()) + } + + authorization, err := cl.WaitAuthorization(context.Background(), challenge.URI) + if err != nil { + return fmt.Errorf("error waiting for authorization for domain '%s': %s", auth.domain, err.Error()) + } + + if authorization.Status != acme.StatusValid { + return fmt.Errorf("expected acme domain authorization status for '%s' to be valid, but it's %s", auth.domain, authorization.Status) + } + + crt.Status.ACME.SaveAuthorization(v1alpha1.ACMEDomainAuthorization{ + Domain: auth.domain, + URI: authorization.URI, + }) + } + + return nil +} + +func checkAuthorization(cl *acme.Client, uri string) (bool, error) { + a, err := cl.GetAuthorization(context.Background(), uri) + + if err != nil { + return false, err + } + + if a.Status == acme.StatusValid { + return true, nil + } + + return false, nil +} + +type authResponses []authResponse +type authResponse struct { + domain string + auth *acme.Authorization + err error +} + +// Error returns an error if any one of the authResponses contains an error +func (a authResponses) Error() error { + var errs []error + for _, r := range a { + if r.err != nil { + errs = append(errs, fmt.Errorf("'%s': %s", r.domain, r.err)) + } + } + if len(errs) > 0 { + return fmt.Errorf("error getting authorization for domains: %v", errs) + } + return nil +} + +func getAuthorizations(cl *acme.Client, domains ...string) ([]authResponse, error) { + respCh := make(chan authResponse) + defer close(respCh) + for _, d := range domains { + go func(domain string) { + auth, err := cl.Authorize(context.Background(), domain) + + if err != nil { + respCh <- authResponse{"", nil, fmt.Errorf("getting acme authorization failed: %s", err.Error())} + return + } + + respCh <- authResponse{domain, auth, nil} + }(d) + } + + responses := make([]authResponse, len(domains)) + for i := 0; i < len(domains); i++ { + responses[i] = <-respCh + } + return responses, authResponses(responses).Error() +} + +func pickChallengeType(log log.Logger, domain string, auth *acme.Authorization, cfg []v1alpha1.ACMECertificateDomainConfig) (string, error) { + for _, d := range cfg { + log.Printf("checking config %v", d) + for _, dom := range d.Domains { + log.Printf("checking domain %s", dom) + if dom == domain { + for _, challenge := range auth.Challenges { + switch { + case challenge.Type == "http-01" && d.HTTP01 != nil: + return challenge.Type, nil + case challenge.Type == "dns-01" && d.DNS01 != nil: + return challenge.Type, nil + } + log.Printf("cannot use %s challenge for domain %s", challenge.Type, domain) + } + } + } + } + return "", fmt.Errorf("no configured and supported challenge type found") +} + +func challengeForAuthorization(cl *acme.Client, auth *acme.Authorization, challengeType string) (*acme.Challenge, error) { + for _, challenge := range auth.Challenges { + if challenge.Type != challengeType { + continue + } + + return challenge, nil + } + return nil, fmt.Errorf("no supported challenges offered") +} diff --git a/pkg/issuer/issuer.go b/pkg/issuer/issuer.go index 61386333f..ab14a8489 100644 --- a/pkg/issuer/issuer.go +++ b/pkg/issuer/issuer.go @@ -13,6 +13,8 @@ type Interface interface { // a service, creating a CA and storing it somewhere, or verifying // credentials and authorization with a remote server. Setup() error + // Prepare + Prepare(*v1alpha1.Certificate) error // Issue attempts to issue a certificate as described by the certificate // resource given Issue(*v1alpha1.Certificate) ([]byte, []byte, error) diff --git a/pkg/util/filter.go b/pkg/util/filter.go new file mode 100644 index 000000000..71f096b66 --- /dev/null +++ b/pkg/util/filter.go @@ -0,0 +1,57 @@ +package util + +import ( + "fmt" + "sync" +) + +// StringFilterT is a tuple for a value that has not been filtered out +type StringFilterT struct { + String string + Err error +} + +type StringFilterWrapper []StringFilterT + +func (f StringFilterWrapper) Error() error { + var errs []error + for _, r := range f { + if r.Err != nil { + errs = append(errs, fmt.Errorf("'%s': %s", r.String, r.Err)) + } + } + if len(errs) > 0 { + return fmt.Errorf("%v", errs) + } + return nil +} + +// FilterFn is a function used to filter a list of string. If the +// function returns false or a non-nil error, it will not be filtered. +type FilterFn func(string) (filter bool, err error) + +// StringFilter will run fn with each element of in, filtering out elements. +// it will return a slice of results where fn returned ok, or a non-nil error. +// it will also call each instance of fn in it's own goroutine. +func StringFilter(fn FilterFn, in ...string) StringFilterWrapper { + outCh := make(chan StringFilterT, len(in)) + var wg sync.WaitGroup + for i, s := range in { + wg.Add(1) + go func(i int, s string) { + defer wg.Done() + if filter, err := fn(s); err != nil || !filter { + outCh <- StringFilterT{s, err} + } + }(i, s) + } + wg.Wait() + close(outCh) + res := make([]StringFilterT, len(outCh)) + i := 0 + for o := range outCh { + res[i] = o + i++ + } + return res +}