From 95cba8ab5f012d7974582eeceefd2562f5ca94db Mon Sep 17 00:00:00 2001 From: James Munnelly Date: Fri, 21 Jul 2017 15:18:39 +0100 Subject: [PATCH] Add acme issuer. Implement 'Setup' method. Now manages ACME accounts. --- cmd/controller/main.go | 24 ++- pkg/controller/base.go | 62 -------- pkg/controller/certificates/controller.go | 62 ++++---- pkg/controller/certificates/sync.go | 145 ++++++++++++++++- pkg/controller/certificates/sync_test.go | 49 ++++++ pkg/controller/controller.go | 81 ++++++++++ pkg/controller/issuers/controller.go | 46 ++++++ pkg/controller/issuers/sync.go | 30 ++++ pkg/issuer/acme/account.go | 186 ++++++++++++++++++++++ pkg/issuer/acme/acme.go | 99 ++++++++++++ pkg/issuer/acme/dns/dns.go | 1 + pkg/issuer/acme/http/constants.go | 6 + pkg/issuer/acme/http/http.go | 73 +++++++++ pkg/issuer/issuer.go | 30 ++++ pkg/util/util.go | 14 ++ 15 files changed, 799 insertions(+), 109 deletions(-) delete mode 100644 pkg/controller/base.go create mode 100644 pkg/controller/certificates/sync_test.go create mode 100644 pkg/controller/controller.go create mode 100644 pkg/controller/issuers/controller.go create mode 100644 pkg/controller/issuers/sync.go create mode 100644 pkg/issuer/acme/account.go create mode 100644 pkg/issuer/acme/acme.go create mode 100644 pkg/issuer/acme/dns/dns.go create mode 100644 pkg/issuer/acme/http/constants.go create mode 100644 pkg/issuer/acme/http/http.go create mode 100644 pkg/issuer/issuer.go create mode 100644 pkg/util/util.go diff --git a/cmd/controller/main.go b/cmd/controller/main.go index a98e2cb76..f444307fd 100644 --- a/cmd/controller/main.go +++ b/cmd/controller/main.go @@ -26,10 +26,10 @@ import ( rest "k8s.io/client-go/rest" "k8s.io/client-go/tools/clientcmd" + _ "github.com/jetstack/cert-manager/pkg/apis/certmanager/install" "github.com/jetstack/cert-manager/pkg/client" "github.com/jetstack/cert-manager/pkg/controller" - "github.com/jetstack/cert-manager/pkg/controller/certificates" - "github.com/jetstack/cert-manager/pkg/controller/ingress" + "github.com/jetstack/cert-manager/pkg/controller/issuers" "github.com/jetstack/cert-manager/pkg/informers/externalversions" logpkg "github.com/jetstack/cert-manager/pkg/log" ) @@ -74,23 +74,17 @@ func main() { Logger: log, } - ingressCtrl, err := ingress.New(ctx) - - if err != nil { - log.Fatalf("error creating ingress control loop: %s", err.Error()) - } - - certificatesCtrl, err := certificates.New(ctx) - - if err != nil { - log.Fatalf("error creating certificates control loop: %s", err.Error()) - } + issuerCtrl := issuers.New(ctx) + // certificatesCtrl := certificates.New(ctx) stopCh := make(chan struct{}) factory.Start(stopCh) cmFactory.Start(stopCh) - ingressCtrl.Run(5, stopCh) - certificatesCtrl.Run(5, stopCh) + + go issuerCtrl.Run(5, stopCh) + // go certificatesCtrl.Run(5, stopCh) + + <-stopCh } // kubeConfig will return a rest.Config for communicating with the Kubernetes API server. diff --git a/pkg/controller/base.go b/pkg/controller/base.go deleted file mode 100644 index e2a8cd8ef..000000000 --- a/pkg/controller/base.go +++ /dev/null @@ -1,62 +0,0 @@ -package controller - -import ( - "reflect" - "time" - - "k8s.io/apimachinery/pkg/util/wait" - "k8s.io/client-go/tools/cache" - "k8s.io/client-go/util/workqueue" -) - -type Base struct { - Context Context - // TODO (@munnerz): come up with some way to swap out this queue type - Queue workqueue.RateLimitingInterface - Worker func() bool - - hasSynced []cache.InformerSynced -} - -func (b *Base) AddHandler(informer cache.SharedIndexInformer) { - informer.AddEventHandler(cache.ResourceEventHandlerFuncs{ - AddFunc: b.Queue.Add, - UpdateFunc: func(old, cur interface{}) { - if !reflect.DeepEqual(old, cur) { - b.Queue.Add(cur) - } - }, - DeleteFunc: b.Queue.Add, - }) - b.hasSynced = append(b.hasSynced, informer.HasSynced) -} - -// Run will start this controllers run loop, with the specified number of -// worker goroutines. It will block until a message is placed onto the stopCh. -func (b *Base) Run(workers int, stopCh <-chan struct{}) { - defer b.Queue.ShutDown() - - b.Context.Logger.Printf("Starting control loop") - - // wait for all the informer caches we depend on are synced - if !cache.WaitForCacheSync(stopCh, b.hasSynced...) { - b.Context.Logger.Errorf("error waiting for informer caches to sync") - return - } - - for i := 0; i < workers; i++ { - // TODO (@munnerz): make time.Second duration configurable - go wait.Until(b.worker, time.Second, stopCh) - } - - <-stopCh - b.Context.Logger.Printf("shutting down queue as workqueue signalled shutdown") -} - -func (b *Base) worker() { - b.Context.Logger.Printf("starting worker") - for b.Worker() { - b.Context.Logger.Printf("finished processing work item") - } - b.Context.Logger.Printf("exiting worker loop") -} diff --git a/pkg/controller/certificates/controller.go b/pkg/controller/certificates/controller.go index c7ab36309..1efb6365e 100644 --- a/pkg/controller/certificates/controller.go +++ b/pkg/controller/certificates/controller.go @@ -1,46 +1,46 @@ package certificates import ( + "time" + + api "k8s.io/api/core/v1" + "k8s.io/client-go/tools/cache" "k8s.io/client-go/util/workqueue" "github.com/jetstack/cert-manager/pkg/apis/certmanager/v1alpha1" "github.com/jetstack/cert-manager/pkg/controller" + "github.com/juju/ratelimit" ) -type Controller struct { - *controller.Base +func New(ctx controller.Context) controller.Controller { + return controller.Controller{ + Context: &ctx, + Queue: workqueue.NewRateLimitingQueue( + workqueue.NewMaxOfRateLimiter( + workqueue.NewItemExponentialFailureRateLimiter(15*time.Second, time.Minute), + // 10 qps, 100 bucket size. This is only for retry speed and its only the overall factor (not per item) + &workqueue.BucketRateLimiter{Bucket: ratelimit.NewBucketWithRate(float64(10), int64(100))}, + ), + ), + Worker: processNextWorkItem, + Informers: []cache.SharedIndexInformer{ + ctx.CertManagerInformerFactory.Certmanager().V1alpha1().Certificates().Informer(), + ctx.InformerFactory.Core().V1().Secrets().Informer(), + }, + } } -func New(ctx controller.Context) (*Controller, error) { - ctrl := &Controller{} - base := &controller.Base{ - Context: ctx, - Queue: workqueue.NewRateLimitingQueue(workqueue.DefaultControllerRateLimiter()), - Worker: ctrl.processNextWorkItem, - } - ctrl.Base = base - - // Start watching for changes to Certificate resources - ctrl.AddHandler(ctx.CertManagerInformerFactory.Certmanager().V1alpha1().Certificates().Informer()) - - return ctrl, nil -} - -func (c *Controller) processNextWorkItem() bool { - obj, shutdown := c.Queue.Get() - if shutdown { - return false - } - defer c.Queue.Done(obj) - - switch obj.(type) { +func processNextWorkItem(ctx controller.Context, obj interface{}) error { + ctx.Logger.Printf("obj of type %T", obj) + switch v := obj.(type) { case *v1alpha1.Certificate: - // TODO (@munnerz): lookup ingress for this certificate resource, and - // add it the the workqueue in order to ensure the ingress and - // certificate resource are in sync. + if err := sync(&ctx, v); err != nil { + return err + } + case *api.Secret: + ctx.Logger.Printf("unhandled change to Secret resource: %+v", v) default: - c.Context.Logger.Errorf("unexpected resource type (%T) in work queue", obj) + ctx.Logger.Errorf("unexpected resource type (%T) in work queue", obj) } - - return true + return nil } diff --git a/pkg/controller/certificates/sync.go b/pkg/controller/certificates/sync.go index e0ac8b774..2be9700cb 100644 --- a/pkg/controller/certificates/sync.go +++ b/pkg/controller/certificates/sync.go @@ -2,8 +2,151 @@ package certificates import ( "github.com/jetstack/cert-manager/pkg/apis/certmanager/v1alpha1" + "github.com/jetstack/cert-manager/pkg/controller" ) -func (c *Controller) sync(crt *v1alpha1.Certificate) error { +func sync(ctx *controller.Context, crt *v1alpha1.Certificate) error { + // // step zero: check if the referenced issuer exists and is ready + // issuer, err := ctx.CertManagerInformerFactory.Certmanager().V1alpha1().Issuers().Lister().Issuers(crt.Namespace).Get(crt.Spec.Issuer) + + // if err != nil { + // return fmt.Errorf("issuer '%s' for certificate '%s' does not exist", crt.Spec.Issuer, crt.Name) + // } + + // // step one: check if referenced secret exists, if not, trigger issue event + // secret, err := ctx.InformerFactory.Core().V1().Secrets().Lister().Secrets(crt.Namespace).Get(crt.Spec.SecretName) + + // if err != nil { + // // TODO (@munnerz): only issue a certificate if the call failed due to + // // no resource being found + // return c.issue(crt) + // } + + // certBytes, okcert := secret.Data[api.TLSCertKey] + // keyBytes, okkey := secret.Data[api.TLSPrivateKeyKey] + + // // check if the certificate and private key exist, if not, trigger an issue + // if !okcert || !okkey { + // return c.issue(crt) + // } + // // decode the tls certificate pem + // block, _ := pem.Decode(certBytes) + // if block == nil { + // ctx.Logger.Printf("error decoding cert PEM block in '%s'", crt.Spec.SecretName) + // return c.issue(crt) + // } + // // parse the tls certificate + // cert, err := x509.ParseCertificate(block.Bytes) + // if err != nil { + // ctx.Logger.Printf("error parsing TLS certificate in '%s': %s", crt.Spec.SecretName, err.Error()) + // return c.issue(crt) + // } + // // decode the private key pem + // block, _ = pem.Decode(keyBytes) + // if block == nil { + // ctx.Logger.Printf("error decoding private key PEM block in '%s'", crt.Spec.SecretName) + // return c.issue(crt) + // } + // // parse the private key + // key, err := x509.ParsePKCS1PrivateKey(block.Bytes) + // if err != nil { + // ctx.Logger.Printf("error parsing private key in '%s': %s", crt.Spec.SecretName, err.Error()) + // return c.issue(crt) + // } + // // validate the private key + // if err = key.Validate(); err != nil { + // ctx.Logger.Printf("private key failed validation in '%s': %s", crt.Spec.SecretName, err.Error()) + // return c.issue(crt) + // } + // // step two: check if referenced secret is valid for listed domains. if not, return failure + // if !equalUnsorted(crt.Spec.Domains, cert.DNSNames) { + // ctx.Logger.Printf("list of domains on certificate do not match domains in spec") + // return c.issue(crt) + // } + // // step three: check if referenced secret is valid (after start & before expiry) + // if time.Now().Sub(cert.NotAfter) > time.Hour*(24*30) { + // return c.renew(crt) + // } + return nil } + +// // issue will attempt to retrieve a certificate from the specified issuer, or +// // return an error on failure. If retrieval is succesful, the certificate data +// // and private key will be stored in the named secret +// func (c *Controller) issue(crt *v1alpha1.Certificate) error { +// i, err := issuer.IssuerFor(crt) +// if err != nil { +// return err +// } + +// cert, key, err := i.Issue(&ctx, crt) +// if err != nil { +// return fmt.Errorf("error issuing certificate: %s", err.Error()) +// } + +// // TODO: support updating resources +// _, err = ctx.Client.Secrets(crt.Namespace).Create(&api.Secret{ +// ObjectMeta: metav1.ObjectMeta{ +// Name: crt.Spec.SecretName, +// Namespace: crt.Namespace, +// }, +// Data: map[string][]byte{ +// api.TLSCertKey: cert, +// api.TLSPrivateKeyKey: key, +// }, +// }) + +// if err != nil { +// return fmt.Errorf("error saving certificate: %s", err.Error()) +// } + +// return nil +// } + +// // renew will attempt to renew a certificate from the specified issuer, or +// // return an error on failure. If renewal is succesful, the certificate data +// // and private key will be stored in the named secret +// func (c *Controller) renew(crt *v1alpha1.Certificate) error { +// i, err := issuer.IssuerFor(crt) +// if err != nil { +// return err +// } + +// cert, key, err := i.Renew(&ctx, crt) +// if err != nil { +// return fmt.Errorf("error renewing certificate: %s", err.Error()) +// } + +// _, err = ctx.Client.Secrets(crt.Namespace).Update(&api.Secret{ +// ObjectMeta: metav1.ObjectMeta{ +// Name: crt.Spec.SecretName, +// Namespace: crt.Namespace, +// }, +// Data: map[string][]byte{ +// api.TLSCertKey: cert, +// api.TLSPrivateKeyKey: key, +// }, +// }) + +// if err != nil { +// return fmt.Errorf("error saving certificate: %s", err.Error()) +// } + +// return nil +// } + +// func equalUnsorted(s1 []string, s2 []string) bool { +// if len(s1) != len(s2) { +// return false +// } +// s1_2, s2_2 := make([]string, len(s1)), make([]string, len(s2)) +// sort.Strings(s1) +// sort.Strings(s2) +// for i, s := range s1_2 { +// if s != s2_2[i] { +// return false +// } +// } +// return true +// } diff --git a/pkg/controller/certificates/sync_test.go b/pkg/controller/certificates/sync_test.go new file mode 100644 index 000000000..a7a034e6e --- /dev/null +++ b/pkg/controller/certificates/sync_test.go @@ -0,0 +1,49 @@ +package certificates + +import ( + "testing" +) + +func TestEqualUnsorted(t *testing.T) { + type testT struct { + desc string + s1 []string + s2 []string + equal bool + } + tests := []testT{ + { + desc: "equal but out of order slices should be equal", + s1: []string{"a", "b", "c"}, + s2: []string{"b", "a", "c"}, + equal: true, + }, + { + desc: "non-equal but ordered slices should not be equal", + s1: []string{"a", "b"}, + s2: []string{"a", "b", "c"}, + equal: false, + }, + { + desc: "non-equal but ordered slices should not be equal", + s1: []string{"a", "b", "c"}, + s2: []string{"a", "b"}, + equal: false, + }, + { + desc: "equal and ordered slices should be equal", + s1: []string{"a", "b", "c"}, + s2: []string{"a", "b", "c"}, + equal: true, + }, + } + for _, test := range tests { + t.Run(test.desc, func(test testT) func(*testing.T) { + return func(t *testing.T) { + if actual := equalUnsorted(test.s1, test.s2); actual != test.equal { + t.Errorf("equalUnsorted(%+v, %+v) = %t, but expected %t", test.s1, test.s2, actual, test.equal) + } + } + }(test)) + } +} diff --git a/pkg/controller/controller.go b/pkg/controller/controller.go new file mode 100644 index 000000000..d579e8e0a --- /dev/null +++ b/pkg/controller/controller.go @@ -0,0 +1,81 @@ +package controller + +import ( + "reflect" + "time" + + "k8s.io/apimachinery/pkg/util/wait" + "k8s.io/client-go/tools/cache" + "k8s.io/client-go/util/workqueue" +) + +type Controller struct { + Context *Context + // TODO (@munnerz): come up with some way to swap out this queue type + Queue workqueue.RateLimitingInterface + Worker func(Context, interface{}) error + Informers []cache.SharedIndexInformer +} + +// Run will start this controllers run loop, with the specified number of +// worker goroutines. It will block until a message is placed onto the stopCh. +func (c *Controller) Run(workers int, stopCh <-chan struct{}) { + defer c.Queue.ShutDown() + + hasSynced := make([]cache.InformerSynced, len(c.Informers)) + for i, informer := range c.Informers { + informer.AddEventHandler(cache.ResourceEventHandlerFuncs{ + AddFunc: c.Queue.Add, + UpdateFunc: func(old, cur interface{}) { + if !reflect.DeepEqual(old, cur) { + c.Queue.Add(cur) + } + }, + DeleteFunc: c.Queue.Add, + }) + hasSynced[i] = informer.HasSynced + } + + c.Context.Logger.Printf("Starting control loop") + + // wait for all the informer caches we depend on are synced + if !cache.WaitForCacheSync(stopCh, hasSynced...) { + c.Context.Logger.Errorf("error waiting for informer caches to sync") + return + } + + for i := 0; i < workers; i++ { + // TODO (@munnerz): make time.Second duration configurable + go wait.Until(c.worker, time.Second, stopCh) + } + + <-stopCh + c.Context.Logger.Printf("shutting down queue as workqueue signalled shutdown") +} + +func (c *Controller) worker() { + c.Context.Logger.Printf("starting worker") + for { + obj, shutdown := c.Queue.Get() + if shutdown { + break + } + + err := func(obj interface{}) error { + defer c.Queue.Done(obj) + if err := c.Worker(*c.Context, obj); err != nil { + return err + } + c.Queue.Forget(obj) + return nil + }(obj) + + if err != nil { + c.Context.Logger.Printf("requeuing item due to error processing: %s", err.Error()) + c.Queue.AddRateLimited(obj) + } + + c.Context.Logger.Printf("finished processing work item") + } + c.Context.Logger.Printf("exiting worker loop") +} diff --git a/pkg/controller/issuers/controller.go b/pkg/controller/issuers/controller.go new file mode 100644 index 000000000..f9f626add --- /dev/null +++ b/pkg/controller/issuers/controller.go @@ -0,0 +1,46 @@ +package issuers + +import ( + "time" + + "github.com/juju/ratelimit" + api "k8s.io/api/core/v1" + "k8s.io/client-go/tools/cache" + "k8s.io/client-go/util/workqueue" + + "github.com/jetstack/cert-manager/pkg/apis/certmanager/v1alpha1" + "github.com/jetstack/cert-manager/pkg/controller" +) + +func New(ctx controller.Context) controller.Controller { + return controller.Controller{ + Context: &ctx, + Queue: workqueue.NewRateLimitingQueue( + workqueue.NewMaxOfRateLimiter( + workqueue.NewItemExponentialFailureRateLimiter(15*time.Second, time.Minute), + // 10 qps, 100 bucket size. This is only for retry speed and its only the overall factor (not per item) + &workqueue.BucketRateLimiter{Bucket: ratelimit.NewBucketWithRate(float64(10), int64(100))}, + ), + ), + Worker: processNextWorkItem, + Informers: []cache.SharedIndexInformer{ + ctx.CertManagerInformerFactory.Certmanager().V1alpha1().Issuers().Informer(), + ctx.InformerFactory.Core().V1().Secrets().Informer(), + }, + } +} + +func processNextWorkItem(ctx controller.Context, obj interface{}) error { + switch v := obj.(type) { + case *v1alpha1.Issuer: + if err := sync(&ctx, v.Namespace, v.Name); err != nil { + return err + } + case *api.Secret: + ctx.Logger.Printf("got secret %s/%s, nothing implemented to handle yet", v.Namespace, v.Name) + default: + ctx.Logger.Errorf("unexpected resource type (%T) in work queue", obj) + } + + return nil +} diff --git a/pkg/controller/issuers/sync.go b/pkg/controller/issuers/sync.go new file mode 100644 index 000000000..4933c2080 --- /dev/null +++ b/pkg/controller/issuers/sync.go @@ -0,0 +1,30 @@ +package issuers + +import ( + "fmt" + + k8sErrors "k8s.io/apimachinery/pkg/api/errors" + + "github.com/jetstack/cert-manager/pkg/controller" + "github.com/jetstack/cert-manager/pkg/issuer" +) + +func sync(ctx *controller.Context, namespace, name string) error { + acc, err := ctx.CertManagerInformerFactory.Certmanager().V1alpha1().Issuers().Lister().Issuers(namespace).Get(name) + + if err != nil { + if k8sErrors.IsNotFound(err) { + ctx.Logger.Printf("issuer '%s/%s' in sync queue has been deleted", namespace, name) + return nil + } + return fmt.Errorf("error retreiving issuer: %s", err.Error()) + } + + i, err := issuer.IssuerFor(*ctx, acc) + + if err != nil { + return err + } + + return i.Setup() +} diff --git a/pkg/issuer/acme/account.go b/pkg/issuer/acme/account.go new file mode 100644 index 000000000..9f5ba4444 --- /dev/null +++ b/pkg/issuer/acme/account.go @@ -0,0 +1,186 @@ +package acme + +import ( + "context" + "crypto/rand" + "crypto/rsa" + "crypto/x509" + "encoding/pem" + "fmt" + "strings" + + "golang.org/x/crypto/acme" + api "k8s.io/api/core/v1" + k8sErrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + "github.com/jetstack/cert-manager/pkg/apis/certmanager/v1alpha1" + "github.com/jetstack/cert-manager/pkg/controller" +) + +const ( + acmeAccountPrivateKeyKey = "key.pem" +) + +type account struct { + ctx *controller.Context + issuer *v1alpha1.Issuer +} + +func (a *account) uri() string { + return a.issuer.Spec.ACME.URI +} + +func (a *account) email() string { + return a.issuer.Spec.ACME.Email +} + +func (a *account) server() string { + return a.issuer.Spec.ACME.Server +} + +// privateKey returns the private key for this account from the given context, +// or an error +// TODO (@munnerz): how can we support different types of private keys other +// than rsa? +func (a *account) privateKey() (*rsa.PrivateKey, error) { + keyName := a.issuer.Spec.ACME.PrivateKey + keySecret, err := a.ctx.InformerFactory.Core().V1().Secrets().Lister().Secrets(a.issuer.Namespace).Get(keyName) + + if err != nil { + // we return the plain error here so k8sErrors.IsNotFound can be used + return nil, err + } + + keyBytes, okkey := keySecret.Data[acmeAccountPrivateKeyKey] + + // TODO: should we automatically recover from this situation by creating the key? + if !okkey { + return nil, fmt.Errorf("no '%s' key set in account secret", api.TLSPrivateKeyKey) + } + + // decode the private key pem + block, _ := pem.Decode(keyBytes) + if block == nil { + return nil, fmt.Errorf("error decoding private key PEM block in '%s'", keyName) + } + // parse the private key + key, err := x509.ParsePKCS1PrivateKey(block.Bytes) + if err != nil { + return nil, fmt.Errorf("error parsing private key in '%s': %s", keyName, err.Error()) + } + // validate the private key + if err = key.Validate(); err != nil { + return nil, fmt.Errorf("private key failed validation in '%s': %s", keyName, err.Error()) + } + + return key, nil +} + +// verify verifies an acme account is valid with the acme server +func (a *account) verify() error { + if a.issuer.Spec.ACME.Server == "" { + a.ctx.Logger.Printf("acme server url must be set") + return nil + } + + privateKey, err := a.privateKey() + + if err != nil { + a.issuer.Status.Ready = false + return err + } + + a.ctx.Logger.Printf("using acme server '%s' for verification", a.issuer.Spec.ACME.Server) + cl := acme.Client{ + Key: privateKey, + DirectoryURL: a.issuer.Spec.ACME.Server, + } + + _, err = cl.GetReg(context.Background(), a.issuer.Spec.ACME.URI) + + if err != nil { + return fmt.Errorf("error getting acme registration: %s", err.Error()) + } + + // TODO: come up with some way to verify the private key is valid for this + // account + + return nil +} + +// register will register an account with the acme server and store the account +// details in the context +func (a *account) register() error { + if a.issuer.Spec.ACME.Server == "" { + a.ctx.Logger.Printf("acme server url must be set") + return nil + } + + privateKey, err := a.privateKey() + + if err != nil { + if !k8sErrors.IsNotFound(err) { + return fmt.Errorf("error getting private key: %s", err.Error()) + } + + // TODO (@munnerz): allow changing the keysize + privateKey, err = a.generatePrivateKey(2048) + + if err != nil { + return fmt.Errorf("error generating private key: %s", err.Error()) + } + + _, err = a.ctx.Client.Secrets(a.issuer.Namespace).Create(&api.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: a.issuer.Spec.ACME.PrivateKey, + Namespace: a.issuer.Namespace, + }, + Data: map[string][]byte{ + acmeAccountPrivateKeyKey: pem.EncodeToMemory( + &pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(privateKey)}, + ), + }, + }) + + if err != nil { + return fmt.Errorf("error saving private key: %s", err.Error()) + } + } + + a.ctx.Logger.Printf("using acme server '%s' for registration", a.issuer.Spec.ACME.Server) + cl := acme.Client{ + Key: privateKey, + DirectoryURL: a.issuer.Spec.ACME.Server, + } + + acc := &acme.Account{ + Contact: []string{fmt.Sprintf("mailto:%s", strings.ToLower(a.issuer.Spec.ACME.Email))}, + } + + // todo (@munnerz): don't use ctx.Background() here + account, err := cl.Register(context.Background(), acc, acme.AcceptTOS) + + if err != nil { + var acmeErr *acme.Error + var ok bool + if acmeErr, ok = err.(*acme.Error); !ok || (acmeErr.StatusCode != 409) { + return fmt.Errorf("error registering acme account: %s", err.Error()) + } + + if a.issuer.Spec.ACME.URI == "" { + return fmt.Errorf("private key already registered but user URI not found. delete existing private key or set acme account URI") + } + + if account, err = cl.UpdateReg(context.Background(), acc); err != nil { + return fmt.Errorf("error updating acme account registration: %s", err.Error()) + } + } + + a.issuer.Spec.ACME.URI = account.URI + return nil +} + +func (a *account) generatePrivateKey(bits int) (*rsa.PrivateKey, error) { + return rsa.GenerateKey(rand.Reader, bits) +} diff --git a/pkg/issuer/acme/acme.go b/pkg/issuer/acme/acme.go new file mode 100644 index 000000000..308b700ea --- /dev/null +++ b/pkg/issuer/acme/acme.go @@ -0,0 +1,99 @@ +package acme + +import ( + "fmt" + "reflect" + "strings" + + "github.com/jetstack/cert-manager/pkg/apis/certmanager/v1alpha1" + "github.com/jetstack/cert-manager/pkg/client/scheme" + "github.com/jetstack/cert-manager/pkg/controller" +) + +type Acme struct { + ctx *controller.Context + issuer *v1alpha1.Issuer + account *account +} + +func New(ctx *controller.Context, issuer *v1alpha1.Issuer) (*Acme, error) { + if issuer.Spec.ACME == nil { + return nil, fmt.Errorf("acme config is not set") + } + return &Acme{ctx, issuer, &account{ctx, issuer}}, nil +} + +func (a *Acme) Setup() error { + before, err := scheme.Scheme.DeepCopy(a.issuer) + + if err != nil { + return fmt.Errorf("internal error creating deepcopy for issuer: %s", err.Error()) + } + + defer func() { + if !reflect.DeepEqual(before, a.issuer) { + a.saveIssuer() + } + }() + + err = a.ensureSetup() + + if err != nil { + return err + } + + return nil +} + +// saveIssuer will save the contained issuer resource in the API server +func (a *Acme) saveIssuer() error { + _, err := a.ctx.CertManagerClient.Issuers(a.issuer.Namespace).Update(a.issuer) + return err +} + +// ensureSetup will ensure that this issuer is ready to issue certificates. +// it +func (a *Acme) ensureSetup() error { + err := a.account.verify() + + if err == nil { + a.issuer.Status.Ready = true + return nil + } + + a.issuer.Status.Ready = false + + err = a.account.register() + + if err != nil { + // don't write updated state as an actual error occurred + return fmt.Errorf("error registering acme account: %s", err.Error()) + } + + a.issuer.Status.Ready = true + + return nil +} + +func (a *Acme) Issue(crt *v1alpha1.Certificate) ([]byte, []byte, error) { + if crt.Spec.ACME == nil { + return nil, nil, fmt.Errorf("acme config must be specified") + } + + // TODO (@munnerz): tidy this weird horrible line up + switch v1alpha1.ACMEChallengeType(strings.ToUpper(string(crt.Spec.ACME.Challenge))) { + case v1alpha1.ACMEChallengeTypeHTTP01: + a.ctx.Logger.Printf("Obtaining certificates for %+v", crt.Spec.Domains) + // todo: use acme library to obtain challenge details and pass them to the solver + case v1alpha1.ACMEChallengeTypeTLSSNI01: + case v1alpha1.ACMEChallengeTypeDNS01: + default: + return nil, nil, fmt.Errorf("invalid acme challenge type '%s'", crt.Spec.ACME.Challenge) + } + + return nil, nil, nil +} + +func (a *Acme) Renew(crt *v1alpha1.Certificate) ([]byte, []byte, error) { + return nil, nil, nil +} diff --git a/pkg/issuer/acme/dns/dns.go b/pkg/issuer/acme/dns/dns.go new file mode 100644 index 000000000..1ffe03d57 --- /dev/null +++ b/pkg/issuer/acme/dns/dns.go @@ -0,0 +1 @@ +package dns diff --git a/pkg/issuer/acme/http/constants.go b/pkg/issuer/acme/http/constants.go new file mode 100644 index 000000000..0a7398c33 --- /dev/null +++ b/pkg/issuer/acme/http/constants.go @@ -0,0 +1,6 @@ +package http + +const ( + // HTTPChallengePath is the path prefix used for http-01 challenge requests + HTTPChallengePath = "/.well-known/acme-challenge" +) diff --git a/pkg/issuer/acme/http/http.go b/pkg/issuer/acme/http/http.go new file mode 100644 index 000000000..f688a912e --- /dev/null +++ b/pkg/issuer/acme/http/http.go @@ -0,0 +1,73 @@ +package http + +import ( + "fmt" + "net/http" + "path" + "strings" + "sync" +) + +// Challenge is a Host/Token pair that is used for verify ownership of domains +type challenge struct { + host, token string +} + +// httpSolver is an ACME HTTP-01 challenge solver. It will listen on a given +// port and respond with the appropriate response given the listen of valid +// Challenge structures registered with it. +type httpSolver struct { + // challenges is a list of challenge resources to validate + challenges []challenge + // challengeMutex is used to guarantee sync between different goroutines + // accessing the list of valid challenges + challengeMutex sync.Mutex + // ListenPort is the port cert-manager should listen on for ACME HTTP-01 + // challenge requests + ListenPort int +} + +// AddChallenge will add a challenge structure to the list of valid challenges +func (h *httpSolver) addChallenge(c challenge) { + h.challengeMutex.Lock() + defer h.challengeMutex.Unlock() + h.challenges = append(h.challenges, c) +} + +// Listen will begin listening for connections on the given port, and +// validating requests that contain a valid domain, path & token +func (h *httpSolver) listen() error { + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + h.challengeMutex.Lock() + defer h.challengeMutex.Unlock() + + // extract vars from the request + host := strings.Split(r.Host, ":")[0] + basePath := path.Dir(r.URL.EscapedPath()) + token := path.Base(r.URL.EscapedPath()) + + // verify the base path is correct + if basePath != HTTPChallengePath { + http.NotFound(w, r) + return + } + + for i, c := range h.challenges { + // if either the host or the token don't match what is expected, + // we should continue to the next loop iteration + if c.host != host || c.token != token { + continue + } + + // otherwise, this is a valid request and we're going to approve it + w.WriteHeader(http.StatusOK) + // remove this challenge from the list so we don't store indefinitely + h.challenges = append(h.challenges[:i], h.challenges[i+1:]...) + return + } + + // if nothing else, we return a 404 here + http.NotFound(w, r) + }) + return http.ListenAndServe(fmt.Sprintf(":%d", h.ListenPort), handler) +} diff --git a/pkg/issuer/issuer.go b/pkg/issuer/issuer.go new file mode 100644 index 000000000..61386333f --- /dev/null +++ b/pkg/issuer/issuer.go @@ -0,0 +1,30 @@ +package issuer + +import ( + "fmt" + + "github.com/jetstack/cert-manager/pkg/apis/certmanager/v1alpha1" + "github.com/jetstack/cert-manager/pkg/controller" + "github.com/jetstack/cert-manager/pkg/issuer/acme" +) + +type Interface interface { + // Setup initialises the issuer. This may include registering accounts with + // a service, creating a CA and storing it somewhere, or verifying + // credentials and authorization with a remote server. + Setup() error + // Issue attempts to issue a certificate as described by the certificate + // resource given + Issue(*v1alpha1.Certificate) ([]byte, []byte, error) + // Renew attempts to renew the certificate describe by the certificate + // resource given. If no certificate exists, an error is returned. + Renew(*v1alpha1.Certificate) ([]byte, []byte, error) +} + +func IssuerFor(ctx controller.Context, issuer *v1alpha1.Issuer) (Interface, error) { + switch { + case issuer.Spec.ACME != nil: + return acme.New(&ctx, issuer) + } + return nil, fmt.Errorf("issuer '%s' does not have an issuer specification", issuer.Name) +} diff --git a/pkg/util/util.go b/pkg/util/util.go new file mode 100644 index 000000000..3409e0a65 --- /dev/null +++ b/pkg/util/util.go @@ -0,0 +1,14 @@ +package util + +func OnlyOneNotNil(items ...interface{}) (any bool, one bool) { + oneNotNil := false + for _, i := range items { + if i != nil { + if oneNotNil { + return true, false + } + oneNotNil = true + } + } + return oneNotNil, oneNotNil +}