diff --git a/pkg/apis/certmanager/v1alpha1/helpers.go b/pkg/apis/certmanager/v1alpha1/helpers.go index 59a9d0297..5f2fcf36e 100644 --- a/pkg/apis/certmanager/v1alpha1/helpers.go +++ b/pkg/apis/certmanager/v1alpha1/helpers.go @@ -25,15 +25,15 @@ func (a *ACMEIssuerDNS01Config) Provider(name string) (*ACMEIssuerDNS01Provider, return nil, fmt.Errorf("provider '%s' not found", name) } -func (a *ACMECertificateConfig) ConfigForDomain(domain string) ACMECertificateDomainConfig { +func (a *ACMECertificateConfig) ConfigForDomain(domain string) *ACMECertificateDomainConfig { for _, cfg := range a.Config { for _, d := range cfg.Domains { if d == domain { - return cfg + return &cfg } } } - return ACMECertificateDomainConfig{} + return &ACMECertificateDomainConfig{} } func (c *CertificateStatus) ACMEStatus() *CertificateACMEStatus { diff --git a/pkg/issuer/acme/acme.go b/pkg/issuer/acme/acme.go index 98a4f5768..d7a10f2ec 100644 --- a/pkg/issuer/acme/acme.go +++ b/pkg/issuer/acme/acme.go @@ -11,6 +11,7 @@ import ( corev1 "k8s.io/api/core/v1" "k8s.io/client-go/kubernetes" corelisters "k8s.io/client-go/listers/core/v1" + extlisters "k8s.io/client-go/listers/extensions/v1beta1" "k8s.io/client-go/tools/record" "github.com/jetstack/cert-manager/pkg/apis/certmanager/v1alpha1" @@ -32,7 +33,10 @@ type Acme struct { cmClient clientset.Interface recorder record.EventRecorder - secretsLister corelisters.SecretLister + secretsLister corelisters.SecretLister + podsLister corelisters.PodLister + servicesLister corelisters.ServiceLister + ingressLister extlisters.IngressLister dnsSolver solver httpSolver solver @@ -61,7 +65,10 @@ func New(issuer v1alpha1.GenericIssuer, recorder record.EventRecorder, resourceNamespace string, acmeHTTP01SolverImage string, - secretsLister corelisters.SecretLister) (issuer.Interface, error) { + secretsLister corelisters.SecretLister, + podsLister corelisters.PodLister, + servicesLister corelisters.ServiceLister, + ingressLister extlisters.IngressLister) (issuer.Interface, error) { if issuer.GetSpec().ACME == nil { return nil, fmt.Errorf("acme config may not be empty") } @@ -77,13 +84,17 @@ func New(issuer v1alpha1.GenericIssuer, } return &Acme{ - issuer: issuer, - client: client, - cmClient: cmClient, - recorder: recorder, - secretsLister: secretsLister, + issuer: issuer, + client: client, + cmClient: cmClient, + recorder: recorder, + secretsLister: secretsLister, + podsLister: podsLister, + servicesLister: servicesLister, + ingressLister: ingressLister, + dnsSolver: dns.NewSolver(issuer, client, secretsLister, resourceNamespace), - httpSolver: http.NewSolver(issuer, client, secretsLister, acmeHTTP01SolverImage), + httpSolver: http.NewSolver(issuer, client, podsLister, servicesLister, ingressLister, acmeHTTP01SolverImage), issuerResourcesNamespace: resourceNamespace, }, nil } @@ -147,6 +158,9 @@ func init() { issuerResourcesNamespace, ctx.ACMEHTTP01SolverImage, ctx.KubeSharedInformerFactory.Core().V1().Secrets().Lister(), + ctx.KubeSharedInformerFactory.Core().V1().Pods().Lister(), + ctx.KubeSharedInformerFactory.Core().V1().Services().Lister(), + ctx.KubeSharedInformerFactory.Extensions().V1beta1().Ingresses().Lister(), ) }) } diff --git a/pkg/issuer/acme/http/http.go b/pkg/issuer/acme/http/http.go index e2ca77a7a..8d2edc03a 100644 --- a/pkg/issuer/acme/http/http.go +++ b/pkg/issuer/acme/http/http.go @@ -7,25 +7,16 @@ import ( "net/http" "net/url" "strings" - "sync" "time" "github.com/golang/glog" - corev1 "k8s.io/api/core/v1" - extv1beta1 "k8s.io/api/extensions/v1beta1" - k8sErrors "k8s.io/apimachinery/pkg/api/errors" - "k8s.io/apimachinery/pkg/api/resource" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" utilerrors "k8s.io/apimachinery/pkg/util/errors" - "k8s.io/apimachinery/pkg/util/intstr" "k8s.io/client-go/kubernetes" corev1listers "k8s.io/client-go/listers/core/v1" - "k8s.io/ingress/core/pkg/ingress/annotations/class" + extv1beta1listers "k8s.io/client-go/listers/extensions/v1beta1" "github.com/jetstack/cert-manager/pkg/apis/certmanager/v1alpha1" "github.com/jetstack/cert-manager/pkg/issuer/acme/http/solver" - "github.com/jetstack/cert-manager/pkg/util" - "github.com/jetstack/cert-manager/pkg/util/kube" ) const ( @@ -34,357 +25,53 @@ const ( HTTP01Timeout = time.Minute * 15 // acmeSolverListenPort is the port acmesolver should listen on acmeSolverListenPort = 8089 + // orderURLLabelKey is the key used for the order URL label on resources + // created by the HTTP01 solver + orderURLLabelKey = "certmanager.k8s.io/acme-order-url" + domainLabelKey = "certmanager.k8s.io/acme-http-domain" ) -// svcNameFunc returns the name for the service to solve the challenge -func svcNameFunc(crtName, domain string) string { - return dns1035(fmt.Sprintf("cm-%s-%s", crtName, util.RandStringRunes(5))) -} - -// ingNameFunc returns the name for the ingress to solve the challenge -func ingNameFunc(crtName, domain string) string { - return dns1035(fmt.Sprintf("cm-%s-%s", crtName, util.RandStringRunes(5))) -} - -func podNameFunc(crtName, domain string) string { - return dns1035(fmt.Sprintf("cm-%s-%s", crtName, util.RandStringRunes(5))) -} - // Solver is an implementation of the acme http-01 challenge solver protocol type Solver struct { - issuer v1alpha1.GenericIssuer - client kubernetes.Interface - secretLister corev1listers.SecretLister - solverImage string + issuer v1alpha1.GenericIssuer + client kubernetes.Interface + solverImage string + + podLister corev1listers.PodLister + serviceLister corev1listers.ServiceLister + ingressLister extv1beta1listers.IngressLister testReachability reachabilityTest requiredPasses int - - // This is a hack to record the randomly generated names of resources - // created by this Solver. This should be refactored out in future with a - // redesign of this package. It is used so resources can be cleaned up - // during the call to Cleanup() - svcNames map[string]string - ingNames map[string]string - podNames map[string]string - // lock is used to lock the solver - lock sync.Mutex } type reachabilityTest func(ctx context.Context, domain, path, key string) (bool, error) // NewSolver returns a new ACME HTTP01 solver for the given Issuer and client. -func NewSolver(issuer v1alpha1.GenericIssuer, client kubernetes.Interface, secretLister corev1listers.SecretLister, solverImage string) *Solver { +// TODO: refactor this to have fewer args +func NewSolver(issuer v1alpha1.GenericIssuer, client kubernetes.Interface, podLister corev1listers.PodLister, serviceLister corev1listers.ServiceLister, ingressLister extv1beta1listers.IngressLister, solverImage string) *Solver { return &Solver{ issuer: issuer, client: client, - secretLister: secretLister, + podLister: podLister, + serviceLister: serviceLister, + ingressLister: ingressLister, solverImage: solverImage, testReachability: testReachability, requiredPasses: 5, - svcNames: make(map[string]string), - ingNames: make(map[string]string), - podNames: make(map[string]string), } } -// labelsForCert returns some labels to add to resources related to the given -// Certificate. -// TODO: move this somewhere 'general', so that other control loops can filter -// their watches based on these labels and save watching *all* resource types. -func labelsForCert(crt *v1alpha1.Certificate, domain string) map[string]string { - return map[string]string{ - "certmanager.k8s.io/managed": "true", - "certmanager.k8s.io/domain": domain, - "certmanager.k8s.io/certificate": crt.Name, - "certmanager.k8s.io/id": util.RandStringRunes(5), - } -} - -func dns1035(s string) string { - return strings.Replace(s, ".", "-", -1) -} - -// createService will create the service required to solve this challenge -// in the target API server. -func (s *Solver) createService(crt *v1alpha1.Certificate, domain string, labels map[string]string) (*corev1.Service, error) { - svcName := svcNameFunc(crt.Name, domain) - svc := &corev1.Service{ - ObjectMeta: metav1.ObjectMeta{ - Name: svcName, - Namespace: crt.Namespace, - Labels: labels, - }, - Spec: corev1.ServiceSpec{ - Type: corev1.ServiceTypeNodePort, - Ports: []corev1.ServicePort{ - { - Name: "http", - Port: acmeSolverListenPort, - TargetPort: intstr.FromInt(acmeSolverListenPort), - }, - }, - Selector: labels, - }, - } - svc, err := s.client.CoreV1().Services(crt.Namespace).Create(svc) - if err != nil { - return nil, err - } - s.svcNames[domain] = svcName - return svc, nil -} - -// cleanupService will ensure the service created for this challenge request -// does not exist. -func (s *Solver) cleanupService(crt *v1alpha1.Certificate, domain string) error { - var svcName string - var ok bool - if svcName, ok = s.svcNames[domain]; !ok { - // no service to cleanup - return nil - } - - err := s.client.CoreV1().Services(crt.Namespace).Delete(svcName, nil) - if err != nil && !k8sErrors.IsNotFound(err) { - return fmt.Errorf("error cleaning up service: %s", err.Error()) - } - return nil -} - -// ensureIngress will ensure the ingress required to solve this challenge -// exists. -func (s *Solver) ensureIngress(crt *v1alpha1.Certificate, svcName, domain, token string, labels map[string]string) (ing *extv1beta1.Ingress, err error) { - domainCfg := crt.Spec.ACME.ConfigForDomain(domain) - if existingIngressName := domainCfg.HTTP01.Ingress; existingIngressName != "" { - ing, err = s.ensureIngressHasRule(existingIngressName, crt, svcName, domain, token, nil) - } else { - ingName := ingNameFunc(crt.Name, domain) - s.ingNames[domain] = ingName - ing, err = s.ensureIngressHasRule(ingName, crt, svcName, domain, token, labels) - } - - if err != nil { - return nil, err - } - - return kube.EnsureIngress(s.client, ing) -} - -// cleanupIngress will remove the rules added by cert-manager to an existing -// ingress, or delete the ingress if an existing ingress name is not specified -// on the certificate. -func (s *Solver) cleanupIngress(crt *v1alpha1.Certificate, svcName, domain, token string, labels map[string]string) error { - domainCfg := crt.Spec.ACME.ConfigForDomain(domain) - existingIngressName := domainCfg.HTTP01.Ingress - if existingIngressName == "" { - var ingName string - var ok bool - if ingName, ok = s.ingNames[domain]; !ok { - // no service to cleanup - return nil - } - err := s.client.ExtensionsV1beta1().Ingresses(crt.Namespace).Delete(ingName, nil) - if err != nil && !k8sErrors.IsNotFound(err) { - return fmt.Errorf("error cleaning up ingress: %s", err.Error()) - } - return nil - } - - ing, err := s.client.ExtensionsV1beta1().Ingresses(crt.Namespace).Get(existingIngressName, metav1.GetOptions{}) - - if err != nil && !k8sErrors.IsNotFound(err) { - return fmt.Errorf("error cleaning up ingress: %s", err.Error()) - } - - ingPathToDel := ingressPath(token, svcName) -Outer: - for _, rule := range ing.Spec.Rules { - if rule.Host == domain { - if rule.HTTP == nil { - return nil - } - for i, path := range rule.HTTP.Paths { - if path.Path == ingPathToDel.Path { - rule.HTTP.Paths = append(rule.HTTP.Paths[:i], rule.HTTP.Paths[i+1:]...) - break Outer - } - } - } - } - - _, err = s.client.ExtensionsV1beta1().Ingresses(ing.Namespace).Update(ing) - - if err != nil { - return fmt.Errorf("error cleaning up ingress: %s", err.Error()) - } - - return nil -} - -// ensureIngressHasRule will return an Ingress resource that contains the rule -// required to solve the ACME challenge request for the given domain. If an -// ingress named `ingName` already exists, it will be updated to contain the -// required rule and returned. Otherwise, a new Ingress resource is returned. -func (s *Solver) ensureIngressHasRule(ingName string, crt *v1alpha1.Certificate, svcName, domain, token string, labels map[string]string) (ing *extv1beta1.Ingress, err error) { - domainCfg := crt.Spec.ACME.ConfigForDomain(domain) - ing, err = s.client.ExtensionsV1beta1().Ingresses(crt.Namespace).Get(ingName, metav1.GetOptions{}) - if err != nil && !k8sErrors.IsNotFound(err) { - return nil, fmt.Errorf("error checking for existing ingress when ensuring ingress: %s", err.Error()) - } - if ing == nil { - ing = &extv1beta1.Ingress{} - } - - ing.Name = ingName - ing.Namespace = crt.Namespace - if ing.Annotations == nil { - ing.Annotations = make(map[string]string) - } - if domainCfg.HTTP01.IngressClass != nil { - ing.Annotations[class.IngressKey] = *domainCfg.HTTP01.IngressClass - } - if ing.Labels == nil { - ing.Labels = make(map[string]string) - } - for k, v := range labels { - ing.Labels[k] = v - } - - ingPathToAdd := ingressPath(token, svcName) - - for i, rule := range ing.Spec.Rules { - if rule.Host == domain { - http := rule.HTTP - if http == nil { - http = &extv1beta1.HTTPIngressRuleValue{} - ing.Spec.Rules[i].HTTP = http - } - http.Paths = append(http.Paths, ingPathToAdd) - return ing, nil - } - } - - ing.Spec.Rules = append(ing.Spec.Rules, extv1beta1.IngressRule{ - Host: domain, - IngressRuleValue: extv1beta1.IngressRuleValue{ - HTTP: &extv1beta1.HTTPIngressRuleValue{ - Paths: []extv1beta1.HTTPIngressPath{ingPathToAdd}, - }, - }, - }) - - return ing, nil -} - -// ingressPath returns the ingress HTTPIngressPath object needed to solve this -// challenge. -func ingressPath(token, serviceName string) extv1beta1.HTTPIngressPath { - return extv1beta1.HTTPIngressPath{ - Path: fmt.Sprintf("%s/%s", solver.HTTPChallengePath, token), - Backend: extv1beta1.IngressBackend{ - ServiceName: serviceName, - ServicePort: intstr.FromInt(acmeSolverListenPort), - }, - } -} - -// ensurePod will ensure the pod required to solve this challenge exists in the -// Kubernetes API server. -func (s *Solver) ensurePod(crt *v1alpha1.Certificate, domain, token, key string, labels map[string]string) (*corev1.Pod, error) { - podName := podNameFunc(crt.Name, domain) - - err := s.client.CoreV1().Pods(crt.Namespace).Delete(podName, nil) - if err != nil && !k8sErrors.IsNotFound(err) { - return nil, fmt.Errorf("error removing old pod when creating new pod resource: %s", err.Error()) - } - - s.podNames[domain] = podName - - return s.client.CoreV1().Pods(crt.Namespace).Create(&corev1.Pod{ - ObjectMeta: metav1.ObjectMeta{ - Name: podName, - Namespace: crt.Namespace, - Labels: labels, - }, - Spec: corev1.PodSpec{ - RestartPolicy: corev1.RestartPolicyOnFailure, - Containers: []corev1.Container{ - { - Name: "acmesolver", - // TODO: use an image as specified as a config option - Image: s.solverImage, - ImagePullPolicy: corev1.PullIfNotPresent, - // TODO: replace this with some kind of cmdline generator - Args: []string{ - fmt.Sprintf("--listen-port=%d", acmeSolverListenPort), - fmt.Sprintf("--domain=%s", domain), - fmt.Sprintf("--token=%s", token), - fmt.Sprintf("--key=%s", key), - }, - Resources: corev1.ResourceRequirements{ - Requests: corev1.ResourceList{ - corev1.ResourceCPU: resource.MustParse("10m"), - corev1.ResourceMemory: resource.MustParse("2Mi"), - }, - }, - Ports: []corev1.ContainerPort{ - { - Name: "http", - ContainerPort: acmeSolverListenPort, - }, - }, - }, - }, - }, - }) -} - -func (s *Solver) cleanupPod(crt *v1alpha1.Certificate, domain string) error { - var podName string - var ok bool - if podName, ok = s.podNames[domain]; !ok { - // no pod to cleanup - return nil - } - - propPolicy := metav1.DeletePropagationBackground - err := s.client.CoreV1().Pods(crt.Namespace).Delete(podName, &metav1.DeleteOptions{ - PropagationPolicy: &propPolicy, - }) - - if err != nil && !k8sErrors.IsNotFound(err) { - return fmt.Errorf("error cleaning up pod '%s': %s", podName, err.Error()) - } - return nil -} - -// Present will create the required service, update/create the required ingress -// and created a Kubernetes Pod to solve the HTTP01 challenge +// Present will realise the resources required to solve the given HTTP01 +// challenge validation in the apiserver. If those resources already exist, it +// will return nil (i.e. this function is idempotent). func (s *Solver) Present(ctx context.Context, crt *v1alpha1.Certificate, domain, token, key string) error { - s.lock.Lock() - defer s.lock.Unlock() - labels := labelsForCert(crt, domain) - - svc, err := s.createService(crt, domain, labels) - - if err != nil { - return fmt.Errorf("error ensuring http01 challenge service: %s", err.Error()) - } - - _, err = s.ensureIngress(crt, svc.Name, domain, token, labels) - - if err != nil { - return fmt.Errorf("error ensuring http01 challenge ingress: %s", err.Error()) - } - - _, err = s.ensurePod(crt, domain, token, key, labels) - - if err != nil { - return fmt.Errorf("error ensuring http01 challenge pod: %s", err.Error()) - } - - return nil + var errs []error + _, podErr := s.ensurePod(crt, domain, token, key) + _, svcErr := s.ensureService(crt, domain, token, key) + _, ingressErr := s.ensureIngress(crt, domain, token, key) + errs = append(errs, podErr, svcErr, ingressErr) + return utilerrors.NewAggregate(errs) } func (s *Solver) Check(domain, token, key string) (bool, error) { @@ -403,6 +90,20 @@ func (s *Solver) Check(domain, token, key string) (bool, error) { return true, nil } +// CleanUp will ensure the created service, ingress and pod are clean/deleted of any +// cert-manager created data. +func (s *Solver) CleanUp(ctx context.Context, crt *v1alpha1.Certificate, domain, token, key string) error { + var errs []error + errs = append(errs, s.cleanupPods(crt, domain)) + errs = append(errs, s.cleanupServices(crt, domain)) + errs = append(errs, s.cleanupIngresses(crt, domain, token)) + return utilerrors.NewAggregate(errs) +} + +func dns1035(s string) string { + return strings.Replace(s, ".", "-", -1) +} + // testReachability will attempt to connect to the 'domain' with 'path' and // check if the returned body equals 'key' func testReachability(ctx context.Context, domain, path, key string) (bool, error) { @@ -435,25 +136,3 @@ func testReachability(ctx context.Context, domain, path, key string) (bool, erro return false, nil } - -// CleanUp will ensure the created service and ingress are clean/deleted of any -// cert-manager created data. -func (s *Solver) CleanUp(ctx context.Context, crt *v1alpha1.Certificate, domain, token, key string) error { - s.lock.Lock() - defer s.lock.Unlock() - var errs []error - if err := s.cleanupPod(crt, domain); err != nil { - errs = append(errs, fmt.Errorf("[%s] Error cleaning up pod: %s", domain, err.Error())) - } - if err := s.cleanupService(crt, domain); err != nil { - errs = append(errs, fmt.Errorf("[%s] Error cleaning up service: %s", domain, err.Error())) - } - if err := s.cleanupIngress(crt, svcNameFunc(crt.Name, domain), domain, token, labelsForCert(crt, domain)); err != nil { - errs = append(errs, fmt.Errorf("[%s] Error cleaning up ingress: %s", domain, err.Error())) - } - if errs != nil { - return utilerrors.NewAggregate(errs) - } - - return nil -} diff --git a/pkg/issuer/acme/http/ingress.go b/pkg/issuer/acme/http/ingress.go new file mode 100644 index 000000000..003641a1f --- /dev/null +++ b/pkg/issuer/acme/http/ingress.go @@ -0,0 +1,227 @@ +package http + +import ( + "fmt" + + extv1beta1 "k8s.io/api/extensions/v1beta1" + k8sErrors "k8s.io/apimachinery/pkg/api/errors" + 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" + "k8s.io/apimachinery/pkg/util/intstr" + "k8s.io/ingress/core/pkg/ingress/annotations/class" + + "github.com/golang/glog" + "github.com/jetstack/cert-manager/pkg/apis/certmanager/v1alpha1" + "github.com/jetstack/cert-manager/pkg/issuer/acme/http/solver" +) + +// getIngressesForCertificate returns a list of Ingresses that were created to solve +// http challenges for the given domain +func (s *Solver) getIngressesForCertificate(crt *v1alpha1.Certificate, domain string) ([]*extv1beta1.Ingress, error) { + if crt.Status.ACME.OrderURL == "" { + return []*extv1beta1.Ingress{}, nil + } + podLabels := podLabels(crt, domain) + selector := labels.NewSelector() + for key, val := range podLabels { + req, err := labels.NewRequirement(key, selection.Equals, []string{val}) + if err != nil { + return nil, err + } + selector = selector.Add(*req) + } + + ingressList, err := s.ingressLister.Ingresses(crt.Namespace).List(selector) + if err != nil { + return nil, err + } + + var relevantIngresses []*extv1beta1.Ingress + for _, ingress := range ingressList { + if !metav1.IsControlledBy(ingress, crt) { + glog.Infof("Found ingress %q with acme-order-url annotation set to that of Certificate %q"+ + "but it is not owned by the Certificate resource, so skipping it.", ingress.Name, crt.Name) + continue + } + if ingress.Labels == nil || + ingress.Labels[domainLabelKey] != domain { + continue + } + relevantIngresses = append(relevantIngresses, ingress) + } + + return relevantIngresses, nil +} + +// ensureIngress will ensure the ingress required to solve this challenge +// exists, or if an existing ingress is specified on the secret will ensure +// that the ingress has an appropriate challenge path configured +func (s *Solver) ensureIngress(crt *v1alpha1.Certificate, svcName, domain, token string) (ing *extv1beta1.Ingress, err error) { + domainCfg := crt.Spec.ACME.ConfigForDomain(domain) + if domainCfg == nil { + return nil, fmt.Errorf("no ACME challenge configuration found for domain %q", domain) + } + httpDomainCfg := domainCfg.HTTP01 + if httpDomainCfg == nil { + httpDomainCfg = &v1alpha1.ACMECertificateHTTP01Config{} + } + if httpDomainCfg != nil && + httpDomainCfg.Ingress != "" { + return s.addChallengePathToIngress(crt, svcName, domain, token, *httpDomainCfg) + } + return s.createIngress(crt, svcName, domain, token, *httpDomainCfg) +} + +// createIngress will create a challenge solving pod for the given certificate, +// domain, token and key. +func (s *Solver) createIngress(crt *v1alpha1.Certificate, svcName, domain, token string, domainCfg v1alpha1.ACMECertificateHTTP01Config) (*extv1beta1.Ingress, error) { + podLabels := podLabels(crt, domain) + // TODO: add additional annotations to help workaround problematic ingress controller behaviours + ingAnnotaions := make(map[string]string) + if ingClass := domainCfg.IngressClass; ingClass != nil { + ingAnnotaions[class.IngressKey] = *ingClass + } + + ingPathToAdd := ingressPath(token, svcName) + + return s.client.ExtensionsV1beta1().Ingresses(crt.Namespace).Create(&extv1beta1.Ingress{ + ObjectMeta: metav1.ObjectMeta{ + GenerateName: "cm-acme-http-solver-", + Namespace: crt.Namespace, + Labels: podLabels, + Annotations: ingAnnotaions, + }, + Spec: extv1beta1.IngressSpec{ + Rules: []extv1beta1.IngressRule{ + { + Host: domain, + IngressRuleValue: extv1beta1.IngressRuleValue{ + HTTP: &extv1beta1.HTTPIngressRuleValue{ + Paths: []extv1beta1.HTTPIngressPath{ingPathToAdd}, + }, + }, + }, + }, + }, + }) +} + +func (s *Solver) addChallengePathToIngress(crt *v1alpha1.Certificate, svcName, domain, token string, domainCfg v1alpha1.ACMECertificateHTTP01Config) (*extv1beta1.Ingress, error) { + ing, err := s.ingressLister.Ingresses(crt.Namespace).Get(domainCfg.Ingress) + if err != nil { + return nil, err + } + + ingPathToAdd := ingressPath(token, svcName) + // check for an existing Rule for the given domain on the ingress resource + for _, rule := range ing.Spec.Rules { + if rule.Host == domain { + if rule.HTTP == nil { + rule.HTTP = &extv1beta1.HTTPIngressRuleValue{} + } + for i, p := range rule.HTTP.Paths { + // if an existing path exists on this rule for the challenge path, + // we overwrite it else we'll confuse ingress controllers + if p.Path == ingPathToAdd.Path { + rule.HTTP.Paths[i] = ingPathToAdd + return s.client.ExtensionsV1beta1().Ingresses(ing.Namespace).Update(ing) + } + } + rule.HTTP.Paths = append(rule.HTTP.Paths, ingPathToAdd) + return s.client.ExtensionsV1beta1().Ingresses(ing.Namespace).Update(ing) + } + } + + // if one doesn't exist, create a new IngressRule + ing.Spec.Rules = append(ing.Spec.Rules, extv1beta1.IngressRule{ + Host: domain, + IngressRuleValue: extv1beta1.IngressRuleValue{ + HTTP: &extv1beta1.HTTPIngressRuleValue{ + Paths: []extv1beta1.HTTPIngressPath{ingPathToAdd}, + }, + }, + }) + return s.client.ExtensionsV1beta1().Ingresses(ing.Namespace).Update(ing) +} + +// cleanupIngresses will remove the rules added by cert-manager to an existing +// ingress, or delete the ingress if an existing ingress name is not specified +// on the certificate. +func (s *Solver) cleanupIngresses(crt *v1alpha1.Certificate, domain, token string) error { + domainCfg := crt.Spec.ACME.ConfigForDomain(domain) + httpDomainCfg := domainCfg.HTTP01 + if httpDomainCfg == nil { + httpDomainCfg = &v1alpha1.ACMECertificateHTTP01Config{} + } + existingIngressName := httpDomainCfg.Ingress + + // if the 'ingress' field on the domain config is not set, we need to delete + // the ingress resources that cert-manager has created to solve the challenge + if existingIngressName == "" { + ingresses, err := s.getIngressesForCertificate(crt, domain) + if err != nil { + return err + } + var errs []error + for _, ingress := range ingresses { + // TODO: should we call DeleteCollection here? We'd need to somehow + // also ensure ownership as part of that request using a FieldSelector. + err := s.client.ExtensionsV1beta1().Ingresses(ingress.Namespace).Delete(ingress.Name, nil) + if err != nil { + errs = append(errs, err) + } + } + return utilerrors.NewAggregate(errs) + } + + // otherwise, we need to remove any cert-manager added rules from the ingress resource + ing, err := s.client.ExtensionsV1beta1().Ingresses(crt.Namespace).Get(existingIngressName, metav1.GetOptions{}) + if k8sErrors.IsNotFound(err) { + glog.Infof("attempt to cleanup Ingress %q of ACME challenge path failed: %v", existingIngressName, err) + return nil + } + if err != nil { + return err + } + + ingPathToDel := solverPathFn(token) +Outer: + for _, rule := range ing.Spec.Rules { + if rule.Host == domain { + if rule.HTTP == nil { + return nil + } + for i, path := range rule.HTTP.Paths { + if path.Path == ingPathToDel { + rule.HTTP.Paths = append(rule.HTTP.Paths[:i], rule.HTTP.Paths[i+1:]...) + break Outer + } + } + } + } + + _, err = s.client.ExtensionsV1beta1().Ingresses(ing.Namespace).Update(ing) + if err != nil { + return err + } + + return nil +} + +// ingressPath returns the ingress HTTPIngressPath object needed to solve this +// challenge. +func ingressPath(token, serviceName string) extv1beta1.HTTPIngressPath { + return extv1beta1.HTTPIngressPath{ + Path: solverPathFn(token), + Backend: extv1beta1.IngressBackend{ + ServiceName: serviceName, + ServicePort: intstr.FromInt(acmeSolverListenPort), + }, + } +} + +var solverPathFn = func(token string) string { + return fmt.Sprintf("%s/%s", solver.HTTPChallengePath, token) +} diff --git a/pkg/issuer/acme/http/pod.go b/pkg/issuer/acme/http/pod.go new file mode 100644 index 000000000..51dffa215 --- /dev/null +++ b/pkg/issuer/acme/http/pod.go @@ -0,0 +1,138 @@ +package http + +import ( + "fmt" + + "github.com/golang/glog" + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/api/resource" + 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/jetstack/cert-manager/pkg/apis/certmanager/v1alpha1" +) + +func podLabels(crt *v1alpha1.Certificate, domain string) map[string]string { + return map[string]string{ + domainLabelKey: domain, + orderURLLabelKey: crt.Status.ACME.OrderURL, + } +} + +func (s *Solver) ensurePod(crt *v1alpha1.Certificate, domain, token, key string) (*corev1.Pod, error) { + existingPods, err := s.getPodsForCertificate(crt, domain) + if err != nil { + return nil, err + } + var pod *corev1.Pod + if len(existingPods) > 0 { + // we will only care about the first pod if there are multiple returned + // here. The others should be cleaned up after a call to CleanUp is + // complete. + pod = existingPods[0] + } + if len(existingPods) == 0 { + glog.Infof("No existing HTTP01 challenge solver pod found for Certificate %q. One will be created.") + pod, err = s.createPod(crt, domain, token, key) + if err != nil { + return nil, err + } + } + return pod, nil +} + +// getPodsForCertificate returns a list of pods that were created to solve +// http challenges for the given domain +func (s *Solver) getPodsForCertificate(crt *v1alpha1.Certificate, domain string) ([]*corev1.Pod, error) { + if crt.Status.ACME.OrderURL == "" { + return nil, fmt.Errorf("Certificate order URL must be set") + } + podLabels := podLabels(crt, domain) + orderSelector := labels.NewSelector() + for key, val := range podLabels { + req, err := labels.NewRequirement(key, selection.Equals, []string{val}) + if err != nil { + return nil, err + } + orderSelector = orderSelector.Add(*req) + } + + podList, err := s.podLister.Pods(crt.Namespace).List(orderSelector) + if err != nil { + return nil, err + } + + var relevantPods []*corev1.Pod + for _, pod := range podList { + if !metav1.IsControlledBy(pod, crt) { + glog.Infof("Found pod %q with acme-order-url annotation set to that of Certificate %q"+ + "but it is not owned by the Certificate resource, so skipping it.", pod.Name, crt.Name) + continue + } + relevantPods = append(relevantPods, pod) + } + + return relevantPods, nil +} + +func (s *Solver) cleanupPods(crt *v1alpha1.Certificate, domain string) error { + pods, err := s.getPodsForCertificate(crt, domain) + if err != nil { + return err + } + var errs []error + for _, pod := range pods { + // TODO: should we call DeleteCollection here? We'd need to somehow + // also ensure ownership as part of that request using a FieldSelector. + err := s.client.CoreV1().Pods(pod.Namespace).Delete(pod.Name, nil) + if err != nil { + errs = append(errs, err) + } + } + return utilerrors.NewAggregate(errs) +} + +// createPod will create a challenge solving pod for the given certificate, +// domain, token and key. +func (s *Solver) createPod(crt *v1alpha1.Certificate, domain, token, key string) (*corev1.Pod, error) { + podLabels := podLabels(crt, domain) + return s.client.CoreV1().Pods(crt.Namespace).Create(&corev1.Pod{ + ObjectMeta: metav1.ObjectMeta{ + GenerateName: "cm-acme-http-solver-", + Namespace: crt.Namespace, + Labels: podLabels, + }, + Spec: corev1.PodSpec{ + RestartPolicy: corev1.RestartPolicyOnFailure, + Containers: []corev1.Container{ + { + Name: "acmesolver", + // TODO: use an image as specified as a config option + Image: s.solverImage, + ImagePullPolicy: corev1.PullIfNotPresent, + // TODO: replace this with some kind of cmdline generator + Args: []string{ + fmt.Sprintf("--listen-port=%d", acmeSolverListenPort), + fmt.Sprintf("--domain=%s", domain), + fmt.Sprintf("--token=%s", token), + fmt.Sprintf("--key=%s", key), + }, + Resources: corev1.ResourceRequirements{ + Requests: corev1.ResourceList{ + corev1.ResourceCPU: resource.MustParse("10m"), + corev1.ResourceMemory: resource.MustParse("2Mi"), + }, + }, + Ports: []corev1.ContainerPort{ + { + Name: "http", + ContainerPort: acmeSolverListenPort, + }, + }, + }, + }, + }, + }) +} diff --git a/pkg/issuer/acme/http/service.go b/pkg/issuer/acme/http/service.go new file mode 100644 index 000000000..789288c33 --- /dev/null +++ b/pkg/issuer/acme/http/service.go @@ -0,0 +1,114 @@ +package http + +import ( + corev1 "k8s.io/api/core/v1" + 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" + "k8s.io/apimachinery/pkg/util/intstr" + + "github.com/golang/glog" + "github.com/jetstack/cert-manager/pkg/apis/certmanager/v1alpha1" +) + +func (s *Solver) ensureService(crt *v1alpha1.Certificate, domain, token, key string) (*corev1.Service, error) { + existingServices, err := s.getServicesForCertificate(crt, domain) + if err != nil { + return nil, err + } + var service *corev1.Service + if len(existingServices) > 0 { + // we will only care about the first service if there are multiple returned + // here. The others should be cleaned up after a call to CleanUp is + // complete. + service = existingServices[0] + } + if len(existingServices) == 0 { + glog.Infof("No existing HTTP01 challenge solver service found for Certificate %q. One will be created.") + service, err = s.createService(crt, domain) + if err != nil { + return nil, err + } + } + return service, nil +} + +// getServicesForCertificate returns a list of services that were created to solve +// http challenges for the given domain +func (s *Solver) getServicesForCertificate(crt *v1alpha1.Certificate, domain string) ([]*corev1.Service, error) { + if crt.Status.ACME.OrderURL == "" { + return []*corev1.Service{}, nil + } + podLabels := podLabels(crt, domain) + selector := labels.NewSelector() + for key, val := range podLabels { + req, err := labels.NewRequirement(key, selection.Equals, []string{val}) + if err != nil { + return nil, err + } + selector = selector.Add(*req) + } + + serviceList, err := s.serviceLister.Services(crt.Namespace).List(selector) + if err != nil { + return nil, err + } + + var relevantServices []*corev1.Service + for _, service := range serviceList { + if !metav1.IsControlledBy(service, crt) { + glog.Infof("Found service %q with acme-order-url annotation set to that of Certificate %q"+ + "but it is not owned by the Certificate resource, so skipping it.", service.Name, crt.Name) + continue + } + if service.Labels == nil || + service.Labels[domainLabelKey] != domain { + continue + } + relevantServices = append(relevantServices, service) + } + + return relevantServices, nil +} + +// createService will create the service required to solve this challenge +// in the target API server. +func (s *Solver) createService(crt *v1alpha1.Certificate, domain string) (*corev1.Service, error) { + podLabels := podLabels(crt, domain) + return s.client.CoreV1().Services(crt.Namespace).Create(&corev1.Service{ + ObjectMeta: metav1.ObjectMeta{ + GenerateName: "cm-acme-http-solver-", + Namespace: crt.Namespace, + Labels: podLabels, + }, + Spec: corev1.ServiceSpec{ + Type: corev1.ServiceTypeNodePort, + Ports: []corev1.ServicePort{ + { + Name: "http", + Port: acmeSolverListenPort, + TargetPort: intstr.FromInt(acmeSolverListenPort), + }, + }, + Selector: podLabels, + }, + }) +} + +func (s *Solver) cleanupServices(crt *v1alpha1.Certificate, domain string) error { + services, err := s.getServicesForCertificate(crt, domain) + if err != nil { + return err + } + var errs []error + for _, service := range services { + // TODO: should we call DeleteCollection here? We'd need to somehow + // also ensure ownership as part of that request using a FieldSelector. + err := s.client.CoreV1().Services(service.Namespace).Delete(service.Name, nil) + if err != nil { + errs = append(errs, err) + } + } + return utilerrors.NewAggregate(errs) +} diff --git a/pkg/issuer/acme/prepare.go b/pkg/issuer/acme/prepare.go index 6701d4cb6..e204c92cc 100644 --- a/pkg/issuer/acme/prepare.go +++ b/pkg/issuer/acme/prepare.go @@ -276,6 +276,11 @@ func partitionAuthorizations(authzs ...*acme.Authorization) (failed, pending, va return failed, pending, valid } +// pickChallengeType will select a challenge type to used based on the types +// offered by the ACME server (i.e. auth.Challenges), the options configured on +// the Certificate resource (cfg) and the providers configured on the +// corresponding Issuer resource. If there is no challenge type that can be +// used, it will return an error. func (a *Acme) pickChallengeType(domain string, auth *acme.Authorization, cfg []v1alpha1.ACMECertificateDomainConfig) (string, error) { for _, d := range cfg { for _, dom := range d.Domains {