diff --git a/pkg/api/util/conditions.go b/pkg/api/util/conditions.go new file mode 100644 index 0000000..bb4a5bd --- /dev/null +++ b/pkg/api/util/conditions.go @@ -0,0 +1,37 @@ +package util + +import ( + acmapi "gitbucket.jerxie.com/yangyangxie/AnthosCertManager/pkg/apis/anthoscertmanager/v1" +) + +// CertificateHasCondition will return true if the given Certificate has a +// condition matching the provided CertificateCondition. +// Only the Type and Status field will be used in the comparison, meaning that +// this function will return 'true' even if the Reason, Message and +// LastTransitionTime fields do not match. +func CertificateHasCondition(crt *acmapi.Certificate, c acmapi.CertificateCondition) bool { + if crt == nil { + return false + } + existingConditions := crt.Status.Conditions + for _, cond := range existingConditions { + if c.Type == cond.Type && c.Status == cond.Status { + return true + } + } + return false +} + +// RemoveCertificateCondition will remove any condition with this condition type +func RemoveCertificateCondition(crt *acmapi.Certificate, conditionType acmapi.CertificateConditionType) { + var updatedConditions []acmapi.CertificateCondition + for _, cond := range crt.Status.Conditions { + if cond.Type != conditionType { + // Only add the unrelated conditions + updatedConditions = append(updatedConditions, cond) + } + } + // Add the other conditions back. + crt.Status.Conditions = updatedConditions + +} diff --git a/pkg/apis/anthoscertmanager/v1/certificate_types.go b/pkg/apis/anthoscertmanager/v1/certificate_types.go index ee10d28..93df4ac 100644 --- a/pkg/apis/anthoscertmanager/v1/certificate_types.go +++ b/pkg/apis/anthoscertmanager/v1/certificate_types.go @@ -229,6 +229,34 @@ // CertificateConditionType represents an Certificate condition value. type CertificateConditionType string +const ( + // CertificateConditionReady indicates that a certificate is ready for use. + // This is defined as: + // - The target secret exists + // - The target secret contains a certificate that has not expired + // - The target secret contains a private key valid for the certificate + // - The commonName and dnsNames attributes match those specified on the Certificate + CertificateConditionReady CertificateConditionType = "Ready" + + // A condition added to Certificate resources when an issuance is required. + // This condition will be automatically added and set to true if: + // * No keypair data exists in the target Secret + // * The data stored in the Secret cannot be decoded + // * The private key and certificate do not have matching public keys + // * If a CertificateRequest for the current revision exists and the + // certificate data stored in the Secret does not match the + // `status.certificate` on the CertificateRequest. + // * If no CertificateRequest resource exists for the current revision, + // the options on the Certificate resource are compared against the + // x509 data in the Secret, similar to what's done in earlier versions. + // If there is a mismatch, an issuance is triggered. + // This condition may also be added by external API consumers to trigger + // a re-issuance manually for any other reason. + // + // It will be removed by the 'issuing' controller upon completing issuance. + CertificateConditionIssuing CertificateConditionType = "Issuing" +) + // CertificateCondition contains condition information for an Certificate. type CertificateCondition struct { // Type of the condition, known values are (`Ready`, `Issuing`). diff --git a/pkg/controller/certificates/issuing/issuing_controller.go b/pkg/controller/certificates/issuing/issuing_controller.go index 8fcfd58..fe78ef6 100644 --- a/pkg/controller/certificates/issuing/issuing_controller.go +++ b/pkg/controller/certificates/issuing/issuing_controller.go @@ -2,23 +2,33 @@ import ( "context" + "crypto" "time" + apiutil "gitbucket.jerxie.com/yangyangxie/AnthosCertManager/pkg/api/util" acmapi "gitbucket.jerxie.com/yangyangxie/AnthosCertManager/pkg/apis/anthoscertmanager/v1" + acmmeta "gitbucket.jerxie.com/yangyangxie/AnthosCertManager/pkg/apis/meta/v1" acmClient "gitbucket.jerxie.com/yangyangxie/AnthosCertManager/pkg/client/clientset/versioned" acmInformers "gitbucket.jerxie.com/yangyangxie/AnthosCertManager/pkg/client/informers/externalversions" acmlisters "gitbucket.jerxie.com/yangyangxie/AnthosCertManager/pkg/client/listers/anthoscertmanager/v1" controllerpkg "gitbucket.jerxie.com/yangyangxie/AnthosCertManager/pkg/controller" "gitbucket.jerxie.com/yangyangxie/AnthosCertManager/pkg/controller/certificates" "gitbucket.jerxie.com/yangyangxie/AnthosCertManager/pkg/controller/certificates/issuing/internal" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + policies "gitbucket.jerxie.com/yangyangxie/AnthosCertManager/pkg/controller/certificates/policies" + logf "gitbucket.jerxie.com/yangyangxie/AnthosCertManager/pkg/logs" + utilpki "gitbucket.jerxie.com/yangyangxie/AnthosCertManager/pkg/util/pki" "gitbucket.jerxie.com/yangyangxie/AnthosCertManager/pkg/util/predicate" "github.com/go-logr/logr" + apierrors "k8s.io/apimachinery/pkg/api/errors" "k8s.io/apimachinery/pkg/labels" "k8s.io/client-go/informers" "k8s.io/client-go/kubernetes" corelisters "k8s.io/client-go/listers/core/v1" - "k8s.io/client-go/tools/cache" + "k8s.io/client-go/tools/record" "k8s.io/client-go/util/workqueue" "k8s.io/utils/clock" ) @@ -34,9 +44,14 @@ certificateLister acmlisters.CertificateLister certificateRequestLister acmlisters.CertificateRequestLister secretLister corelisters.SecretLister + recorder record.EventRecorder clock clock.Clock client acmClient.Interface + // postIssuancePolicyChain is the policies chain to ensure that all Secret + // metadata and output formats are kept are present and correct. + postIssuancePolicyChain policies.Chain + // secretsUpdateData is used by the SecretTemplate controller for // re-reconciling Secrets where the SecretTemplate is not up to date with a // Certificate's secret. @@ -49,6 +64,7 @@ client acmClient.Interface, factory informers.SharedInformerFactory, acmFactory acmInformers.SharedInformerFactory, + recorder record.EventRecorder, clock clock.Clock, certificateControllerOptions controllerpkg.CertificateOptions, fieldManager string, @@ -85,12 +101,101 @@ ) return &controller{ - certificateLister: certificateInformer.Lister(), certificateRequestLister: certificateRequestInformer.Lister(), secretLister: secretsInformer.Lister(), + recorder: recorder, clock: clock, secretsUpdateData: secretsManager.UpdateData, client: client, + postIssuancePolicyChain: policies.NewSecretPostIssuancePolicyChain(certificateControllerOptions.EnableOwnerRef, + fieldManager), }, queue, mustSync } + +func (c *controller) ProcessItem(ctx context.Context, key string) error { + ctx, cancel := context.WithTimeout(ctx, time.Second*10) + defer cancel() + + log := logf.FromContext(ctx).WithValues("key", key) + + namespace, name, err := cache.SplitMetaNamespaceKey(key) + if err != nil { + return nil + } + + crt, err := c.certificateLister.Certificates(namespace).Get(name) + if apierrors.IsNotFound(err) { + log.V(logf.DebugLevel).Info("Certificate not found for key", "error", err.Error()) + } + if err != nil { + return err + } + + log = logf.WithResource(log, crt) + ctx = logf.NewContext(ctx, log) + + // if the certificate condition is not equal to issuing, we need to ensure all non-issuing related secret Data is correct. + if !apiutil.CertificateHasCondition(crt, acmapi.CertificateCondition{ + Type: acmapi.CertificateConditionIssuing, + Status: acmmeta.ConditionTrue, + }) { + return c.ensureSecretData(ctx, log, crt) + } + + return nil +} + +// updateOrApplyStatus will update the controller status. +func (c *controller) updateOrApplyStatus(ctx context.Context, crt *acmapi.Certificate, conditionRemoved bool) error { + // TODO: conditionally doing the operation based on SSA feature gate + + _, err := c.client.AnthosCertmanagerV1().Certificates(crt.Namespace).UpdateStatus(ctx, crt, metav1.UpdateOptions{}) + return err +} + +// issueCertificate will ensure the public key of the CSR matches the signed +// certificate, and then store the certificate, CA and private key into the +// Secret in the appropriate format type. +func (c *controller) issueCertificate(ctx context.Context, nextRevision int, crt *acmapi.Certificate, req *acmapi.CertificateRequest, pk crypto.Signer) error { + crt = crt.DeepCopy() + if crt.Spec.PrivateKey == nil { + crt.Spec.PrivateKey = &acmapi.CertificatePrivateKey{} + } + + pkData, err := utilpki.EncodePrivateKey(pk, crt.Spec.PrivateKey.Encoding) + if err != nil { + return err + } + + secretData := internal.SecretData{ + PrivateKey: pkData, + Certificate: req.Status.Certificate, + CA: req.Status.CA, + } + + if err := c.secretsUpdateData(ctx, crt, secretData); err != nil { + return err + } + + // Set status.revision to revision of the CertificateRequest + crt.Status.Revision = &nextRevision + + // Remove Issuing status condition + // TODO: Once we move to only server-side apply API calls, this should be changed to setting the Issuing condition to False + apiutil.RemoveCertificateCondition(crt, acmapi.CertificateConditionIssuing) + + // Clearn the failed attempts + crt.Status.FailedIssuanceAttempts = nil + + // Clean status.lastFailureTime + crt.Status.LastFailureTime = nil + + if err := c.updateOrApplyStatus(ctx, crt, true); err != nil { + return err + } + + message := "The certificate has been successfully issused" + c.recorder.Event(crt, corev1.EventTypeNormal, "Issuing", message) + return nil +} diff --git a/pkg/controller/certificates/issuing/secret_manager.go b/pkg/controller/certificates/issuing/secret_manager.go new file mode 100644 index 0000000..2d7c54a --- /dev/null +++ b/pkg/controller/certificates/issuing/secret_manager.go @@ -0,0 +1,69 @@ +package issuing + +import ( + "context" + "errors" + + acmapi "gitbucket.jerxie.com/yangyangxie/AnthosCertManager/pkg/apis/anthoscertmanager/v1" + "gitbucket.jerxie.com/yangyangxie/AnthosCertManager/pkg/controller/certificates/issuing/internal" + "gitbucket.jerxie.com/yangyangxie/AnthosCertManager/pkg/controller/certificates/policies" + logf "gitbucket.jerxie.com/yangyangxie/AnthosCertManager/pkg/logs" + + acmmeta "gitbucket.jerxie.com/yangyangxie/AnthosCertManager/pkg/apis/meta/v1" + "github.com/go-logr/logr" + corev1 "k8s.io/api/core/v1" + apierrors "k8s.io/apimachinery/pkg/api/errors" +) + +// ensureSecretData ensures that the Certificates's Secret is up-to-date +// with non-issuing condition related data. +// It will reconcile the data if mismatched. + +func (c *controller) ensureSecretData(ctx context.Context, log logr.Logger, crt *acmapi.Certificate) error { + // Retrieve the desired secret which is associated with this Certificate. + secret, err := c.secretLister.Secrets(crt.Namespace).Get(crt.Spec.SecretName) + + if apierrors.IsNotFound(err) { + log.V(logf.DebugLevel).Info("secret not found", "error", err.Error()) + return nil + } + + if err != nil { + return err + } + + log.WithValues("secret", secret.Name) + + // If there is no certificate or private key data available at the target Secret then exit early. + // The absense of these keys should cause an issuance of the certificate, so there is no need to run post issuance check. + certLen := len(secret.Data[corev1.TLSCertKey]) + keyLen := len(secret.Data[corev1.TLSPrivateKeyKey]) + if secret.Data == nil || + certLen == 0 || + keyLen == 0 { + log.V(logf.DebugLevel).Info("secert doesn't contain both certificate and private key", "cert_data_len", certLen, "key_data_len", keyLen) + return nil + } + + data := internal.SecretData{ + PrivateKey: secret.Data[corev1.TLSCertKey], + Certificate: secret.Data[corev1.TLSPrivateKeyKey], + CA: secret.Data[acmmeta.TLSCAKey], + } + + // check whether the certificate's secret has correct output format and metadata + _, message, isViolation := c.postIssuancePolicyChain.Evaluate(policies.Input{ + Certificate: crt, + Secret: secret, + }) + + // If it violates the desired policies, we should return error and re-apply the secret if necessary. + // For the first version, let's blindly update it. + + if isViolation { + log.Error(errors.New(message), "failed to evaluate the secret") + return c.secretsUpdateData(ctx, crt, data) + } + + return nil +} diff --git a/pkg/controller/certificates/policies/checks.go b/pkg/controller/certificates/policies/checks.go new file mode 100644 index 0000000..8adce6b --- /dev/null +++ b/pkg/controller/certificates/policies/checks.go @@ -0,0 +1,14 @@ +package policies + +// SecretOwnerReferenceManagedFieldMismatch validates that the Secret has an +// owner reference to the Certificate if enabled. Returns true (violation) if: +// * the Secret doesn't have an owner reference and is expecting one +// * has an owner reference but is not expecting one +// A violation with the reason `ManagedFieldsParseError` should be considered a +// non re-triable error. +func SecretOwnerReferenceManagedFieldMismatch(ownerRefEnabled bool, fieldManager string) Func { + return func(input Input) (string, string, bool) { + return "", "", false + } + +} diff --git a/pkg/controller/certificates/policies/policies.go b/pkg/controller/certificates/policies/policies.go new file mode 100644 index 0000000..7fc5eca --- /dev/null +++ b/pkg/controller/certificates/policies/policies.go @@ -0,0 +1,53 @@ +package policies + +import ( + corev1 "k8s.io/api/core/v1" + + acmapi "gitbucket.jerxie.com/yangyangxie/AnthosCertManager/pkg/apis/anthoscertmanager/v1" +) + +type Input struct { + Certificate *acmapi.Certificate + Secret *corev1.Secret + + // // The "current" certificate request designates the certificate request that + // // led to the current revision of the certificate. The "current" certificate + // // request is by definition in a ready state, and can be seen as the source + // // of information of the current certificate. Take a look at the gatherer + // // package's documentation to see more about why we care about the "current" + // // certificate request. + // CurrentRevisionRequest *cmapi.CertificateRequest + + // // The "next" certificate request is the one that is currently being issued. + // // Take a look at the gatherer package's documentation to see more about why + // // we care about the "next" certificate request. + // NextRevisionRequest *cmapi.CertificateRequest +} + +// A Func evaluates the given input data and decides whether a check has passed or not. +// It returns additional human readable information in the `reason` and `message` return parameters if so. +type Func func(Input) (reason, message string, failed bool) + +// A chain of policy functions to be evaluated in order +type Chain []Func + +// Evaluate will evaluate the entire policy chain using the provided input. +// As soon as it is discovered that the input violates one policy, +// Evaluate will return and not evaluate the rest of the chain. +func (c Chain) Evaluate(input Input) (string, string, bool) { + for _, policyFunc := range c { + reason, message, volidationFound := policyFunc(input) + if volidationFound { + return reason, message, volidationFound + } + } + return "", "", false +} + +// NewSecretPostIssuancePolicyChain includes policy checks that are to be +// performed _after_ issuance has been successful, testing for the presence and +// correctness of metadata and output formats of Certificate's Secrets. +func NewSecretPostIssuancePolicyChain(ownerRefEnabled bool, fieldManager string) Chain { + // TODO: Check the owner referience value mismatch + return Chain{SecretOwnerReferenceManagedFieldMismatch(ownerRefEnabled, fieldManager)} +} diff --git a/pkg/util/pki/generate.go b/pkg/util/pki/generate.go new file mode 100644 index 0000000..911a33c --- /dev/null +++ b/pkg/util/pki/generate.go @@ -0,0 +1,65 @@ +package pki + +import ( + "crypto" + "crypto/ecdsa" + "crypto/ed25519" + "crypto/rsa" + "crypto/x509" + "encoding/pem" + "fmt" + + acmapi "gitbucket.jerxie.com/yangyangxie/AnthosCertManager/pkg/apis/anthoscertmanager/v1" +) + +// EncodePrivateKey will encode a given crypto.PrivateKey by first inspecting +// the type of key encoding and then inspecting the type of key provided. +// It only supports encoding RSA or ECDSA keys. +func EncodePrivateKey(pk crypto.PrivateKey, keyEncoding acmapi.PrivateKeyEncoding) ([]byte, error) { + switch keyEncoding { + case acmapi.PrivateKeyEncoding(""), acmapi.PKCS1: + switch k := pk.(type) { + case *rsa.PrivateKey: + return EncodePKCS1PrivateKey(k), nil + case *ecdsa.PrivateKey: + return EncodeECPrivateKey(k) + case ed25519.PrivateKey: + return EncodePKCS8PrivateKey(k) + default: + return nil, fmt.Errorf("error encoding private key: unknown key type: %T", pk) + } + case acmapi.PKCS8: + return EncodePKCS8PrivateKey(pk) + default: + return nil, fmt.Errorf("error encoding private key: unknown key encoding: %s", keyEncoding) + } +} + +// EncodePKCS1PrivateKey will marshal a RSA private key into x509 PEM format. +func EncodePKCS1PrivateKey(pk *rsa.PrivateKey) []byte { + block := &pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(pk)} + + return pem.EncodeToMemory(block) +} + +// EncodePKCS8PrivateKey will marshal a private key into x509 PEM format. +func EncodePKCS8PrivateKey(pk interface{}) ([]byte, error) { + keyBytes, err := x509.MarshalPKCS8PrivateKey(pk) + if err != nil { + return nil, err + } + block := &pem.Block{Type: "PRIVATE KEY", Bytes: keyBytes} + + return pem.EncodeToMemory(block), nil +} + +// EncodeECPrivateKey will marshal an ECDSA private key into x509 PEM format. +func EncodeECPrivateKey(pk *ecdsa.PrivateKey) ([]byte, error) { + asnBytes, err := x509.MarshalECPrivateKey(pk) + if err != nil { + return nil, fmt.Errorf("error encoding private key: %s", err.Error()) + } + + block := &pem.Block{Type: "EC PRIVATE KEY", Bytes: asnBytes} + return pem.EncodeToMemory(block), nil +}