Make HTTP challenge solver async
This commit is contained in:
parent
de59fc70ee
commit
7a44cb3e0e
@ -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 {
|
||||
|
||||
@ -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(),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
227
pkg/issuer/acme/http/ingress.go
Normal file
227
pkg/issuer/acme/http/ingress.go
Normal file
@ -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)
|
||||
}
|
||||
138
pkg/issuer/acme/http/pod.go
Normal file
138
pkg/issuer/acme/http/pod.go
Normal file
@ -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,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
114
pkg/issuer/acme/http/service.go
Normal file
114
pkg/issuer/acme/http/service.go
Normal file
@ -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)
|
||||
}
|
||||
@ -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 {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user