diff --git a/cmd/controller/start.go b/cmd/controller/start.go index b4bb09b71..5ef6b25cb 100644 --- a/cmd/controller/start.go +++ b/cmd/controller/start.go @@ -17,6 +17,7 @@ import ( _ "github.com/jetstack/cert-manager/pkg/controller/issuers" _ "github.com/jetstack/cert-manager/pkg/issuer/acme" _ "github.com/jetstack/cert-manager/pkg/issuer/ca" + _ "github.com/jetstack/cert-manager/pkg/issuer/vault" "github.com/jetstack/cert-manager/pkg/util" ) diff --git a/contrib/charts/vault/Chart.yaml b/contrib/charts/vault/Chart.yaml new file mode 100644 index 000000000..e990eb8e1 --- /dev/null +++ b/contrib/charts/vault/Chart.yaml @@ -0,0 +1,4 @@ +apiVersion: v1 +description: A Helm chart for Kubernetes +name: vault +version: 0.1.0 diff --git a/contrib/charts/vault/templates/_helpers.tpl b/contrib/charts/vault/templates/_helpers.tpl new file mode 100644 index 000000000..f0d83d2ed --- /dev/null +++ b/contrib/charts/vault/templates/_helpers.tpl @@ -0,0 +1,16 @@ +{{/* vim: set filetype=mustache: */}} +{{/* +Expand the name of the chart. +*/}} +{{- define "name" -}} +{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" -}} +{{- end -}} + +{{/* +Create a default fully qualified app name. +We truncate at 63 chars because some Kubernetes name fields are limited to this (by the DNS naming spec). +*/}} +{{- define "fullname" -}} +{{- $name := default .Chart.Name .Values.nameOverride -}} +{{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" -}} +{{- end -}} diff --git a/contrib/charts/vault/templates/vault-deployment.yaml b/contrib/charts/vault/templates/vault-deployment.yaml new file mode 100644 index 000000000..a5ca34210 --- /dev/null +++ b/contrib/charts/vault/templates/vault-deployment.yaml @@ -0,0 +1,34 @@ +--- +apiVersion: extensions/v1beta1 +kind: Deployment +metadata: + labels: + app: vault + name: vault +spec: + replicas: 1 + strategy: + type: Recreate + template: + metadata: + labels: + app: vault + spec: + containers: + - name: vault + image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}" + ports: + - containerPort: 8200 + name: vaultport + protocol: TCP + securityContext: + capabilities: + add: + - IPC_LOCK + env: + - name: VAULT_DEV_ROOT_TOKEN_ID + value: vault-root-token + readinessProbe: + httpGet: + path: /v1/sys/health + port: 8200 diff --git a/contrib/charts/vault/templates/vault-service.yaml b/contrib/charts/vault/templates/vault-service.yaml new file mode 100644 index 000000000..9029e39f1 --- /dev/null +++ b/contrib/charts/vault/templates/vault-service.yaml @@ -0,0 +1,12 @@ +apiVersion: v1 +kind: Service +metadata: + name: vault + labels: + app: vault +spec: + ports: + - name: vault + port: 8200 + selector: + app: vault diff --git a/contrib/charts/vault/values.yaml b/contrib/charts/vault/values.yaml new file mode 100644 index 000000000..a5e966c06 --- /dev/null +++ b/contrib/charts/vault/values.yaml @@ -0,0 +1,4 @@ +image: + repository: vault + tag: "0.9.3" + pullPolicy: IfNotPresent diff --git a/docs/index.rst b/docs/index.rst index 165b74428..40c20f98e 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -9,7 +9,7 @@ Welcome to cert-manager's documentation! cert-manager is a native Kubernetes_ certificate management controller. It can help with issuing certificates from a variety of sources, such as -`Let's Encrypt`_ or a simple signing keypair. +`Let's Encrypt`_, `HashiCorp Vault`_ or a simple signing keypair. It will ensure certificates are valid and up to date, and attempt to renew certificates at a configured time before expiry. @@ -37,3 +37,4 @@ a source of references when seeking help with the project. .. _kube-lego: https://github.com/jetstack/kube-lego .. _kube-cert-manager: https://github.com/PalmStoneGames/kube-cert-manager .. _`Let's Encrypt`: https://letsencrypt.org +.. _`HashiCorp Vault`: https://www.vaultproject.io diff --git a/docs/reference/issuers.rst b/docs/reference/issuers.rst index 1011c3944..3cf0c6d52 100644 --- a/docs/reference/issuers.rst +++ b/docs/reference/issuers.rst @@ -126,6 +126,8 @@ Name Description :doc:`CA ` Supports issuing certificates using a simple signing keypair, stored in a Secret in the Kubernetes API server +:doc:`Vault ` Supports issuing certificates using + HashiCorp Vault. =================================== ========================================= Each Issuer resource is of one, and only one type. The type of an Issuer is @@ -136,6 +138,7 @@ for the ACME issuer, or ``spec.ca`` for the CA based issuer. issuers/acme/index issuers/ca/index + issuers/vault/index .. _`Let's Encrypt`: https://letsencrypt.org .. _kube2iam: https://github.com/jtblin/kube2iam diff --git a/docs/reference/issuers/vault/OWNERS b/docs/reference/issuers/vault/OWNERS new file mode 100644 index 000000000..1fb493914 --- /dev/null +++ b/docs/reference/issuers/vault/OWNERS @@ -0,0 +1,5 @@ +approvers: +- munnerz +reviewers: +- munnerz +- vdesjardins diff --git a/docs/reference/issuers/vault/index.rst b/docs/reference/issuers/vault/index.rst new file mode 100644 index 000000000..c4291b241 --- /dev/null +++ b/docs/reference/issuers/vault/index.rst @@ -0,0 +1,15 @@ +=================== +Vault Configuration +=================== + +.. toctree:: + :maxdepth: 1 + +Vault Issuers issue certificates from `Hashicorp's +Vault `__. + +You can find user guides on using the Vault Issuer in the :doc:`Vault Issuer tutorials +section `. + +.. todo:: + Expand out Vault Issuer reference documentation diff --git a/docs/tutorials/index.rst b/docs/tutorials/index.rst index 7b416fd90..b6e175798 100644 --- a/docs/tutorials/index.rst +++ b/docs/tutorials/index.rst @@ -10,3 +10,4 @@ cert-manager. acme/index ca/index + vault/index diff --git a/docs/tutorials/vault/OWNERS b/docs/tutorials/vault/OWNERS new file mode 100644 index 000000000..1fb493914 --- /dev/null +++ b/docs/tutorials/vault/OWNERS @@ -0,0 +1,5 @@ +approvers: +- munnerz +reviewers: +- munnerz +- vdesjardins diff --git a/docs/tutorials/vault/creating-vault-issuers.rst b/docs/tutorials/vault/creating-vault-issuers.rst new file mode 100644 index 000000000..1e72a5f73 --- /dev/null +++ b/docs/tutorials/vault/creating-vault-issuers.rst @@ -0,0 +1,192 @@ +Vault Installation +================== + +Installing Vault +---------------- + +Vault installation is a complex subject. For a thorough tour of the subject +you can read the official HashiCorp Vault +`documentation `__. + + +Vault PKI Backend +----------------- + +The PKI Secrets Engine needs to be initialized for cert-manager to be +able to generate certificate. The official Vault documentation can be +found +`here `__. + +Vault Authentication with a AppRole +=================================== + +This Vault authentication method uses a +`Vault AppRole `__. + +The secret ID of the AppRole is stored in a secret. + +Here an example of a secret containing the secretId of the AppRole: + +.. code-block:: yaml + + apiVersion: v1 + kind: Secret + type: Opaque + metadata: + name: cert-manager-vault-approle + namespace: default + data: + secretId: "MDI..." + +Where the secretId is the base 64 encoded value of the appRole *secretId* +giving access to the pki backend in Vault. + +We can now create a cluster issuer referencing this secret: + +.. code-block:: yaml + + apiVersion: certmanager.k8s.io/v1alpha1 + kind: Issuer + metadata: + name: vault-issuer + namespace: default + spec: + vault: + path: pki_int/sign/example-dot-com + server: https://vault + auth: + appRole: + roleId: "291b9d21-8ff5-..." + secretRef: + name: cert-manager-vault-approle + key: secretId + +Where *path* is the Vault role path of the PKI backend and *server* is +the Vault server base URL. The Vault appRole credentials are supplied as the +Vault authentication method using the appRole created in Vault. The secretRef +references the Kubernetes secret created previously. More specifically, the field +*name* is the Kubernetes secret name and *key* is the name given as the +key value that store the *secretId*. + +Once we have created the above Issuer we can use it to obtain a certificate. + +.. code-block:: yaml + + apiVersion: certmanager.k8s.io/v1alpha1 + kind: Certificate + metadata: + name: example-com + namespace: default + spec: + secretName: example-com-tls + issuerRef: + name: vault-issuer + commonName: example.com + dnsNames: + - www.example.com + +The Certificate resource describes our desired certificate and the possible +methods that can be used to obtain it. You can learn more about the Certificate +resource in the :doc:`reference docs `. +If the certificate is obtained successfully, the resulting key pair will be +stored in a secret called ``example-com-tls`` in the same namespace as the Certificate. + +The certificate will have a common name of ``example.com`` and the +`Subject Alternative Names`_ (SANs) will be ``example.com`` and ``www.example.com``. + +In our Certificate we have referenced the ``vault-issuer`` Issuer above. +The Issuer must be in the same namespace as the Certificate. +If you want to reference a ClusterIssuer, which is a cluster-scoped version of +an Issuer, you must add ``kind: ClusterIssuer`` to the ``issuerRef`` stanza. + +For more information on ClusterIssuers, read the +:doc:`ClusterIssuer reference docs `. + +Vault Authentication with a Token +================================= + +This Vault authentication method uses a plain token. A Vault token is generated by +one of the many authentication backend supported by Vault. Tokens in Vault have +expiration and need to be refreshed. You need to be aware that cert-manager do not +refresh these tokens. Another process must be put in place to keep them from expiring. + +For testing purpose a root token which do not expire is generated at Vault installation +time. **WARNING: a root token should only be used for testing purpose only**. + +Please refer to the official token `documentation `__ +for all the details. + +Here an example of a secret Kubernetes resource containing the Vault token: + +.. code-block:: yaml + + apiVersion: v1 + kind: Secret + type: Opaque + metadata: + name: cert-manager-vault-token + namespace: kube-system + data: + token: "MjI..." + +Where the token value is the base 64 encoded value of the token giving +access to the PKI backend in Vault. + +We can now create an issuer referencing this secret: + +.. code-block:: yaml + + apiVersion: certmanager.k8s.io/v1alpha1 + kind: Issuer + metadata: + name: vault-issuer + namespace: default + spec: + vault: + auth: + tokenSecretRef: + name: cert-manager-vault-token + key: token + path: pki_int/sign/example-dot-com + server: https://vault + +Where *path* is the Vault role path of the PKI backend and *server* is +the Vault server base URL. The secret created previously is referenced in the issuer +with its *name* and *key* corresponding to the name of the Kubernetes secret and the +property name containing the token value respectively. + +Once we have created the above Issuer we can use it to obtain a certificate. + +.. code-block:: yaml + + apiVersion: certmanager.k8s.io/v1alpha1 + kind: Certificate + metadata: + name: example-com + namespace: default + spec: + secretName: example-com-tls + issuerRef: + name: vault-issuer + commonName: example.com + dnsNames: + - www.example.com + +The Certificate resource describes our desired certificate and the possible +methods that can be used to obtain it. You can learn more about the Certificate +resource in the :doc:`reference docs `. +If the certificate is obtained successfully, the resulting key pair will be +stored in a secret called ``example-com-tls`` in the same namespace as the Certificate. + +The certificate will have a common name of ``example.com`` and the +`Subject Alternative Names`_ (SANs) will be ``example.com`` and ``www.example.com``. + +In our Certificate we have referenced the ``vault-issuer`` Issuer above. +The Issuer must be in the same namespace as the Certificate. +If you want to reference a ClusterIssuer, which is a cluster-scoped version of +an Issuer, you must add ``kind: ClusterIssuer`` to the ``issuerRef`` stanza. + +For more information on ClusterIssuers, read the +:doc:`ClusterIssuer reference docs `. + +.. _`Subject Alternative Names`: https://en.wikipedia.org/wiki/Subject_Alternative_Name diff --git a/docs/tutorials/vault/index.rst b/docs/tutorials/vault/index.rst new file mode 100644 index 000000000..b3a76e26f --- /dev/null +++ b/docs/tutorials/vault/index.rst @@ -0,0 +1,12 @@ +====================== +Vault Issuer Tutorials +====================== + +This section contains tutorials that specifically utilise the Vault Issuer. They +are designed to help teach the underlying concepts of cert-manager whilst also +helping 'quickstart' common use-cases for the project. + +.. toctree:: + :maxdepth: 1 + + creating-vault-issuers diff --git a/pkg/apis/certmanager/v1alpha1/types.go b/pkg/apis/certmanager/v1alpha1/types.go index 7ac4a832c..142268bdb 100644 --- a/pkg/apis/certmanager/v1alpha1/types.go +++ b/pkg/apis/certmanager/v1alpha1/types.go @@ -81,8 +81,34 @@ type IssuerSpec struct { } type IssuerConfig struct { - ACME *ACMEIssuer `json:"acme,omitempty"` - CA *CAIssuer `json:"ca,omitempty"` + ACME *ACMEIssuer `json:"acme,omitempty"` + CA *CAIssuer `json:"ca,omitempty"` + Vault *VaultIssuer `json:"vault,omitempty""` +} + +type VaultIssuer struct { + // Vault authentication + Auth VaultAuth `json:"auth"` + // Server is the vault connection address + Server string `json:"server"` + // Vault URL path to the certificate role + Path string `json:"path"` +} + +// Vault authentication can be configured: +// - With a secret containing a token. Cert-manager is using this token as-is. +// - With a secret containing a AppRole. This AppRole is used to authenticate to +// Vault and retrieve a token. +type VaultAuth struct { + // This Secret contains the Vault token key + TokenSecretRef SecretKeySelector `json:"tokenSecretRef,omitempty"` + // This Secret contains a AppRole and Secret + AppRole VaultAppRole `json:"appRoleSecret,omitempty"` +} + +type VaultAppRole struct { + RoleId string `json:"roleId"` + SecretRef SecretKeySelector `json:"appRoleSecretRef"` } type CAIssuer struct { diff --git a/pkg/apis/certmanager/v1alpha1/zz_generated.deepcopy.go b/pkg/apis/certmanager/v1alpha1/zz_generated.deepcopy.go index ba2076d9e..925547e04 100644 --- a/pkg/apis/certmanager/v1alpha1/zz_generated.deepcopy.go +++ b/pkg/apis/certmanager/v1alpha1/zz_generated.deepcopy.go @@ -728,6 +728,15 @@ func (in *IssuerConfig) DeepCopyInto(out *IssuerConfig) { **out = **in } } + if in.Vault != nil { + in, out := &in.Vault, &out.Vault + if *in == nil { + *out = nil + } else { + *out = new(VaultIssuer) + **out = **in + } + } return } @@ -872,3 +881,55 @@ func (in *SecretKeySelector) DeepCopy() *SecretKeySelector { in.DeepCopyInto(out) return out } + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *VaultAppRole) DeepCopyInto(out *VaultAppRole) { + *out = *in + out.SecretRef = in.SecretRef + return +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new VaultAppRole. +func (in *VaultAppRole) DeepCopy() *VaultAppRole { + if in == nil { + return nil + } + out := new(VaultAppRole) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *VaultAuth) DeepCopyInto(out *VaultAuth) { + *out = *in + out.TokenSecretRef = in.TokenSecretRef + out.AppRole = in.AppRole + return +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new VaultAuth. +func (in *VaultAuth) DeepCopy() *VaultAuth { + if in == nil { + return nil + } + out := new(VaultAuth) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *VaultIssuer) DeepCopyInto(out *VaultIssuer) { + *out = *in + out.Auth = in.Auth + return +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new VaultIssuer. +func (in *VaultIssuer) DeepCopy() *VaultIssuer { + if in == nil { + return nil + } + out := new(VaultIssuer) + in.DeepCopyInto(out) + return out +} diff --git a/pkg/controller/clusterissuers/checks.go b/pkg/controller/clusterissuers/checks.go index 0963d0d27..cf7e40f82 100644 --- a/pkg/controller/clusterissuers/checks.go +++ b/pkg/controller/clusterissuers/checks.go @@ -22,7 +22,8 @@ func (c *Controller) issuersForSecret(secret *corev1.Secret) ([]*v1alpha1.Cluste continue } if (iss.Spec.ACME != nil && iss.Spec.ACME.PrivateKey.Name == secret.Name) || - (iss.Spec.CA != nil && iss.Spec.CA.SecretName == secret.Name) { + (iss.Spec.CA != nil && iss.Spec.CA.SecretName == secret.Name) || + (iss.Spec.Vault != nil && iss.Spec.Vault.Auth.TokenSecretRef.Name == secret.Name) { affected = append(affected, iss) continue } diff --git a/pkg/controller/issuers/checks.go b/pkg/controller/issuers/checks.go index 9a30ee562..dcb61f463 100644 --- a/pkg/controller/issuers/checks.go +++ b/pkg/controller/issuers/checks.go @@ -22,7 +22,8 @@ func (c *Controller) issuersForSecret(secret *corev1.Secret) ([]*v1alpha1.Issuer continue } if (iss.Spec.ACME != nil && iss.Spec.ACME.PrivateKey.Name == secret.Name) || - (iss.Spec.CA != nil && iss.Spec.CA.SecretName == secret.Name) { + (iss.Spec.CA != nil && iss.Spec.CA.SecretName == secret.Name) || + (iss.Spec.Vault != nil && iss.Spec.Vault.Auth.TokenSecretRef.Name == secret.Name) { affected = append(affected, iss) continue } diff --git a/pkg/issuer/const.go b/pkg/issuer/const.go index c23386d3a..5673a3be7 100644 --- a/pkg/issuer/const.go +++ b/pkg/issuer/const.go @@ -11,6 +11,8 @@ const ( IssuerACME string = "acme" // IssuerCA is the name of the simple issuer IssuerCA string = "ca" + // IssuerVault is the name of the Vault issuer + IssuerVault string = "vault" ) // nameForIssuer determines the name of the issuer implementation given an @@ -21,6 +23,8 @@ func nameForIssuer(i v1alpha1.GenericIssuer) (string, error) { return IssuerACME, nil case i.GetSpec().CA != nil: return IssuerCA, nil + case i.GetSpec().Vault != nil: + return IssuerVault, nil } return "", fmt.Errorf("no issuer specified for Issuer '%s/%s'", i.GetObjectMeta().Namespace, i.GetObjectMeta().Name) } diff --git a/pkg/issuer/vault/OWNERS b/pkg/issuer/vault/OWNERS new file mode 100644 index 000000000..1fb493914 --- /dev/null +++ b/pkg/issuer/vault/OWNERS @@ -0,0 +1,5 @@ +approvers: +- munnerz +reviewers: +- munnerz +- vdesjardins diff --git a/pkg/issuer/vault/issue.go b/pkg/issuer/vault/issue.go new file mode 100644 index 000000000..301216b21 --- /dev/null +++ b/pkg/issuer/vault/issue.go @@ -0,0 +1,280 @@ +package vault + +import ( + "bytes" + "context" + "crypto/rand" + "crypto/rsa" + "crypto/x509" + "encoding/pem" + "fmt" + "path" + "strings" + "time" + + "github.com/golang/glog" + vault "github.com/hashicorp/vault/api" + "github.com/hashicorp/vault/helper/certutil" + "github.com/jetstack/cert-manager/pkg/apis/certmanager/v1alpha1" + "github.com/jetstack/cert-manager/pkg/util/errors" + "github.com/jetstack/cert-manager/pkg/util/kube" + "github.com/jetstack/cert-manager/pkg/util/pki" + k8sErrors "k8s.io/apimachinery/pkg/api/errors" +) + +const ( + errorGetCertKeyPair = "ErrGetCertKeyPair" + errorIssueCert = "ErrIssueCert" + + successCertIssued = "CertIssueSuccess" + + messageErrorIssueCert = "Error issuing TLS certificate: " + + messageCertIssued = "Certificate issued successfully" + + defaultCertificateDuration = time.Hour * 24 * 90 +) + +const ( + defaultOrganization = "cert-manager" + + keyBitSize = 2048 +) + +func (v *Vault) Issue(ctx context.Context, crt *v1alpha1.Certificate) ([]byte, []byte, error) { + key, certPem, err := v.obtainCertificate(ctx, crt) + if err != nil { + s := messageErrorIssueCert + err.Error() + crt.UpdateStatusCondition(v1alpha1.CertificateConditionReady, v1alpha1.ConditionFalse, errorIssueCert, s, false) + return nil, nil, err + } + + crt.UpdateStatusCondition(v1alpha1.CertificateConditionReady, v1alpha1.ConditionTrue, successCertIssued, messageCertIssued, true) + + return key, certPem, nil +} + +func (v *Vault) obtainCertificate(ctx context.Context, crt *v1alpha1.Certificate) ([]byte, []byte, error) { + // get existing certificate private key + signeeKey, err := kube.SecretTLSKey(v.secretsLister, crt.Namespace, crt.Spec.SecretName) + if k8sErrors.IsNotFound(err) || errors.IsInvalidData(err) { + signeeKey, err = pki.GenerateRSAPrivateKey(keyBitSize) + if err != nil { + return nil, nil, fmt.Errorf("error generating private key: %s", err.Error()) + } + } + + if err != nil { + return nil, nil, fmt.Errorf("error getting certificate private key: %s", err.Error()) + } + + commonName := crt.Spec.CommonName + altNames := crt.Spec.DNSNames + if len(commonName) == 0 && len(altNames) == 0 { + return nil, nil, fmt.Errorf("no domains specified on certificate") + } + + crtPem, err := v.signCertificate(crt, signeeKey) + if err != nil { + return nil, nil, err + } + + return pki.EncodePKCS1PrivateKey(signeeKey), crtPem, nil +} + +// signCertificate returns a signed x509.Certificate object for the given +// *v1alpha1.Certificate crt. +func (v *Vault) signCertificate(crt *v1alpha1.Certificate, key *rsa.PrivateKey) ([]byte, error) { + commonName := pki.CommonNameForCertificate(crt) + altNames := pki.DNSNamesForCertificate(crt) + + if len(commonName) == 0 && len(altNames) > 0 { + commonName = altNames[0] + } + + template := pki.GenerateCSR(commonName, altNames...) + template.Subject.Organization = []string{defaultOrganization} + + derBytes, err := x509.CreateCertificateRequest(rand.Reader, template, key) + if err != nil { + return nil, fmt.Errorf("error creating x509 certificate: %s", err.Error()) + } + + pemRequestBuf := &bytes.Buffer{} + err = pem.Encode(pemRequestBuf, &pem.Block{Type: "CERTIFICATE REQUEST", Bytes: derBytes}) + if err != nil { + return nil, fmt.Errorf("error encoding certificate request: %s", err.Error()) + } + + return v.requestVaultCert(commonName, altNames, pemRequestBuf.String()) +} + +func (v *Vault) initVaultClient() (*vault.Client, error) { + client, err := vault.NewClient(nil) + if err != nil { + return nil, fmt.Errorf("error initializing Vault client: %s", err.Error()) + } + + client.SetAddress(v.issuer.GetSpec().Vault.Server) + + tokenRef := v.issuer.GetSpec().Vault.Auth.TokenSecretRef + if tokenRef.Name != "" { + token, err := v.vaultTokenRef(tokenRef.Name, tokenRef.Key) + if err != nil { + return nil, fmt.Errorf("error reading Vault token from secret %s/%s: %s", v.issuerResourcesNamespace, tokenRef.Name, err.Error()) + } + client.SetToken(token) + + return client, nil + } + + appRole := v.issuer.GetSpec().Vault.Auth.AppRole + if appRole.RoleId != "" { + token, err := v.requestTokenWithAppRoleRef(client, &appRole) + if err != nil { + return nil, fmt.Errorf("error reading Vault token from secret %s/%s: %s", v.issuerResourcesNamespace, appRole.SecretRef.Name, err.Error()) + } + client.SetToken(token) + + return client, nil + } + + return nil, fmt.Errorf("error initializing Vault client. tokenSecretRef or appRoleSecretRef not set") +} + +func (v *Vault) requestTokenWithAppRoleRef(client *vault.Client, appRole *v1alpha1.VaultAppRole) (string, error) { + roleId, secretId, err := v.appRoleRef(appRole) + if err != nil { + return "", fmt.Errorf("error reading Vault AppRole from secret: %s/%s: %s", appRole.SecretRef.Name, v.issuerResourcesNamespace, err.Error()) + } + + parameters := map[string]string{ + "role_id": roleId, + "secret_id": secretId, + } + + url := "/v1/auth/approle/login" + + request := client.NewRequest("POST", url) + + err = request.SetJSONBody(parameters) + if err != nil { + return "", fmt.Errorf("error encoding Vault parameters: %s", err.Error()) + } + + resp, err := client.RawRequest(request) + if err != nil { + return "", fmt.Errorf("error calling Vault server: %s", err.Error()) + } + + defer resp.Body.Close() + + vaultResult := vault.Secret{} + resp.DecodeJSON(&vaultResult) + if err != nil { + return "", fmt.Errorf("unable to decode JSON payload: %s", err.Error()) + } + + token, err := vaultResult.TokenID() + if err != nil { + return "", fmt.Errorf("unable to read token: %s", err.Error()) + } + + return token, nil +} + +func (v *Vault) requestVaultCert(commonName string, altNames []string, csr string) ([]byte, error) { + client, err := v.initVaultClient() + if err != nil { + return nil, err + } + + glog.V(4).Infof("Vault certificate request for commonName %s altNames: %q", commonName, altNames) + + parameters := map[string]string{ + "common_name": commonName, + "alt_names": strings.Join(altNames, ","), + "ttl": defaultCertificateDuration.String(), + "csr": csr, + "exclude_cn_from_sans": "true", + } + + url := path.Join("/v1", v.issuer.GetSpec().Vault.Path) + + request := client.NewRequest("POST", url) + + err = request.SetJSONBody(parameters) + if err != nil { + return nil, fmt.Errorf("error encoding Vault parameters: %s", err.Error()) + } + + resp, err := client.RawRequest(request) + if err != nil { + return nil, fmt.Errorf("error calling Vault server: %s", err.Error()) + } + + defer resp.Body.Close() + + vaultResult := certutil.Secret{} + resp.DecodeJSON(&vaultResult) + if err != nil { + return nil, fmt.Errorf("unable to decode JSON payload: %s", err.Error()) + } + + parsedBundle, err := certutil.ParsePKIMap(vaultResult.Data) + if err != nil { + return nil, fmt.Errorf("unable to parse certificate: %s", err.Error()) + } + + bundle, err := parsedBundle.ToCertBundle() + if err != nil { + return nil, fmt.Errorf("unable to convert certificate bundle to PEM bundle: %s", err.Error()) + } + + return []byte(bundle.ToPEMBundle()), nil +} + +func (v *Vault) appRoleRef(appRole *v1alpha1.VaultAppRole) (roleId, secretId string, err error) { + roleId = strings.TrimSpace(appRole.RoleId) + + secret, err := v.secretsLister.Secrets(v.issuerResourcesNamespace).Get(appRole.SecretRef.Name) + if err != nil { + return "", "", err + } + + key := "secretId" + if appRole.SecretRef.Key != "secretId" { + key = appRole.SecretRef.Key + } + + keyBytes, ok := secret.Data[key] + if !ok { + return "", "", fmt.Errorf("no data for %q in secret '%s/%s'", key, appRole.SecretRef.Name, v.issuerResourcesNamespace) + } + + secretId = string(keyBytes) + secretId = strings.TrimSpace(secretId) + + return roleId, secretId, nil +} + +func (v *Vault) vaultTokenRef(name, key string) (string, error) { + secret, err := v.secretsLister.Secrets(v.issuerResourcesNamespace).Get(name) + if err != nil { + return "", err + } + + if key == "" { + key = "token" + } + + keyBytes, ok := secret.Data[key] + if !ok { + return "", fmt.Errorf("no data for %q in secret '%s/%s'", key, name, v.issuerResourcesNamespace) + } + + token := string(keyBytes) + token = strings.TrimSpace(token) + + return token, nil +} diff --git a/pkg/issuer/vault/prepare.go b/pkg/issuer/vault/prepare.go new file mode 100644 index 000000000..8f6365a8a --- /dev/null +++ b/pkg/issuer/vault/prepare.go @@ -0,0 +1,14 @@ +package vault + +import ( + "context" + + "github.com/jetstack/cert-manager/pkg/apis/certmanager/v1alpha1" +) + +// Prepare does nothing for the Vault issuer. In future, this may validate +// the certificate request against the issuer, or set fields in the Status +// block to be consumed in Issue and Renew +func (c *Vault) Prepare(ctx context.Context, crt *v1alpha1.Certificate) error { + return nil +} diff --git a/pkg/issuer/vault/renew.go b/pkg/issuer/vault/renew.go new file mode 100644 index 000000000..95a0530d4 --- /dev/null +++ b/pkg/issuer/vault/renew.go @@ -0,0 +1,28 @@ +package vault + +import ( + "context" + + "github.com/jetstack/cert-manager/pkg/apis/certmanager/v1alpha1" +) + +const ( + errorRenewCert = "ErrRenewCert" + messageErrorRenewCert = "Error renewing TLS certificate: " + + successCertRenewed = "CertRenewSuccess" + messageCertRenewed = "Certificate renewed successfully" +) + +func (c *Vault) Renew(ctx context.Context, crt *v1alpha1.Certificate) ([]byte, []byte, error) { + key, cert, err := c.obtainCertificate(ctx, crt) + if err != nil { + s := messageErrorRenewCert + err.Error() + crt.UpdateStatusCondition(v1alpha1.CertificateConditionReady, v1alpha1.ConditionFalse, errorRenewCert, s, false) + return nil, nil, err + } + + crt.UpdateStatusCondition(v1alpha1.CertificateConditionReady, v1alpha1.ConditionTrue, successCertRenewed, messageCertRenewed, true) + + return key, cert, err +} diff --git a/pkg/issuer/vault/setup.go b/pkg/issuer/vault/setup.go new file mode 100644 index 000000000..3c65f1193 --- /dev/null +++ b/pkg/issuer/vault/setup.go @@ -0,0 +1,82 @@ +package vault + +import ( + "context" + "fmt" + + "github.com/golang/glog" + "github.com/jetstack/cert-manager/pkg/apis/certmanager/v1alpha1" +) + +const ( + successVaultVerified = "VaultVerified" + messageVaultVerified = "Vault verified" + + errorVault = "VaultError" + + messageVaultClientInitFailed = "Failed to initialize Vault client: " + messageVaultHealthCheckFailed = "Failed to call Vault health check: " + messageVaultStatusVerificationFailed = "Vault is not initialized or is sealed: " + messageVaultConfigRequired = "Vault config cannot be empty" + messageServerAndPathRequired = "Vault server and path are required fields" + messsageAuthFieldsRequired = "Vault tokenSecretRef or appRole is required" + messageAuthFieldRequired = "Vault tokenSecretRef and appRole cannot be set on the same issuer" +) + +func (v *Vault) Setup(ctx context.Context) error { + if v.issuer.GetSpec().Vault == nil { + glog.Infof("%s: %s", v.issuer.GetObjectMeta().Name, messageVaultConfigRequired) + v.issuer.UpdateStatusCondition(v1alpha1.IssuerConditionReady, v1alpha1.ConditionFalse, errorVault, messageVaultConfigRequired) + return fmt.Errorf(messageVaultConfigRequired) + } + + if v.issuer.GetSpec().Vault.Server == "" || + v.issuer.GetSpec().Vault.Path == "" { + glog.Infof("%s: %s", v.issuer.GetObjectMeta().Name, messageServerAndPathRequired) + v.issuer.UpdateStatusCondition(v1alpha1.IssuerConditionReady, v1alpha1.ConditionFalse, errorVault, messageServerAndPathRequired) + return fmt.Errorf(messageVaultConfigRequired) + } + + if v.issuer.GetSpec().Vault.Auth.TokenSecretRef.Name == "" && + v.issuer.GetSpec().Vault.Auth.AppRole.RoleId == "" && + v.issuer.GetSpec().Vault.Auth.AppRole.SecretRef.Name == "" { + glog.Infof("%s: %s", v.issuer.GetObjectMeta().Name, messsageAuthFieldsRequired) + v.issuer.UpdateStatusCondition(v1alpha1.IssuerConditionReady, v1alpha1.ConditionFalse, errorVault, messsageAuthFieldsRequired) + return fmt.Errorf(messsageAuthFieldsRequired) + } + + if v.issuer.GetSpec().Vault.Auth.TokenSecretRef.Name != "" && + (v.issuer.GetSpec().Vault.Auth.AppRole.RoleId != "" || + v.issuer.GetSpec().Vault.Auth.AppRole.SecretRef.Name != "") { + glog.Infof("%s: %s", v.issuer.GetObjectMeta().Name, messageAuthFieldRequired) + v.issuer.UpdateStatusCondition(v1alpha1.IssuerConditionReady, v1alpha1.ConditionFalse, errorVault, messageAuthFieldRequired) + return fmt.Errorf(messageAuthFieldRequired) + } + + client, err := v.initVaultClient() + if err != nil { + s := messageVaultClientInitFailed + err.Error() + glog.V(4).Infof("%s: %s", v.issuer.GetObjectMeta().Name, s) + v.issuer.UpdateStatusCondition(v1alpha1.IssuerConditionReady, v1alpha1.ConditionFalse, errorVault, s) + return err + } + + health, err := client.Sys().Health() + if err != nil { + s := messageVaultHealthCheckFailed + err.Error() + glog.V(4).Infof("%s: %s", v.issuer.GetObjectMeta().Name, s) + v.issuer.UpdateStatusCondition(v1alpha1.IssuerConditionReady, v1alpha1.ConditionFalse, errorVault, s) + return err + } + + if !health.Initialized || health.Sealed { + s := messageVaultStatusVerificationFailed + err.Error() + glog.V(4).Infof("%s: %s", v.issuer.GetObjectMeta().Name, s) + v.issuer.UpdateStatusCondition(v1alpha1.IssuerConditionReady, v1alpha1.ConditionFalse, errorVault, s) + return err + } + + glog.Info(messageVaultVerified) + v.issuer.UpdateStatusCondition(v1alpha1.IssuerConditionReady, v1alpha1.ConditionTrue, successVaultVerified, messageVaultVerified) + return nil +} diff --git a/pkg/issuer/vault/vault.go b/pkg/issuer/vault/vault.go new file mode 100644 index 000000000..fd11aadb9 --- /dev/null +++ b/pkg/issuer/vault/vault.go @@ -0,0 +1,64 @@ +package vault + +import ( + "k8s.io/client-go/kubernetes" + corelisters "k8s.io/client-go/listers/core/v1" + "k8s.io/client-go/tools/record" + + "github.com/jetstack/cert-manager/pkg/apis/certmanager/v1alpha1" + clientset "github.com/jetstack/cert-manager/pkg/client/clientset/versioned" + "github.com/jetstack/cert-manager/pkg/issuer" +) + +type Vault struct { + issuer v1alpha1.GenericIssuer + + client kubernetes.Interface + cmclient clientset.Interface + recorder record.EventRecorder + + secretsLister corelisters.SecretLister + + // issuerResourcesNamespace is a namespace to store resources in. This is + // here so we can easily support ClusterIssuers with the same codepath. By + // setting this field to either the namespace of the Issuer, or the + // clusterResourceNamespace specified on the CLI, we can easily continue + // to work with supplemental (e.g. secrets) resources without significant + // refactoring. + issuerResourcesNamespace string +} + +func NewVault(issuerObj v1alpha1.GenericIssuer, + cl kubernetes.Interface, + cmclient clientset.Interface, + recorder record.EventRecorder, + resourceNamespace string, + secretsLister corelisters.SecretLister) (issuer.Interface, error) { + + return &Vault{ + issuer: issuerObj, + client: cl, + cmclient: cmclient, + recorder: recorder, + issuerResourcesNamespace: resourceNamespace, + secretsLister: secretsLister, + }, nil +} + +// Register this Issuer with the issuer factory +func init() { + issuer.Register(issuer.IssuerVault, func(issuer v1alpha1.GenericIssuer, ctx *issuer.Context) (issuer.Interface, error) { + issuerResourcesNamespace := issuer.GetObjectMeta().Namespace + if issuerResourcesNamespace == "" { + issuerResourcesNamespace = ctx.ClusterResourceNamespace + } + return NewVault( + issuer, + ctx.Client, + ctx.CMClient, + ctx.Recorder, + issuerResourcesNamespace, + ctx.KubeSharedInformerFactory.Core().V1().Secrets().Lister(), + ) + }) +} diff --git a/test/e2e/certificate/certificate_vault.go b/test/e2e/certificate/certificate_vault.go new file mode 100644 index 000000000..dfd4ac46c --- /dev/null +++ b/test/e2e/certificate/certificate_vault.go @@ -0,0 +1,75 @@ +package certificate + +import ( + "fmt" + + . "github.com/onsi/ginkgo" + . "github.com/onsi/gomega" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + "github.com/jetstack/cert-manager/pkg/apis/certmanager/v1alpha1" + "github.com/jetstack/cert-manager/test/e2e/framework" + "github.com/jetstack/cert-manager/test/util" + "github.com/jetstack/cert-manager/test/util/vault" +) + +var _ = framework.CertManagerDescribe("Vault Certificate (AppRole)", func() { + f := framework.NewDefaultFramework("create-vault-certificate") + + rootMount := "root-ca" + intermediateMount := "intermediate-ca" + role := "kubernetes-vault" + issuerName := "test-vault-issuer" + certificateName := "test-vault-certificate" + certificateSecretName := "test-vault-certificate" + vaultSecretAppRoleName := "vault-role" + vaultPath := fmt.Sprintf("%s/sign/%s", intermediateMount, role) + var vaultInit *vault.VaultInitializer + var roleId string + var secretId string + + BeforeEach(func() { + By("Configuring the Vault server") + podList, err := f.KubeClientSet.CoreV1().Pods("vault").List(metav1.ListOptions{}) + Expect(err).NotTo(HaveOccurred()) + vaultPodName := podList.Items[0].Name + vaultInit, err = vault.NewVaultInitializer(vaultPodName, rootMount, intermediateMount, role) + Expect(err).NotTo(HaveOccurred()) + err = vaultInit.Setup() + Expect(err).NotTo(HaveOccurred()) + roleId, secretId, err = vaultInit.CreateAppRole() + Expect(err).NotTo(HaveOccurred()) + _, err = f.KubeClientSet.CoreV1().Secrets(f.Namespace.Name).Create(vault.NewVaultAppRoleSecret(vaultSecretAppRoleName, secretId)) + Expect(err).NotTo(HaveOccurred()) + }) + + AfterEach(func() { + By("Cleaning up") + f.CertManagerClientSet.CertmanagerV1alpha1().Issuers(f.Namespace.Name).Delete(issuerName, nil) + f.KubeClientSet.CoreV1().Secrets(f.Namespace.Name).Delete(vaultSecretAppRoleName, nil) + vaultInit.CleanAppRole() + vaultInit.Clean() + }) + + vaultURL := "http://vault.vault:8200" + It("should generate a new valid certificate", func() { + By("Creating an Issuer") + _, err := f.CertManagerClientSet.CertmanagerV1alpha1().Issuers(f.Namespace.Name).Create(util.NewCertManagerVaultIssuerAppRole(issuerName, vaultURL, vaultPath, roleId, vaultSecretAppRoleName)) + Expect(err).NotTo(HaveOccurred()) + + By("Waiting for Issuer to become Ready") + err = util.WaitForIssuerCondition(f.CertManagerClientSet.CertmanagerV1alpha1().Issuers(f.Namespace.Name), + issuerName, + v1alpha1.IssuerCondition{ + Type: v1alpha1.IssuerConditionReady, + Status: v1alpha1.ConditionTrue, + }) + Expect(err).NotTo(HaveOccurred()) + + By("Creating a Certificate") + cert, err := f.CertManagerClientSet.CertmanagerV1alpha1().Certificates(f.Namespace.Name).Create(util.NewCertManagerVaultCertificate(certificateName, certificateSecretName, issuerName, v1alpha1.IssuerKind)) + Expect(err).NotTo(HaveOccurred()) + + f.WaitCertificateIssuedValid(cert) + }) +}) diff --git a/test/e2e/e2e.go b/test/e2e/e2e.go index bb473bd0b..d2f6587b9 100644 --- a/test/e2e/e2e.go +++ b/test/e2e/e2e.go @@ -62,6 +62,9 @@ func RunE2ETests(t *testing.T) { extraArgs = append(extraArgs, "--set", "image.tag="+framework.TestContext.PebbleImageTag) } InstallHelmChart(t, "pebble", "./contrib/charts/pebble", "pebble", "./test/fixtures/pebble-values.yaml", extraArgs...) + + InstallHelmChart(t, "vault", "./contrib/charts/vault", "vault", "./test/fixtures/vault-values.yaml") + glog.Infof("Starting e2e run %q on Ginkgo node %d", framework.RunId, config.GinkgoConfig.ParallelNode) var r []ginkgo.Reporter diff --git a/test/e2e/issuer/issuer_vault.go b/test/e2e/issuer/issuer_vault.go new file mode 100644 index 000000000..07b22accd --- /dev/null +++ b/test/e2e/issuer/issuer_vault.go @@ -0,0 +1,101 @@ +package issuer + +import ( + "fmt" + "time" + + . "github.com/onsi/ginkgo" + . "github.com/onsi/gomega" + + "github.com/jetstack/cert-manager/pkg/apis/certmanager/v1alpha1" + "github.com/jetstack/cert-manager/test/e2e/framework" + "github.com/jetstack/cert-manager/test/util" + "github.com/jetstack/cert-manager/test/util/vault" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +var _ = framework.CertManagerDescribe("Vault Issuer", func() { + f := framework.NewDefaultFramework("create-vault-issuer") + + issuerName := "test-vault-issuer" + rootMount := "root-ca" + intermediateMount := "intermediate-ca" + role := "kubernetes-vault" + vaultSecretAppRoleName := "vault-role" + vaultSecretTokenName := "vault-token" + vaultPath := fmt.Sprintf("%s/sign/%s", intermediateMount, role) + var roleId, secretId string + var vaultInit *vault.VaultInitializer + + BeforeEach(func() { + By("Configuring the Vault server") + podList, err := f.KubeClientSet.CoreV1().Pods("vault").List(metav1.ListOptions{}) + Expect(err).NotTo(HaveOccurred()) + vaultPodName := podList.Items[0].Name + vaultInit, err = vault.NewVaultInitializer(vaultPodName, rootMount, intermediateMount, role) + Expect(err).NotTo(HaveOccurred()) + err = vaultInit.Setup() + Expect(err).NotTo(HaveOccurred()) + roleId, secretId, err = vaultInit.CreateAppRole() + Expect(err).NotTo(HaveOccurred()) + }) + + AfterEach(func() { + By("Cleaning up") + f.CertManagerClientSet.CertmanagerV1alpha1().Issuers(f.Namespace.Name).Delete(issuerName, nil) + f.KubeClientSet.CoreV1().Secrets(f.Namespace.Name).Delete(vaultSecretAppRoleName, nil) + vaultInit.CleanAppRole() + vaultInit.Clean() + }) + + const vaultDefaultDuration = time.Hour * 24 * 90 + + vaultURL := "http://vault.vault:8200" + It("should be ready with a valid AppRole", func() { + _, err := f.KubeClientSet.CoreV1().Secrets(f.Namespace.Name).Create(vault.NewVaultAppRoleSecret(vaultSecretAppRoleName, secretId)) + Expect(err).NotTo(HaveOccurred()) + + By("Creating an Issuer") + _, err = f.CertManagerClientSet.CertmanagerV1alpha1().Issuers(f.Namespace.Name).Create(util.NewCertManagerVaultIssuerAppRole(issuerName, vaultURL, vaultPath, roleId, vaultSecretAppRoleName)) + Expect(err).NotTo(HaveOccurred()) + + By("Waiting for Issuer to become Ready") + err = util.WaitForIssuerCondition(f.CertManagerClientSet.CertmanagerV1alpha1().Issuers(f.Namespace.Name), + issuerName, + v1alpha1.IssuerCondition{ + Type: v1alpha1.IssuerConditionReady, + Status: v1alpha1.ConditionTrue, + }) + Expect(err).NotTo(HaveOccurred()) + }) + + It("should fail to init with missing Vault AppRole", func() { + By("Creating an Issuer") + _, err := f.CertManagerClientSet.CertmanagerV1alpha1().Issuers(f.Namespace.Name).Create(util.NewCertManagerVaultIssuerAppRole(issuerName, vaultURL, vaultPath, roleId, vaultSecretAppRoleName)) + Expect(err).NotTo(HaveOccurred()) + + By("Waiting for Issuer to become Ready") + err = util.WaitForIssuerCondition(f.CertManagerClientSet.CertmanagerV1alpha1().Issuers(f.Namespace.Name), + issuerName, + v1alpha1.IssuerCondition{ + Type: v1alpha1.IssuerConditionReady, + Status: v1alpha1.ConditionFalse, + }) + Expect(err).NotTo(HaveOccurred()) + }) + + It("should fail to init with missing Vault Token", func() { + By("Creating an Issuer") + _, err := f.CertManagerClientSet.CertmanagerV1alpha1().Issuers(f.Namespace.Name).Create(util.NewCertManagerVaultIssuerToken(issuerName, vaultURL, vaultPath, vaultSecretTokenName)) + Expect(err).NotTo(HaveOccurred()) + + By("Waiting for Issuer to become Ready") + err = util.WaitForIssuerCondition(f.CertManagerClientSet.CertmanagerV1alpha1().Issuers(f.Namespace.Name), + issuerName, + v1alpha1.IssuerCondition{ + Type: v1alpha1.IssuerConditionReady, + Status: v1alpha1.ConditionFalse, + }) + Expect(err).NotTo(HaveOccurred()) + }) +}) diff --git a/test/fixtures/vault-values.yaml b/test/fixtures/vault-values.yaml new file mode 100644 index 000000000..e69de29bb diff --git a/test/util/util.go b/test/util/util.go index 466b3cccc..00bc8a131 100644 --- a/test/util/util.go +++ b/test/util/util.go @@ -287,6 +287,22 @@ func NewCertManagerACMECertificate(name, secretName, issuerName string, issuerKi } } +func NewCertManagerVaultCertificate(name, secretName, issuerName string, issuerKind string) *v1alpha1.Certificate { + return &v1alpha1.Certificate{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + }, + Spec: v1alpha1.CertificateSpec{ + CommonName: "test.domain.com", + SecretName: secretName, + IssuerRef: v1alpha1.ObjectReference{ + Name: issuerName, + Kind: issuerKind, + }, + }, + } +} + func NewIngress(name, secretName string, annotations map[string]string, dnsNames ...string) *extv1beta1.Ingress { return &extv1beta1.Ingress{ ObjectMeta: metav1.ObjectMeta{ @@ -360,6 +376,57 @@ func NewCertManagerCAIssuer(name, secretName string) *v1alpha1.Issuer { } } +func NewCertManagerVaultIssuerToken(name, vaultURL, vaultPath, vaultSecretToken string) *v1alpha1.Issuer { + return &v1alpha1.Issuer{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + }, + Spec: v1alpha1.IssuerSpec{ + IssuerConfig: v1alpha1.IssuerConfig{ + Vault: &v1alpha1.VaultIssuer{ + Server: vaultURL, + Path: vaultPath, + Auth: v1alpha1.VaultAuth{ + TokenSecretRef: v1alpha1.SecretKeySelector{ + Key: "secretkey", + LocalObjectReference: v1alpha1.LocalObjectReference{ + Name: vaultSecretToken, + }, + }, + }, + }, + }, + }, + } +} + +func NewCertManagerVaultIssuerAppRole(name, vaultURL, vaultPath, roleId, vaultSecretAppRole string) *v1alpha1.Issuer { + return &v1alpha1.Issuer{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + }, + Spec: v1alpha1.IssuerSpec{ + IssuerConfig: v1alpha1.IssuerConfig{ + Vault: &v1alpha1.VaultIssuer{ + Server: vaultURL, + Path: vaultPath, + Auth: v1alpha1.VaultAuth{ + AppRole: v1alpha1.VaultAppRole{ + RoleId: roleId, + SecretRef: v1alpha1.SecretKeySelector{ + Key: "secretkey", + LocalObjectReference: v1alpha1.LocalObjectReference{ + Name: vaultSecretAppRole, + }, + }, + }, + }, + }, + }, + }, + } +} + func NewSigningKeypairSecret(name string) *v1.Secret { return &v1.Secret{ ObjectMeta: metav1.ObjectMeta{ diff --git a/test/util/vault/OWNERS b/test/util/vault/OWNERS new file mode 100644 index 000000000..1fb493914 --- /dev/null +++ b/test/util/vault/OWNERS @@ -0,0 +1,5 @@ +approvers: +- munnerz +reviewers: +- munnerz +- vdesjardins diff --git a/test/util/vault/util.go b/test/util/vault/util.go new file mode 100644 index 000000000..2c3d48c90 --- /dev/null +++ b/test/util/vault/util.go @@ -0,0 +1,332 @@ +package vault + +import ( + "fmt" + "os/exec" + "path" + "time" + + "github.com/golang/glog" + vault "github.com/hashicorp/vault/api" + "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +const vaultToken = "vault-root-token" + +func NewVaultTokenSecret(name string) *v1.Secret { + return &v1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + }, + StringData: map[string]string{ + "token": vaultToken, + }, + } +} + +func NewVaultAppRoleSecret(name, secretId string) *v1.Secret { + return &v1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + }, + StringData: map[string]string{ + "secretkey": secretId, + }, + } +} + +type VaultInitializer struct { + proxyCmd *exec.Cmd + client *vault.Client + rootMount string + intermediateMount string + role string +} + +func NewVaultInitializer(container, rootMount, intermediateMount, role string) (*VaultInitializer, error) { + args := []string{"port-forward", "-n", "vault", container, "8200:8200"} + cmd := exec.Command("kubectl", args...) + err := cmd.Start() + if err != nil { + glog.Fatalf("Error starting port-forward: %s", err.Error()) + } + + time.Sleep(3 * time.Second) + + cfg := vault.DefaultConfig() + cfg.Address = "http://127.0.0.1:8200" + client, err := vault.NewClient(cfg) + if err != nil { + return nil, fmt.Errorf("Unable to initialize vault client: %s", err.Error()) + } + + client.SetToken(vaultToken) + + return &VaultInitializer{ + proxyCmd: cmd, + client: client, + rootMount: rootMount, + intermediateMount: intermediateMount, + role: role, + }, nil +} + +func (v *VaultInitializer) Setup() error { + if err := v.mountPKI(v.rootMount, "87600h"); err != nil { + return err + } + + rootCa, err := v.generateRootCert() + if err != nil { + return err + } + + if err := v.configureCert(v.rootMount); err != nil { + return err + + } + + if err := v.mountPKI(v.intermediateMount, "43800h"); err != nil { + return err + } + + csr, err := v.generateIntermediateSigningReq() + if err != nil { + return err + } + + intermediateCa, err := v.signCertificate(csr) + if err != nil { + return err + } + + if err := v.importSignIntermediate(intermediateCa, rootCa, v.intermediateMount); err != nil { + return err + } + + if err := v.configureCert(v.intermediateMount); err != nil { + return err + } + + if err := v.setupRole(); err != nil { + return err + } + + return nil +} + +func (v *VaultInitializer) Clean() error { + if err := v.client.Sys().Unmount("/" + v.intermediateMount); err != nil { + return fmt.Errorf("Unable to unmount %v: %v", v.intermediateMount, err) + } + if err := v.client.Sys().Unmount("/" + v.rootMount); err != nil { + return fmt.Errorf("Unable to unmount %v: %v", v.rootMount, err) + } + + v.proxyCmd.Process.Kill() + v.proxyCmd.Process.Wait() + + return nil +} + +func (v *VaultInitializer) CreateAppRole() (string, string, error) { + // create policy + role_path := path.Join(v.intermediateMount, "sign", v.role) + policy := fmt.Sprintf("path \"%s\" { capabilities = [ \"create\", \"update\" ] }", role_path) + err := v.client.Sys().PutPolicy(v.role, policy) + if err != nil { + return "", "", fmt.Errorf("Error creating policy: %s", err.Error()) + } + + // # create approle + params := map[string]string{ + "period": "24h", + "policies": v.role, + } + url := path.Join("/v1/auth/approle/role", v.role) + _, err = v.callVault("POST", url, "", params) + if err != nil { + return "", "", fmt.Errorf("Error creating approle: %s", err.Error()) + } + + // # read the role-id + url = path.Join("/v1/auth/approle/role", v.role, "role-id") + roleId, err := v.callVault("GET", url, "role_id", map[string]string{}) + if err != nil { + return "", "", fmt.Errorf("Error reading role_id: %s", err.Error()) + } + + // # read the secret-id + url = path.Join("/v1/auth/approle/role", v.role, "secret-id") + secretId, err := v.callVault("POST", url, "secret_id", map[string]string{}) + if err != nil { + return "", "", fmt.Errorf("Error reading secret_id: %s", err.Error()) + } + + return roleId, secretId, nil +} + +func (v *VaultInitializer) CleanAppRole() error { + url := path.Join("/v1/auth/approle/role", v.role) + _, err := v.callVault("DELETE", url, "", map[string]string{}) + if err != nil { + return fmt.Errorf("Error deleting AppRole: %s", err.Error()) + } + + err = v.client.Sys().DeletePolicy(v.role) + if err != nil { + return fmt.Errorf("Error deleting policy: %s", err.Error()) + } + + return nil +} + +func (v *VaultInitializer) mountPKI(mount, ttl string) error { + opts := &vault.MountInput{ + Type: "pki", + Config: vault.MountConfigInput{ + MaxLeaseTTL: "87600h", + }, + } + if err := v.client.Sys().Mount("/"+mount, opts); err != nil { + return fmt.Errorf("Error mounting %s: %s", mount, err.Error()) + } + + return nil +} + +func (v *VaultInitializer) generateRootCert() (string, error) { + params := map[string]string{ + "common_name": "Root CA", + "ttl": "87600h", + "exclude_cn_from_sans": "true", + } + url := path.Join("/v1", v.rootMount, "root", "generate", "internal") + + cert, err := v.callVault("POST", url, "certificate", params) + if err != nil { + return "", fmt.Errorf("Error generating CA root certificate: %s", err.Error()) + } + + return cert, nil +} + +func (v *VaultInitializer) generateIntermediateSigningReq() (string, error) { + params := map[string]string{ + "common_name": "Intermediate CA", + "ttl": "43800h", + "exclude_cn_from_sans": "true", + } + url := path.Join("/v1", v.intermediateMount, "intermediate", "generate", "internal") + + csr, err := v.callVault("POST", url, "csr", params) + if err != nil { + return "", fmt.Errorf("Error generating CA intermediate certificate: %s", err.Error()) + } + + return csr, nil +} + +func (v *VaultInitializer) signCertificate(csr string) (string, error) { + params := map[string]string{ + "use_csr_values": "true", + "ttl": "43800h", + "exclude_cn_from_sans": "true", + "csr": csr, + } + url := path.Join("/v1", v.rootMount, "root", "sign-intermediate") + + cert, err := v.callVault("POST", url, "certificate", params) + if err != nil { + return "", fmt.Errorf("Error signing intermediate Vault certificate: %s", err.Error()) + } + + return cert, nil +} + +func (v *VaultInitializer) importSignIntermediate(intermediateCa, rootCa, intermediateMount string) error { + params := map[string]string{ + "certificate": intermediateCa, + } + url := path.Join("/v1", intermediateMount, "intermediate", "set-signed") + + _, err := v.callVault("POST", url, "", params) + if err != nil { + return fmt.Errorf("Error importing intermediate Vault certificate: %s", err.Error()) + } + + return nil +} + +func (v *VaultInitializer) configureCert(mount string) error { + params := map[string]string{ + "issuing_certificates": fmt.Sprintf("http://vault.vault:8200/v1/%s/ca", mount), + "crl_distribution_points": fmt.Sprintf("http://vault.vault:8200/v1/%s/crl", mount), + } + url := path.Join("/v1", mount, "config", "urls") + + _, err := v.callVault("POST", url, "", params) + if err != nil { + return fmt.Errorf("Error configuring Vault certificate: %s", err.Error()) + } + + return nil +} + +func (v *VaultInitializer) setupRole() error { + // vault auth-enable approle + auths, err := v.client.Sys().ListAuth() + if err != nil { + return fmt.Errorf("Error fetching auth mounts: %s", err.Error()) + } + if _, ok := auths["approle/"]; !ok { + options := &vault.EnableAuthOptions{Type: "approle"} + if err := v.client.Sys().EnableAuthWithOptions("approle", options); err != nil { + return fmt.Errorf("Error enabling approle: %s", err.Error()) + } + } + + // vault write intermediate-ca/roles/kubernetes-vault allow_any_name=true max_ttl="2160h" + params := map[string]string{ + "allow_any_name": "true", + "max_ttl": "2160h", + } + url := path.Join("/v1", v.intermediateMount, "roles", v.role) + + _, err = v.callVault("POST", url, "", params) + if err != nil { + return fmt.Errorf("Error creating role %s: %s", v.role, err.Error()) + } + + return nil +} + +func (v *VaultInitializer) callVault(method, url, field string, params map[string]string) (string, error) { + req := v.client.NewRequest(method, url) + + err := req.SetJSONBody(params) + if err != nil { + return "", fmt.Errorf("error encoding Vault parameters: %s", err.Error()) + + } + + resp, err := v.client.RawRequest(req) + if err != nil { + return "", fmt.Errorf("error calling Vault server: %s", err.Error()) + + } + + defer resp.Body.Close() + + result := map[string]interface{}{} + resp.DecodeJSON(&result) + + fieldData := "" + if field != "" { + data := result["data"].(map[string]interface{}) + fieldData = data[field].(string) + } + + return fieldData, err +}