diff --git a/cmd/controller/app/options/options.go b/cmd/controller/app/options/options.go index 2fa501f3d..3246d3f36 100644 --- a/cmd/controller/app/options/options.go +++ b/cmd/controller/app/options/options.go @@ -25,6 +25,8 @@ import ( "github.com/jetstack/cert-manager/pkg/util" + challengescontroller "github.com/jetstack/cert-manager/pkg/controller/acmechallenges" + orderscontroller "github.com/jetstack/cert-manager/pkg/controller/acmeorders" certificatescontroller "github.com/jetstack/cert-manager/pkg/controller/certificates" clusterissuerscontroller "github.com/jetstack/cert-manager/pkg/controller/clusterissuers" ingressshimcontroller "github.com/jetstack/cert-manager/pkg/controller/ingress-shim" @@ -95,6 +97,8 @@ var ( clusterissuerscontroller.ControllerName, certificatescontroller.ControllerName, ingressshimcontroller.ControllerName, + orderscontroller.ControllerName, + challengescontroller.ControllerName, } ) diff --git a/cmd/controller/start.go b/cmd/controller/start.go index 6d3f96d6c..3d82350ab 100644 --- a/cmd/controller/start.go +++ b/cmd/controller/start.go @@ -27,6 +27,8 @@ import ( "github.com/jetstack/cert-manager/cmd/controller/app" "github.com/jetstack/cert-manager/cmd/controller/app/options" + _ "github.com/jetstack/cert-manager/pkg/controller/acmechallenges" + _ "github.com/jetstack/cert-manager/pkg/controller/acmeorders" _ "github.com/jetstack/cert-manager/pkg/controller/certificates" _ "github.com/jetstack/cert-manager/pkg/controller/clusterissuers" _ "github.com/jetstack/cert-manager/pkg/controller/ingress-shim" diff --git a/pkg/controller/acmechallenges/checks.go b/pkg/controller/acmechallenges/checks.go new file mode 100644 index 000000000..95da1436a --- /dev/null +++ b/pkg/controller/acmechallenges/checks.go @@ -0,0 +1,3 @@ +package acmechallenges + +// no checks for the acme orders controller yet diff --git a/pkg/controller/acmechallenges/controller.go b/pkg/controller/acmechallenges/controller.go new file mode 100644 index 000000000..b7c747641 --- /dev/null +++ b/pkg/controller/acmechallenges/controller.go @@ -0,0 +1,184 @@ +package acmechallenges + +import ( + "context" + "fmt" + "sync" + "time" + + "github.com/golang/glog" + k8sErrors "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/util/runtime" + "k8s.io/apimachinery/pkg/util/wait" + corelisters "k8s.io/client-go/listers/core/v1" + "k8s.io/client-go/tools/cache" + "k8s.io/client-go/util/workqueue" + + cmlisters "github.com/jetstack/cert-manager/pkg/client/listers/certmanager/v1alpha1" + controllerpkg "github.com/jetstack/cert-manager/pkg/controller" + "github.com/jetstack/cert-manager/pkg/issuer/acme/dns" + "github.com/jetstack/cert-manager/pkg/issuer/acme/http" + "github.com/jetstack/cert-manager/pkg/util" +) + +type Controller struct { + controllerpkg.Context + + helper *controllerpkg.Helper + + // To allow injection for testing. + syncHandler func(ctx context.Context, key string) error + + challengeLister cmlisters.ChallengeLister + issuerLister cmlisters.IssuerLister + clusterIssuerLister cmlisters.ClusterIssuerLister + secretLister corelisters.SecretLister + + // ACME challenge solvers are instantiated once at the time of controller + // construction. + // This also allows for easy mocking of the different challenge mechanisms. + dnsSolver solver + httpSolver solver + + watchedInformers []cache.InformerSynced + queue workqueue.RateLimitingInterface +} + +func New(ctx *controllerpkg.Context) *Controller { + ctrl := &Controller{Context: *ctx} + ctrl.syncHandler = ctrl.processNextWorkItem + + // exponentially back-off self checks, with a base of 2s and max wait of 20s + ctrl.queue = workqueue.NewNamedRateLimitingQueue(workqueue.NewItemExponentialFailureRateLimiter(time.Second*2, time.Second*20), "challenges") + + challengeInformer := ctrl.SharedInformerFactory.Certmanager().V1alpha1().Challenges() + challengeInformer.Informer().AddEventHandler(&controllerpkg.QueuingEventHandler{Queue: ctrl.queue}) + ctrl.watchedInformers = append(ctrl.watchedInformers, challengeInformer.Informer().HasSynced) + ctrl.challengeLister = challengeInformer.Lister() + + // issuerInformer.Informer().AddEventHandler(&controllerpkg.QueuingEventHandler{Queue: ctrl.queue}) + issuerInformer := ctrl.SharedInformerFactory.Certmanager().V1alpha1().Issuers() + ctrl.watchedInformers = append(ctrl.watchedInformers, issuerInformer.Informer().HasSynced) + ctrl.issuerLister = issuerInformer.Lister() + + // clusterIssuerInformer.Informer().AddEventHandler(&controllerpkg.QueuingEventHandler{Queue: ctrl.queue}) + clusterIssuerInformer := ctrl.SharedInformerFactory.Certmanager().V1alpha1().ClusterIssuers() + ctrl.watchedInformers = append(ctrl.watchedInformers, clusterIssuerInformer.Informer().HasSynced) + ctrl.clusterIssuerLister = clusterIssuerInformer.Lister() + + secretInformer := ctrl.KubeSharedInformerFactory.Core().V1().Secrets() + ctrl.watchedInformers = append(ctrl.watchedInformers, secretInformer.Informer().HasSynced) + ctrl.secretLister = secretInformer.Lister() + + // instantiate listers used by the http01 solver + podInformer := ctrl.KubeSharedInformerFactory.Core().V1().Pods() + serviceInformer := ctrl.KubeSharedInformerFactory.Core().V1().Services() + ingressInformer := ctrl.KubeSharedInformerFactory.Extensions().V1beta1().Ingresses() + ctrl.watchedInformers = append(ctrl.watchedInformers, podInformer.Informer().HasSynced) + ctrl.watchedInformers = append(ctrl.watchedInformers, serviceInformer.Informer().HasSynced) + ctrl.watchedInformers = append(ctrl.watchedInformers, ingressInformer.Informer().HasSynced) + + ctrl.helper = controllerpkg.NewHelper(ctrl.issuerLister, ctrl.clusterIssuerLister) + + ctrl.httpSolver = http.NewSolver(ctx) + ctrl.dnsSolver = dns.NewSolver(ctx) + + return ctrl +} + +func (c *Controller) Run(workers int, stopCh <-chan struct{}) error { + glog.V(4).Infof("Starting %s control loop", ControllerName) + // wait for all the informer caches we depend on are synced + if !cache.WaitForCacheSync(stopCh, c.watchedInformers...) { + // c.challengeInformerSynced) { + return fmt.Errorf("error waiting for informer caches to sync") + } + + var wg sync.WaitGroup + for i := 0; i < workers; i++ { + wg.Add(1) + // TODO (@munnerz): make time.Second duration configurable + go wait.Until(func() { + defer wg.Done() + c.worker(stopCh) + }, + time.Second, stopCh) + } + <-stopCh + glog.V(4).Infof("Shutting down queue as workqueue signaled shutdown") + c.queue.ShutDown() + glog.V(4).Infof("Waiting for workers to exit...") + wg.Wait() + glog.V(4).Infof("Workers exited.") + return nil +} + +func (c *Controller) worker(stopCh <-chan struct{}) { + glog.V(4).Infof("Starting %q worker", ControllerName) + for { + obj, shutdown := c.queue.Get() + if shutdown { + break + } + + var key string + err := func(obj interface{}) error { + defer c.queue.Done(obj) + var ok bool + if key, ok = obj.(string); !ok { + return nil + } + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + ctx = util.ContextWithStopCh(ctx, stopCh) + glog.Infof("%s controller: syncing item '%s'", ControllerName, key) + if err := c.syncHandler(ctx, key); err != nil { + return err + } + c.queue.Forget(obj) + return nil + }(obj) + + if err != nil { + glog.Errorf("%s controller: Re-queuing item %q due to error processing: %s", ControllerName, key, err.Error()) + c.queue.AddRateLimited(obj) + continue + } + + glog.Infof("%s controller: Finished processing work item %q", ControllerName, key) + } + glog.V(4).Infof("Exiting %q worker loop", ControllerName) +} + +func (c *Controller) processNextWorkItem(ctx context.Context, key string) error { + namespace, name, err := cache.SplitMetaNamespaceKey(key) + if err != nil { + runtime.HandleError(fmt.Errorf("invalid resource key: %s", key)) + return nil + } + + ch, err := c.challengeLister.Challenges(namespace).Get(name) + + if err != nil { + if k8sErrors.IsNotFound(err) { + runtime.HandleError(fmt.Errorf("ch '%s' in work queue no longer exists", key)) + return nil + } + + return err + } + + return c.Sync(ctx, ch) +} + +var keyFunc = controllerpkg.KeyFunc + +const ( + ControllerName = "challenges" +) + +func init() { + controllerpkg.Register(ControllerName, func(ctx *controllerpkg.Context) controllerpkg.Interface { + return New(ctx).Run + }) +} diff --git a/pkg/controller/acmechallenges/sync.go b/pkg/controller/acmechallenges/sync.go new file mode 100644 index 000000000..dec458e79 --- /dev/null +++ b/pkg/controller/acmechallenges/sync.go @@ -0,0 +1,224 @@ +package acmechallenges + +import ( + "context" + "fmt" + "reflect" + + "github.com/golang/glog" + corev1 "k8s.io/api/core/v1" + utilerrors "k8s.io/apimachinery/pkg/util/errors" + + "github.com/jetstack/cert-manager/pkg/acme" + acmecl "github.com/jetstack/cert-manager/pkg/acme/client" + cmapi "github.com/jetstack/cert-manager/pkg/apis/certmanager/v1alpha1" + acmeapi "github.com/jetstack/cert-manager/third_party/crypto/acme" +) + +const ( + reasonDomainVerified = "DomainVerified" +) + +// solver solves ACME challenges by presenting the given token and key in an +// appropriate way given the config in the Issuer and Certificate. +type solver interface { + // Present the challenge value with the given solver. + Present(ctx context.Context, issuer cmapi.GenericIssuer, ch *cmapi.Challenge) error + // Check should return Error only if propagation check cannot be performed. + // It MUST return `false, nil` if can contact all relevant services and all is + // doing is waiting for propagation + Check(ch *cmapi.Challenge) (bool, error) + // CleanUp will remove challenge records for a given solver. + // This may involve deleting resources in the Kubernetes API Server, or + // communicating with other external components (e.g. DNS providers). + CleanUp(ctx context.Context, issuer cmapi.GenericIssuer, ch *cmapi.Challenge) error +} + +// Sync will process this ACME Challenge. +// It is the core control function for ACME challenges, and handles: +// - TODO +func (c *Controller) Sync(ctx context.Context, ch *cmapi.Challenge) (err error) { + oldChal := ch + ch = ch.DeepCopy() + + defer func() { + // TODO: replace with more efficient comparison + if reflect.DeepEqual(oldChal.Status, ch.Status) { + return + } + _, updateErr := c.CMClient.CertmanagerV1alpha1().Challenges(ch.Namespace).Update(ch) + if err != nil { + err = utilerrors.NewAggregate([]error{err, updateErr}) + } + }() + + // if a challenge is in a final state, we bail out early as there is nothing + // left for us to do here. + if acme.IsFinalState(ch.Status.State) || ch.Status.State == cmapi.Valid { + return nil + } + + acmeHelper := &acme.Helper{ + SecretLister: c.secretLister, + ClusterResourceNamespace: c.Context.ClusterResourceNamespace, + } + + genericIssuer, err := c.helper.GetGenericIssuer(ch.Spec.IssuerRef, ch.Namespace) + if err != nil { + return fmt.Errorf("error reading (cluster)issuer %q: %v", ch.Spec.IssuerRef.Name, err) + } + + cl, err := acmeHelper.ClientForIssuer(genericIssuer) + if err != nil { + return err + } + + if ch.Status.State == "" { + err := c.syncChallengeStatus(ctx, cl, ch) + if err != nil { + return err + } + + // we reperform the check from above now that we have updated the status + // if a challenge is in a final state, we bail out early as there is nothing + // left for us to do here. + if acme.IsFinalState(ch.Status.State) || ch.Status.State == cmapi.Valid { + return nil + } + } + + solver, err := c.solverFor(ch.Spec.Type) + if err != nil { + return err + } + + if !ch.Status.Presented { + err := solver.Present(ctx, genericIssuer, ch) + if err != nil { + return err + } + + ch.Status.Presented = true + } + + ok, err := solver.Check(ch) + if err != nil { + return err + } + if !ok { + ch.Status.Reason = fmt.Sprintf("Self check failed - %s challenge still propagating. Will retry after applying back-off.", ch.Spec.Type) + return fmt.Errorf(ch.Status.Reason) + } + + err = c.acceptChallenge(ctx, cl, ch) + if err != nil { + return err + } + + glog.Infof("Cleaning up challenge %s/%s", ch.Namespace, ch.Name) + err = solver.CleanUp(ctx, genericIssuer, ch) + if err != nil { + return err + } + + return nil +} + +// syncChallengeStatus will communicate with the ACME server to retrieve the current +// state of the Challenge. It will then update the Challenge's status block with the new +// state of the Challenge. +func (c *Controller) syncChallengeStatus(ctx context.Context, cl acmecl.Interface, ch *cmapi.Challenge) error { + if ch.Spec.URL == "" { + return fmt.Errorf("challenge URL is blank - challenge has not been created yet") + } + + acmeChallenge, err := cl.GetChallenge(ctx, ch.Spec.URL) + if err != nil { + return err + } + + // TODO: should we validate the State returned by the ACME server here? + cmState := cmapi.State(acmeChallenge.Status) + ch.Status.State = cmState + + return nil +} + +// presentChallenge will process a challenge by talking to the acme server and +// obtaining up to date status information. +// If the challenge is still in a pending state, it will first check propagation +// status of a challenge from previous attempt, and if missing it will 'present' the +// new challenge using the appropriate solver. +// If the check fails, an error will be returned. +// Otherwise, it will return nil. +func (c *Controller) presentChallenge(ctx context.Context, issuer cmapi.GenericIssuer, ch *cmapi.Challenge) error { + solver, err := c.solverFor(ch.Spec.Type) + if err != nil { + return err + } + + // TODO: make sure that solver.Present is noop if challenge + // is already present and all we do is waiting for propagation, + // otherwise it is spamming with errors which are not really erros + // as we are just waiting for propagation + err = solver.Present(ctx, issuer, ch) + if err != nil { + return err + } + + ch.Status.Presented = true + + // We return an error here instead of nil, as the only way for 'presentChallenge' + // to return without error is if the self check passes, which we check above. + return nil +} + +func (c *Controller) acceptChallenge(ctx context.Context, cl acmecl.Interface, ch *cmapi.Challenge) error { + glog.Infof("Accepting challenge for domain %q", ch.Spec.DNSName) + // We manually construct an ACME challenge here from our own internal type + // to save additional round trips to the ACME server. + acmeChal := &acmeapi.Challenge{ + URL: ch.Spec.URL, + Token: ch.Spec.Token, + } + acmeChal, err := cl.AcceptChallenge(ctx, acmeChal) + if err != nil { + ch.Status.State = cmapi.State(acmeChal.Status) + if acmeErr, ok := err.(*acmeapi.Error); ok { + ch.Status.Reason = fmt.Sprintf("Error accepting challenge: %v", acmeErr) + } + return err + } + + glog.Infof("Waiting for authorization for domain %q", ch.Spec.DNSName) + authorization, err := cl.WaitAuthorization(ctx, ch.Spec.AuthzURL) + if err != nil { + ch.Status.State = cmapi.State(authorization.Status) + if acmeErr, ok := err.(*acmeapi.Error); ok { + ch.Status.Reason = fmt.Sprintf("Error accepting challenge: %v", acmeErr) + } + return err + } + + ch.Status.State = cmapi.State(authorization.Status) + + if authorization.Status != acmeapi.StatusValid { + ch.Status.Reason = fmt.Sprintf("Authorization status is %q and not 'valid'", authorization.Status) + return fmt.Errorf("expected acme domain authorization status for %q to be valid, but it is %q", authorization.Identifier.Value, authorization.Status) + } + + ch.Status.Reason = "Successfully authorized domain" + c.Context.Recorder.Eventf(ch, corev1.EventTypeNormal, reasonDomainVerified, "Domain %q verified with %q validation", ch.Spec.DNSName, ch.Spec.Type) + + return nil +} + +func (c *Controller) solverFor(challengeType string) (solver, error) { + switch challengeType { + case "http-01": + return c.httpSolver, nil + case "dns-01": + return c.dnsSolver, nil + } + return nil, fmt.Errorf("no solver for %q implemented", challengeType) +} diff --git a/pkg/controller/acmeorders/checks.go b/pkg/controller/acmeorders/checks.go new file mode 100644 index 000000000..fcaf6858f --- /dev/null +++ b/pkg/controller/acmeorders/checks.go @@ -0,0 +1,3 @@ +package acmeorders + +// no checks for the acme orders controller yet diff --git a/pkg/controller/acmeorders/controller.go b/pkg/controller/acmeorders/controller.go new file mode 100644 index 000000000..e709c06bd --- /dev/null +++ b/pkg/controller/acmeorders/controller.go @@ -0,0 +1,205 @@ +package acmeorders + +import ( + "context" + "fmt" + "sync" + "time" + + "github.com/golang/glog" + k8sErrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/apimachinery/pkg/util/runtime" + "k8s.io/apimachinery/pkg/util/wait" + corelisters "k8s.io/client-go/listers/core/v1" + "k8s.io/client-go/tools/cache" + "k8s.io/client-go/util/workqueue" + + cmlisters "github.com/jetstack/cert-manager/pkg/client/listers/certmanager/v1alpha1" + controllerpkg "github.com/jetstack/cert-manager/pkg/controller" + "github.com/jetstack/cert-manager/pkg/util" +) + +type Controller struct { + controllerpkg.Context + + helper *controllerpkg.Helper + + // To allow injection for testing. + syncHandler func(ctx context.Context, key string) error + + orderLister cmlisters.OrderLister + challengeLister cmlisters.ChallengeLister + issuerLister cmlisters.IssuerLister + clusterIssuerLister cmlisters.ClusterIssuerLister + secretLister corelisters.SecretLister + + watchedInformers []cache.InformerSynced + queue workqueue.RateLimitingInterface +} + +func New(ctx *controllerpkg.Context) *Controller { + ctrl := &Controller{Context: *ctx} + ctrl.syncHandler = ctrl.processNextWorkItem + + ctrl.queue = workqueue.NewNamedRateLimitingQueue(workqueue.NewItemExponentialFailureRateLimiter(time.Second*2, time.Minute*1), "orders") + + orderInformer := ctrl.SharedInformerFactory.Certmanager().V1alpha1().Orders() + orderInformer.Informer().AddEventHandler(&controllerpkg.QueuingEventHandler{Queue: ctrl.queue}) + ctrl.watchedInformers = append(ctrl.watchedInformers, orderInformer.Informer().HasSynced) + ctrl.orderLister = orderInformer.Lister() + + // issuerInformer.Informer().AddEventHandler(&controllerpkg.QueuingEventHandler{Queue: ctrl.queue}) + issuerInformer := ctrl.SharedInformerFactory.Certmanager().V1alpha1().Issuers() + ctrl.watchedInformers = append(ctrl.watchedInformers, issuerInformer.Informer().HasSynced) + ctrl.issuerLister = issuerInformer.Lister() + + // clusterIssuerInformer.Informer().AddEventHandler(&controllerpkg.QueuingEventHandler{Queue: ctrl.queue}) + clusterIssuerInformer := ctrl.SharedInformerFactory.Certmanager().V1alpha1().ClusterIssuers() + ctrl.watchedInformers = append(ctrl.watchedInformers, clusterIssuerInformer.Informer().HasSynced) + ctrl.clusterIssuerLister = clusterIssuerInformer.Lister() + + challengeInformer := ctrl.SharedInformerFactory.Certmanager().V1alpha1().Challenges() + challengeInformer.Informer().AddEventHandler(&controllerpkg.BlockingEventHandler{WorkFunc: ctrl.handleOwnedResource}) + ctrl.watchedInformers = append(ctrl.watchedInformers, challengeInformer.Informer().HasSynced) + ctrl.challengeLister = challengeInformer.Lister() + + secretInformer := ctrl.KubeSharedInformerFactory.Core().V1().Secrets() + ctrl.watchedInformers = append(ctrl.watchedInformers, secretInformer.Informer().HasSynced) + ctrl.secretLister = secretInformer.Lister() + + ctrl.helper = controllerpkg.NewHelper(ctrl.issuerLister, ctrl.clusterIssuerLister) + + return ctrl +} + +func (c *Controller) handleOwnedResource(obj interface{}) { + metaobj, ok := obj.(metav1.Object) + if !ok { + glog.Errorf("item passed to handleOwnedResource does not implement ObjectMetaAccessor") + return + } + + ownerRefs := metaobj.GetOwnerReferences() + for _, ref := range ownerRefs { + // Parse the Group out of the OwnerReference to compare it to what was parsed out of the requested OwnerType + refGV, err := schema.ParseGroupVersion(ref.APIVersion) + if err != nil { + glog.Errorf("Could not parse OwnerReference GroupVersion: %v", err) + continue + } + + if refGV.Group == orderGvk.Group && ref.Kind == orderGvk.Kind { + // TODO: how to handle namespace of owner references? + order, err := c.orderLister.Orders(metaobj.GetNamespace()).Get(ref.Name) + if err != nil { + glog.Errorf("Error getting Order %q referenced by resource %q", ref.Name, metaobj.GetName()) + continue + } + objKey, err := keyFunc(order) + if err != nil { + runtime.HandleError(err) + continue + } + c.queue.Add(objKey) + } + } +} + +func (c *Controller) Run(workers int, stopCh <-chan struct{}) error { + glog.V(4).Infof("Starting %s control loop", ControllerName) + // wait for all the informer caches we depend on are synced + if !cache.WaitForCacheSync(stopCh, c.watchedInformers...) { + // c.challengeInformerSynced) { + return fmt.Errorf("error waiting for informer caches to sync") + } + + var wg sync.WaitGroup + for i := 0; i < workers; i++ { + wg.Add(1) + // TODO (@munnerz): make time.Second duration configurable + go wait.Until(func() { + defer wg.Done() + c.worker(stopCh) + }, + time.Second, stopCh) + } + <-stopCh + glog.V(4).Infof("Shutting down queue as workqueue signaled shutdown") + c.queue.ShutDown() + glog.V(4).Infof("Waiting for workers to exit...") + wg.Wait() + glog.V(4).Infof("Workers exited.") + return nil +} + +func (c *Controller) worker(stopCh <-chan struct{}) { + glog.V(4).Infof("Starting %q worker", ControllerName) + for { + obj, shutdown := c.queue.Get() + if shutdown { + break + } + + var key string + err := func(obj interface{}) error { + defer c.queue.Done(obj) + var ok bool + if key, ok = obj.(string); !ok { + return nil + } + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + ctx = util.ContextWithStopCh(ctx, stopCh) + glog.Infof("%s controller: syncing item '%s'", ControllerName, key) + if err := c.syncHandler(ctx, key); err != nil { + return err + } + c.queue.Forget(obj) + return nil + }(obj) + + if err != nil { + glog.Errorf("%s controller: Re-queuing item %q due to error processing: %s", ControllerName, key, err.Error()) + c.queue.AddRateLimited(obj) + continue + } + + glog.Infof("%s controller: Finished processing work item %q", ControllerName, key) + } + glog.V(4).Infof("Exiting %q worker loop", ControllerName) +} + +func (c *Controller) processNextWorkItem(ctx context.Context, key string) error { + namespace, name, err := cache.SplitMetaNamespaceKey(key) + if err != nil { + runtime.HandleError(fmt.Errorf("invalid resource key: %s", key)) + return nil + } + + order, err := c.orderLister.Orders(namespace).Get(name) + + if err != nil { + if k8sErrors.IsNotFound(err) { + runtime.HandleError(fmt.Errorf("order '%s' in work queue no longer exists", key)) + return nil + } + + return err + } + + return c.Sync(ctx, order) +} + +var keyFunc = controllerpkg.KeyFunc + +const ( + ControllerName = "orders" +) + +func init() { + controllerpkg.Register(ControllerName, func(ctx *controllerpkg.Context) controllerpkg.Interface { + return New(ctx).Run + }) +} diff --git a/pkg/controller/acmeorders/sync.go b/pkg/controller/acmeorders/sync.go new file mode 100644 index 000000000..246a63a26 --- /dev/null +++ b/pkg/controller/acmeorders/sync.go @@ -0,0 +1,452 @@ +package acmeorders + +import ( + "context" + "fmt" + "reflect" + "time" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/labels" + "k8s.io/apimachinery/pkg/selection" + utilerrors "k8s.io/apimachinery/pkg/util/errors" + + "github.com/golang/glog" + "github.com/jetstack/cert-manager/pkg/acme" + acmecl "github.com/jetstack/cert-manager/pkg/acme/client" + cmapi "github.com/jetstack/cert-manager/pkg/apis/certmanager/v1alpha1" + acmeapi "github.com/jetstack/cert-manager/third_party/crypto/acme" +) + +var ( + orderGvk = cmapi.SchemeGroupVersion.WithKind("Order") +) + +// Sync will process this ACME Order. +// It is the core control function for ACME Orders, and handles: +// - creating orders +// - deciding/validated configured challenge mechanisms +// - create a Challenge resource in order to fulfill required validations +// - waiting for Challenge resources to enter the 'ready' state +func (c *Controller) Sync(ctx context.Context, o *cmapi.Order) (err error) { + oldOrder := o + o = o.DeepCopy() + + defer func() { + // TODO: replace with more efficient comparison + if reflect.DeepEqual(oldOrder.Status, o.Status) { + return + } + _, updateErr := c.CMClient.CertmanagerV1alpha1().Orders(o.Namespace).Update(o) + if err != nil { + err = utilerrors.NewAggregate([]error{err, updateErr}) + } + }() + + acmeHelper := &acme.Helper{ + SecretLister: c.secretLister, + ClusterResourceNamespace: c.Context.ClusterResourceNamespace, + } + + genericIssuer, err := c.helper.GetGenericIssuer(o.Spec.IssuerRef, o.Namespace) + if err != nil { + return fmt.Errorf("error reading (cluster)issuer %q: %v", o.Spec.IssuerRef.Name, err) + } + + cl, err := acmeHelper.ClientForIssuer(genericIssuer) + if err != nil { + return err + } + + if o.Status.URL == "" { + err := c.createOrder(ctx, cl, genericIssuer, o) + // TODO: check for error types (perm or transient?) + if err != nil { + return err + } + } + + // if an order is in a final state, we bail out early as there is nothing + // left for us to do here. + if acme.IsFinalState(o.Status.State) { + return nil + } + + switch o.Status.State { + + // if the status field is not set, we should check the Order with the ACME + // server to try and populate it. + // If this is not possible - what should we do? (???) + case cmapi.Unknown: + err := c.syncOrderStatus(ctx, cl, o) + if err != nil { + return err + } + // TODO: we should do something more intelligent than just returning an + // error here. + return fmt.Errorf("updated unknown order state. Retrying processing after applying back-off") + + // if the current state is 'valid', we should keep polling the ACME server + // until the Order automatically progresses to the 'ready' state. + // This *should* happen automatically, after **some period of time**. + case cmapi.Valid: + waitTimeout := time.Second * 60 + // wait up to 60s for the order to enter 'ready' state + ctx, cancel := context.WithTimeout(ctx, waitTimeout) + defer cancel() + + existingState := o.Status.State + // wait for a state change (i.e. transitioning to a 'ready' state) + newState, err := c.pollForStateChange(ctx, cl, o, time.Second*5) + if err != nil { + return err + } + + // if the state has not changed, we return an error so the order can be + // re-queued. + if existingState == newState { + // TODO: should we mark the order as failed if the state doesn't transition? + // For now, we will return an error which will cause the Order to be + // requeued after a back-off has been applied. + return fmt.Errorf("expected order to transition from %q to 'ready' state, but it did not after %s", existingState, waitTimeout) + } + + // if the state has changed, but is not in a 'ready' state, then we return + // an error here. + // When the Sync function gets called again, the appropriate action will + // be taken if the order is now in a failed 'final' state for some reason. + if newState != cmapi.Ready { + return fmt.Errorf("expected order to transition to the %q state, but it is %q", cmapi.Ready, newState) + } + + if acme.IsFinalState(newState) { + return nil + } + + // this *should* be unreachable, because an order cannot transition from 'valid' + // to another non-final state, and if it does then it should be caught by + // the clauses above + return fmt.Errorf("unexpected error: order state is %q - this case should not occur, and is likely a bug", newState) + + // if the order is still pending or processing, we should continue to check + // the state of all Challenge resources (or create challenge resources) + case cmapi.Pending, cmapi.Processing: + // continue + + // this is the catch-all base case for order states that we do not recognise + default: + return fmt.Errorf("unknown order state %q", o.Status.State) + } + + // create a selector that we can use to find all existing Challenges for the order + sel, err := challengeSelectorForOrder(o) + if err != nil { + return err + } + + // get the list of exising challenges for this order + existingChallenges, err := c.challengeLister.Challenges(o.Namespace).List(sel) + if err != nil { + return err + } + + var specsToCreate []cmapi.ChallengeSpec + for _, s := range o.Status.Challenges { + create := true + for _, ch := range existingChallenges { + if s.DNSName == ch.Spec.DNSName { + create = false + break + } + } + + if !create { + break + } + + specsToCreate = append(specsToCreate, s) + } + + glog.Infof("Need to create %d challenges", len(specsToCreate)) + + var errs []error + for _, spec := range specsToCreate { + ch, err := buildChallenge(o, spec) + if err != nil { + // TODO: check if this is a perma-fail + return err + } + + ch, err = c.CMClient.CertmanagerV1alpha1().Challenges(o.Namespace).Create(ch) + if err != nil { + errs = append(errs, err) + continue + } + + existingChallenges = append(existingChallenges, ch) + } + + err = utilerrors.NewAggregate(errs) + if err != nil { + return fmt.Errorf("error ensuring Challenge resources for Order: %v", err) + } + + // if all + recheckOrderStatus := true + anyChallengesFailed := false + for _, ch := range existingChallenges { + switch ch.Status.State { + case cmapi.Pending, cmapi.Processing: + recheckOrderStatus = false + case cmapi.Failed, cmapi.Expired: + anyChallengesFailed = true + } + } + + // if at least 1 order is not valid, AND no orders have failed, we should + // just return early and not query the ACME API. + if !recheckOrderStatus && !anyChallengesFailed { + glog.Infof("Waiting for all challenges for order %q to enter 'ready' state", o.Name) + return nil + } + + // otherwise, sync the order state with the ACME API. + err = c.syncOrderStatus(ctx, cl, o) + if err != nil { + return err + } + + return nil +} + +const ( + orderNameLabelKey = "acme.cert-manager.io/order-name" +) + +func (c *Controller) createOrder(ctx context.Context, cl acmecl.Interface, issuer cmapi.GenericIssuer, o *cmapi.Order) error { + if o.Status.URL != "" { + return fmt.Errorf("refusing to recreate a new order for Order %q. Please create a new Order resource to initiate a new order", o.Name) + } + + // create a new order with the acme server + orderTemplate := acmeapi.NewOrder(o.Spec.DNSNames...) + acmeOrder, err := cl.CreateOrder(ctx, orderTemplate) + if err != nil { + return fmt.Errorf("error creating new order: %v", err) + } + + setOrderStatus(&o.Status, acmeOrder) + + chals := make([]cmapi.ChallengeSpec, len(acmeOrder.Authorizations)) + // we only set the status.challenges field when we first create the order, + // because we only create one order per Order resource. + for i, authzURL := range acmeOrder.Authorizations { + authz, err := cl.GetAuthorization(ctx, authzURL) + if err != nil { + return err + } + + cs, err := c.challengeSpecForAuthorization(ctx, cl, issuer, o, authz) + if err != nil { + return fmt.Errorf("Error constructing Challenge resource for Authorization: %v", err) + } + + chals[i] = *cs + } + o.Status.Challenges = chals + + return nil +} + +func (c *Controller) challengeSpecForAuthorization(ctx context.Context, cl acmecl.Interface, issuer cmapi.GenericIssuer, o *cmapi.Order, authz *acmeapi.Authorization) (*cmapi.ChallengeSpec, error) { + cfg, err := solverConfigurationForAuthorization(o.Spec.Config, authz) + if err != nil { + return nil, err + } + + acmeSpec := issuer.GetSpec().ACME + if acmeSpec == nil { + return nil, fmt.Errorf("issuer %q is not configured as an ACME Issuer. Cannot be used for creating ACME orders", issuer.GetObjectMeta().Name) + } + + var challenge *acmeapi.Challenge + for _, ch := range authz.Challenges { + switch { + case ch.Type == "http-01" && cfg.HTTP01 != nil && acmeSpec.HTTP01 != nil: + challenge = ch + case ch.Type == "dns-01" && cfg.DNS01 != nil && acmeSpec.DNS01 != nil: + challenge = ch + } + } + + domain := authz.Identifier.Value + if challenge == nil { + return nil, fmt.Errorf("ACME server does not allow selected challenge type or no provider is configured for domain %q", domain) + } + + key, err := keyForChallenge(cl, challenge) + if err != nil { + return nil, err + } + + return &cmapi.ChallengeSpec{ + AuthzURL: authz.URL, + Type: challenge.Type, + URL: challenge.URL, + DNSName: domain, + Token: challenge.Token, + Key: key, + Config: *cfg, + Wildcard: authz.Wildcard, + IssuerRef: o.Spec.IssuerRef, + }, nil +} + +func keyForChallenge(cl acmecl.Interface, challenge *acmeapi.Challenge) (string, error) { + var err error + switch challenge.Type { + case "http-01": + return cl.HTTP01ChallengeResponse(challenge.Token) + case "dns-01": + return cl.DNS01ChallengeRecord(challenge.Token) + default: + err = fmt.Errorf("unsupported challenge type %s", challenge.Type) + } + return "", err +} + +func solverConfigurationForAuthorization(cfgs []cmapi.DomainSolverConfig, authz *acmeapi.Authorization) (*cmapi.SolverConfig, error) { + domainToFind := authz.Identifier.Value + if authz.Wildcard { + domainToFind = "*." + domainToFind + } + for _, d := range cfgs { + for _, dom := range d.Domains { + if dom != domainToFind { + continue + } + return &d.SolverConfig, nil + } + } + return nil, fmt.Errorf("solver configuration for domain %q not found. Ensure you have configured a challenge mechanism using the certificate.spec.acme.config field", domainToFind) +} + +// syncOrderStatus will communicate with the ACME server to retrieve the current +// state of the Order. It will then update the Order's status block with the new +// state of the order. +func (c *Controller) syncOrderStatus(ctx context.Context, cl acmecl.Interface, o *cmapi.Order) error { + if o.Status.URL == "" { + return fmt.Errorf("order URL is blank - order has not been created yet") + } + + acmeOrder, err := cl.GetOrder(ctx, o.Status.URL) + if err != nil { + // TODO: handle 404 acme responses and mark the order as failed + return err + } + + setOrderStatus(&o.Status, acmeOrder) + + return nil +} + +func buildChallenge(o *cmapi.Order, chalSpec cmapi.ChallengeSpec) (*cmapi.Challenge, error) { + // TODO: select challenge to use and set these fields appropriately + ch := &cmapi.Challenge{ + ObjectMeta: metav1.ObjectMeta{ + GenerateName: o.Name + "-", + Labels: challengeLabelsForOrder(o), + OwnerReferences: []metav1.OwnerReference{*metav1.NewControllerRef(o, orderGvk)}, + }, + Spec: chalSpec, + } + + return ch, nil +} + +// setOrderStatus will populate the given OrderStatus struct with the details from +// the provided ACME Order. +func setOrderStatus(o *cmapi.OrderStatus, acmeOrder *acmeapi.Order) { + // TODO: should we validate the State returned by the ACME server here? + cmState := cmapi.State(acmeOrder.Status) + setOrderState(o, cmState) + + o.URL = acmeOrder.URL + o.FinalizeURL = acmeOrder.FinalizeURL +} + +func challengeLabelsForOrder(o *cmapi.Order) map[string]string { + return map[string]string{ + orderNameLabelKey: o.Name, + } +} + +// challengeSelectorForOrder will construct a labels.Selector that can be used to +// find Challenges associated with the given Order. +func challengeSelectorForOrder(o *cmapi.Order) (labels.Selector, error) { + lbls := challengeLabelsForOrder(o) + var reqs []labels.Requirement + for k, v := range lbls { + req, err := labels.NewRequirement(k, selection.Equals, []string{v}) + if err != nil { + return nil, err + } + reqs = append(reqs, *req) + } + return labels.NewSelector().Add(reqs...), nil +} + +// pollForStateChange will poll the ACME API every pollInterval for a change in +// the Orders state. +// This is primarily used to wait for the Order to transition from a 'valid' to +// a 'ready' state. +// If the state does not change before the context deadline is reached, the old +// state will be returned and **no error** will be returned. It is up to the caller +// to detect and handle this case appropriately. +func (c *Controller) pollForStateChange(ctx context.Context, cl acmecl.Interface, o *cmapi.Order, pollInterval time.Duration) (cmapi.State, error) { + oldState := o.Status.State + for { + // we define err here outside of the go func, so we can detect errors + // caused by attempting to sync the order state without an extra struct + // that contains (cmapi.State, error). + // This should be okay (at least for now), because there will never be two + // go funcs that are running at once which may access err at the same time. + // If this assumption is wrong however, a race may occur, so we may want + // to consider create a 'wrapper struct' in future. + var err error + select { + case newState := <-func() <-chan cmapi.State { + out := make(chan cmapi.State) + go func() { + defer close(out) + err = c.syncOrderStatus(ctx, cl, o) + out <- o.Status.State + }() + return out + }(): + if err != nil { + return newState, err + } + if newState != oldState { + return newState, nil + } + case <-ctx.Done(): + return oldState, fmt.Errorf("timeout whilst waiting for ACME order state to change from %q", oldState) + } + + // wait for pollInterval until we re-poll the ACME server for a new state + time.Sleep(pollInterval) + } +} + +// setOrderState will set the 'State' field of the given Order to 's'. +// It will set the Orders failureTime field if the state provided is classed as +// a failure state. +func setOrderState(o *cmapi.OrderStatus, s cmapi.State) { + o.State = s + // if the order is in a failure state, we should set the `failureTime` field + if acme.IsFailureState(o.State) { + t := metav1.NewTime(time.Now()) + o.FailureTime = &t + } +} diff --git a/pkg/controller/acmeorders/sync_test.go b/pkg/controller/acmeorders/sync_test.go new file mode 100644 index 000000000..e99a8ef73 --- /dev/null +++ b/pkg/controller/acmeorders/sync_test.go @@ -0,0 +1,167 @@ +package acmeorders + +import ( + "reflect" + "testing" + + "k8s.io/apimachinery/pkg/util/diff" + + "github.com/jetstack/cert-manager/pkg/apis/certmanager/v1alpha1" + "github.com/jetstack/cert-manager/third_party/crypto/acme" +) + +const ( + defaultTestNamespace = "default" +) + +func TestSolverConfigurationForAuthorization(t *testing.T) { + type testT struct { + cfg []v1alpha1.DomainSolverConfig + authz *acme.Authorization + expectedCfg *v1alpha1.SolverConfig + expectedErr bool + } + tests := map[string]testT{ + "correctly selects normal domain": testT{ + cfg: []v1alpha1.DomainSolverConfig{ + { + Domains: []string{"example.com"}, + SolverConfig: v1alpha1.SolverConfig{ + DNS01: &v1alpha1.DNS01SolverConfig{ + Provider: "correctdns", + }, + }, + }, + }, + authz: &acme.Authorization{ + Identifier: acme.AuthzID{ + Value: "example.com", + }, + }, + expectedCfg: &v1alpha1.SolverConfig{ + DNS01: &v1alpha1.DNS01SolverConfig{ + Provider: "correctdns", + }, + }, + }, + "correctly selects normal domain with multiple domains configured": testT{ + cfg: []v1alpha1.DomainSolverConfig{ + { + Domains: []string{"notexample.com", "example.com"}, + SolverConfig: v1alpha1.SolverConfig{ + DNS01: &v1alpha1.DNS01SolverConfig{ + Provider: "correctdns", + }, + }, + }, + }, + authz: &acme.Authorization{ + Identifier: acme.AuthzID{ + Value: "example.com", + }, + }, + expectedCfg: &v1alpha1.SolverConfig{ + DNS01: &v1alpha1.DNS01SolverConfig{ + Provider: "correctdns", + }, + }, + }, + "correctly selects normal domain with multiple domains configured separately": testT{ + cfg: []v1alpha1.DomainSolverConfig{ + { + Domains: []string{"example.com"}, + SolverConfig: v1alpha1.SolverConfig{ + DNS01: &v1alpha1.DNS01SolverConfig{ + Provider: "correctdns", + }, + }, + }, + { + Domains: []string{"notexample.com"}, + SolverConfig: v1alpha1.SolverConfig{ + DNS01: &v1alpha1.DNS01SolverConfig{ + Provider: "incorrectdns", + }, + }, + }, + }, + authz: &acme.Authorization{ + Identifier: acme.AuthzID{ + Value: "example.com", + }, + }, + expectedCfg: &v1alpha1.SolverConfig{ + DNS01: &v1alpha1.DNS01SolverConfig{ + Provider: "correctdns", + }, + }, + }, + "correctly selects configuration for wildcard domain": testT{ + cfg: []v1alpha1.DomainSolverConfig{ + { + Domains: []string{"example.com"}, + SolverConfig: v1alpha1.SolverConfig{ + DNS01: &v1alpha1.DNS01SolverConfig{ + Provider: "incorrectdns", + }, + }, + }, + { + Domains: []string{"*.example.com"}, + SolverConfig: v1alpha1.SolverConfig{ + DNS01: &v1alpha1.DNS01SolverConfig{ + Provider: "correctdns", + }, + }, + }, + }, + authz: &acme.Authorization{ + Wildcard: true, + Identifier: acme.AuthzID{ + // identifiers for wildcards do not include the *. prefix and + // instead set the Wildcard field on the Authz object + Value: "example.com", + }, + }, + expectedCfg: &v1alpha1.SolverConfig{ + DNS01: &v1alpha1.DNS01SolverConfig{ + Provider: "correctdns", + }, + }, + }, + "returns an error when configuration for the domain is not found": testT{ + cfg: []v1alpha1.DomainSolverConfig{ + { + Domains: []string{"notexample.com"}, + SolverConfig: v1alpha1.SolverConfig{ + DNS01: &v1alpha1.DNS01SolverConfig{ + Provider: "incorrectdns", + }, + }, + }, + }, + authz: &acme.Authorization{ + Identifier: acme.AuthzID{ + Value: "example.com", + }, + }, + expectedErr: true, + }, + } + for n, test := range tests { + t.Run(n, func(t *testing.T) { + actualCfg, err := solverConfigurationForAuthorization(test.cfg, test.authz) + if err != nil && !test.expectedErr { + t.Errorf("Expected to return non-nil error, but got %v", err) + return + } + if err == nil && test.expectedErr { + t.Errorf("Expected error, but got none") + return + } + if !reflect.DeepEqual(test.expectedCfg, actualCfg) { + t.Errorf("Expected did not equal actual: %v", diff.ObjectDiff(test.expectedCfg, actualCfg)) + } + }) + } +}