diff --git a/e2e-tests/run-pr.csv b/e2e-tests/run-pr.csv index e2cc4ef93e..e44eef9988 100644 --- a/e2e-tests/run-pr.csv +++ b/e2e-tests/run-pr.csv @@ -75,6 +75,7 @@ service-per-pod serviceless-external-nodes smart-update split-horizon +split-horizon-manual-tls stable-resource-version storage tls-issue-cert-manager diff --git a/e2e-tests/run-release.csv b/e2e-tests/run-release.csv index 87cc8a8b22..4a1042169e 100644 --- a/e2e-tests/run-release.csv +++ b/e2e-tests/run-release.csv @@ -75,6 +75,7 @@ service-per-pod serviceless-external-nodes smart-update split-horizon +split-horizon-manual-tls stable-resource-version storage tls-issue-cert-manager diff --git a/e2e-tests/split-horizon-manual-tls/compare/horizons-3.json b/e2e-tests/split-horizon-manual-tls/compare/horizons-3.json new file mode 100644 index 0000000000..a54ead10ba --- /dev/null +++ b/e2e-tests/split-horizon-manual-tls/compare/horizons-3.json @@ -0,0 +1,11 @@ +[ + { + "external" : "some-name-rs0-0.clouddemo.xyz:27017" + }, + { + "external" : "some-name-rs0-1.clouddemo.xyz:27017" + }, + { + "external" : "some-name-rs0-2.clouddemo.xyz:27017" + } +] diff --git a/e2e-tests/split-horizon-manual-tls/compare/horizons-5.json b/e2e-tests/split-horizon-manual-tls/compare/horizons-5.json new file mode 100644 index 0000000000..a8eac1c216 --- /dev/null +++ b/e2e-tests/split-horizon-manual-tls/compare/horizons-5.json @@ -0,0 +1,17 @@ +[ + { + "external" : "some-name-rs0-0.clouddemo.xyz:27017" + }, + { + "external" : "some-name-rs0-1.clouddemo.xyz:27017" + }, + { + "external" : "some-name-rs0-2.clouddemo.xyz:27017" + }, + { + "external" : "some-name-rs0-3.clouddemo.xyz:27017" + }, + { + "external" : "some-name-rs0-4.clouddemo.xyz:27017" + } +] diff --git a/e2e-tests/split-horizon-manual-tls/conf/some-name-3horizons.yml b/e2e-tests/split-horizon-manual-tls/conf/some-name-3horizons.yml new file mode 100644 index 0000000000..377df0b191 --- /dev/null +++ b/e2e-tests/split-horizon-manual-tls/conf/some-name-3horizons.yml @@ -0,0 +1,40 @@ +apiVersion: psmdb.percona.com/v1 +kind: PerconaServerMongoDB +metadata: + name: some-name +spec: + #platform: openshift + image: + imagePullPolicy: Always + backup: + enabled: false + image: perconalab/percona-server-mongodb-operator:main-backup + replsets: + - name: rs0 + size: 3 + expose: + enabled: true + type: ClusterIP + splitHorizons: + some-name-rs0-0: + external: some-name-rs0-0.clouddemo.xyz + some-name-rs0-1: + external: some-name-rs0-1.clouddemo.xyz + some-name-rs0-2: + external: some-name-rs0-2.clouddemo.xyz + affinity: + antiAffinityTopologyKey: none + resources: + limits: + cpu: 500m + memory: 0.5G + requests: + cpu: 100m + memory: 0.1G + volumeSpec: + persistentVolumeClaim: + resources: + requests: + storage: 1Gi + secrets: + users: some-users diff --git a/e2e-tests/split-horizon-manual-tls/conf/some-name-5horizons.yml b/e2e-tests/split-horizon-manual-tls/conf/some-name-5horizons.yml new file mode 100644 index 0000000000..8630238337 --- /dev/null +++ b/e2e-tests/split-horizon-manual-tls/conf/some-name-5horizons.yml @@ -0,0 +1,44 @@ +apiVersion: psmdb.percona.com/v1 +kind: PerconaServerMongoDB +metadata: + name: some-name +spec: + #platform: openshift + image: + imagePullPolicy: Always + backup: + enabled: false + image: perconalab/percona-server-mongodb-operator:main-backup + replsets: + - name: rs0 + size: 3 + expose: + enabled: true + type: ClusterIP + splitHorizons: + some-name-rs0-0: + external: some-name-rs0-0.clouddemo.xyz + some-name-rs0-1: + external: some-name-rs0-1.clouddemo.xyz + some-name-rs0-2: + external: some-name-rs0-2.clouddemo.xyz + some-name-rs0-3: + external: some-name-rs0-3.clouddemo.xyz + some-name-rs0-4: + external: some-name-rs0-4.clouddemo.xyz + affinity: + antiAffinityTopologyKey: none + resources: + limits: + cpu: 500m + memory: 0.5G + requests: + cpu: 100m + memory: 0.1G + volumeSpec: + persistentVolumeClaim: + resources: + requests: + storage: 1Gi + secrets: + users: some-users diff --git a/e2e-tests/split-horizon-manual-tls/conf/some-name.yml b/e2e-tests/split-horizon-manual-tls/conf/some-name.yml new file mode 100644 index 0000000000..04f446ab15 --- /dev/null +++ b/e2e-tests/split-horizon-manual-tls/conf/some-name.yml @@ -0,0 +1,33 @@ +apiVersion: psmdb.percona.com/v1 +kind: PerconaServerMongoDB +metadata: + name: some-name +spec: + #platform: openshift + image: + imagePullPolicy: Always + backup: + enabled: false + image: perconalab/percona-server-mongodb-operator:main-backup + replsets: + - name: rs0 + size: 3 + expose: + enabled: true + type: ClusterIP + affinity: + antiAffinityTopologyKey: none + resources: + limits: + cpu: 500m + memory: 0.5G + requests: + cpu: 100m + memory: 0.1G + volumeSpec: + persistentVolumeClaim: + resources: + requests: + storage: 1Gi + secrets: + users: some-users \ No newline at end of file diff --git a/e2e-tests/split-horizon-manual-tls/run b/e2e-tests/split-horizon-manual-tls/run new file mode 100755 index 0000000000..e616489743 --- /dev/null +++ b/e2e-tests/split-horizon-manual-tls/run @@ -0,0 +1,186 @@ +#!/bin/bash + +set -o errexit +set -o xtrace + +test_dir=$(realpath "$(dirname "$0")") +. "${test_dir}"/../functions + +verify_cert_san() { + local secret_name=$1 + local expected_san=$2 + + local san_list + san_list=$(kubectl_bin get secret "${secret_name}" -o jsonpath='{.data.tls\.crt}' \ + | base64 -d \ + | openssl x509 -text -noout 2>/dev/null \ + | grep "DNS:" \ + | tr ',' '\n' \ + | sed 's/.*DNS://g' \ + | xargs) + + if echo "${san_list}" | grep -q "${expected_san}"; then + echo "OK: SAN '${expected_san}' found in secret '${secret_name}'" + else + echo "FAIL: SAN '${expected_san}' NOT found in secret '${secret_name}'" + echo " SANs found: ${san_list}" + return 1 + fi +} + +verify_ca_secret_exists() { + local secret_name=$1 + + if kubectl_bin get secret "${secret_name}" -o jsonpath='{.data.ca\.crt}' >/dev/null 2>&1 && + kubectl_bin get secret "${secret_name}" -o jsonpath='{.data.ca\.key}' >/dev/null 2>&1; then + echo "OK: CA secret '${secret_name}' exists with ca.crt and ca.key" + else + echo "FAIL: CA secret '${secret_name}' does not have ca.crt and ca.key" + return 1 + fi +} + +verify_cert_signed_by_ca() { + local tls_secret_name=$1 + local ca_secret_name=$2 + + local ca_crt + ca_crt=$(kubectl_bin get secret "${ca_secret_name}" -o jsonpath='{.data.ca\.crt}' | base64 -d) + local tls_crt + tls_crt=$(kubectl_bin get secret "${tls_secret_name}" -o jsonpath='{.data.tls\.crt}' | base64 -d) + + echo "${ca_crt}" >"${tmp_dir}/ca.crt" + echo "${tls_crt}" >"${tmp_dir}/tls.crt" + + if openssl verify -CAfile "${tmp_dir}/ca.crt" "${tmp_dir}/tls.crt" >/dev/null 2>&1; then + echo "OK: '${tls_secret_name}' is signed by CA in '${ca_secret_name}'" + else + echo "FAIL: '${tls_secret_name}' is NOT signed by CA in '${ca_secret_name}'" + return 1 + fi +} + +save_cert_hash() { + local secret_name=$1 + local output_var=$2 + + kubectl_bin get secret "${secret_name}" -o jsonpath='{.data.tls\.crt}' | base64 -d | openssl x509 -fingerprint -noout 2>/dev/null +} + +configure_client_hostAliases() { + local hostAliasesJson='[]' + + for svc in $(kubectl get svc | awk '{print $3 "|" $1}' | grep -E '^[0-9].*'); do + hostname=$(echo "${svc}" | awk -F '|' '{print $2}') + ip=$(echo "${svc}" | awk -F '|' '{print $1}') + hostAlias="{\"ip\": \"${ip}\", \"hostnames\": [\"${hostname}.clouddemo.xyz\"]}" + hostAliasesJson=$(echo "$hostAliasesJson" | jq --argjson newAlias "$hostAlias" '. += [$newAlias]') + done + + kubectl_bin patch deployment psmdb-client --type='json' -p="[{'op': 'replace', 'path': '/spec/replicas', 'value': 0}]" + + wait_for_delete "pod/$(kubectl_bin get pods --selector=name=psmdb-client -o 'jsonpath={.items[].metadata.name}')" + + kubectl_bin patch deployment psmdb-client --type='json' -p="[{'op': 'replace', 'path': '/spec/template/spec/hostAliases', 'value': $hostAliasesJson}, {'op': 'replace', 'path': '/spec/replicas', 'value': 1}]" + + wait_pod "$(kubectl_bin get pods --selector=name=psmdb-client -o 'jsonpath={.items[].metadata.name}')" +} + +main() { + create_infra "${namespace}" + destroy_cert_manager || true # Ensure we test manual TLS, not cert-manager + + cluster="some-name" + kubectl_bin apply \ + -f "${conf_dir}"/secrets.yml \ + -f "${conf_dir}"/client_with_tls.yml + + desc 'deploy cluster with 3 split horizons (manual TLS)' + apply_cluster "${test_dir}"/conf/${cluster}-3horizons.yml + wait_for_running "${cluster}-rs0" 3 + wait_cluster_consistency ${cluster} + + desc 'verify CA secret exists with ca.crt and ca.key' + verify_ca_secret_exists "${cluster}-ca-cert" + + desc 'verify TLS secrets are signed by the CA' + verify_cert_signed_by_ca "${cluster}-ssl" "${cluster}-ca-cert" + verify_cert_signed_by_ca "${cluster}-ssl-internal" "${cluster}-ca-cert" + + desc 'verify split-horizon DNS names are in certificate SANs' + verify_cert_san "${cluster}-ssl" "some-name-rs0-0.clouddemo.xyz" + verify_cert_san "${cluster}-ssl" "some-name-rs0-1.clouddemo.xyz" + verify_cert_san "${cluster}-ssl" "some-name-rs0-2.clouddemo.xyz" + verify_cert_san "${cluster}-ssl-internal" "some-name-rs0-0.clouddemo.xyz" + + desc 'save certificate fingerprint before horizon update' + cert_hash_before=$(save_cert_hash "${cluster}-ssl") + + configure_client_hostAliases + + sleep 10 # give some time for client pod to be ready + + desc 'verify horizons via rs.conf()' + run_mongo_tls "rs.conf().members.map(function(member) { return member.horizons }).sort((a, b) => a.external.localeCompare(b.external))" \ + "clusterAdmin:clusterAdmin123456@some-name-rs0-0.clouddemo.xyz,some-name-rs0-1.clouddemo.xyz,some-name-rs0-2.clouddemo.xyz" \ + mongodb "" "--quiet" | grep -E -v 'I NETWORK|W NETWORK|Error saving history file|Percona Server for MongoDB|connecting to:|Unable to reach primary for set|Implicit session:|versions do not match|Error saving history file:|does not match the remote host name' >"${tmp_dir}"/horizons-3.json + diff "$test_dir"/compare/horizons-3.json "$tmp_dir"/horizons-3.json + + desc 'update to 5 horizons (triggers SAN change and cert re-signing)' + apply_cluster "${test_dir}"/conf/${cluster}-5horizons.yml + wait_for_running "${cluster}-rs0" 3 + wait_cluster_consistency ${cluster} + + desc 'verify new horizon DNS names are in certificate SANs after re-signing' + verify_cert_san "${cluster}-ssl" "some-name-rs0-3.clouddemo.xyz" + verify_cert_san "${cluster}-ssl" "some-name-rs0-4.clouddemo.xyz" + + desc 'verify certificate was re-signed (fingerprint changed)' + cert_hash_after=$(save_cert_hash "${cluster}-ssl") + if [ "${cert_hash_before}" = "${cert_hash_after}" ]; then + echo "FAIL: certificate was not re-signed after horizon update" + exit 1 + fi + echo "OK: certificate was re-signed after horizon update" + + desc 'verify TLS secrets are still signed by the SAME CA' + verify_cert_signed_by_ca "${cluster}-ssl" "${cluster}-ca-cert" + verify_cert_signed_by_ca "${cluster}-ssl-internal" "${cluster}-ca-cert" + + desc 'scale up to 5 members' + kubectl_bin patch psmdb ${cluster} \ + --type='json' \ + -p='[{"op": "replace", "path": "/spec/replsets/0/size", "value": 5}]' + wait_for_running "${cluster}-rs0" 5 + wait_cluster_consistency ${cluster} + + desc 'verify horizons after scale up' + run_mongo_tls "rs.conf().members.map(function(member) { return member.horizons }).sort((a, b) => a.external.localeCompare(b.external))" \ + "clusterAdmin:clusterAdmin123456@some-name-rs0-0.clouddemo.xyz,some-name-rs0-1.clouddemo.xyz,some-name-rs0-2.clouddemo.xyz" \ + mongodb "" "--quiet" | grep -E -v 'I NETWORK|W NETWORK|Error saving history file|Percona Server for MongoDB|connecting to:|Unable to reach primary for set|Implicit session:|versions do not match|Error saving history file:|does not match the remote host name' >"${tmp_dir}"/horizons-5.json + diff "$test_dir"/compare/horizons-5.json "$tmp_dir"/horizons-5.json + + desc 'scale down to 3 members' + kubectl_bin patch psmdb ${cluster} \ + --type='json' \ + -p='[{"op": "replace", "path": "/spec/replsets/0/size", "value": 3}]' + wait_for_running "${cluster}-rs0" 3 + wait_cluster_consistency ${cluster} + + desc 'verify horizons after scale down' + run_mongo_tls "rs.conf().members.map(function(member) { return member.horizons }).sort((a, b) => a.external.localeCompare(b.external))" \ + "clusterAdmin:clusterAdmin123456@some-name-rs0-0.clouddemo.xyz,some-name-rs0-1.clouddemo.xyz,some-name-rs0-2.clouddemo.xyz" \ + mongodb "" "--quiet" | grep -E -v 'I NETWORK|W NETWORK|Error saving history file|Percona Server for MongoDB|connecting to:|Unable to reach primary for set|Implicit session:|versions do not match|Error saving history file:|does not match the remote host name' >"${tmp_dir}"/horizons.json + diff "$test_dir"/compare/horizons-3.json "$tmp_dir"/horizons.json + + desc 'remove horizon configuration' + apply_cluster "${test_dir}"/conf/${cluster}.yml + wait_for_running "${cluster}-rs0" 3 + wait_cluster_consistency ${cluster} + + destroy "${namespace}" + + desc 'test passed' +} + +main diff --git a/pkg/controller/perconaservermongodb/ssl.go b/pkg/controller/perconaservermongodb/ssl.go index c618f8fd9c..091686e01e 100644 --- a/pkg/controller/perconaservermongodb/ssl.go +++ b/pkg/controller/perconaservermongodb/ssl.go @@ -3,6 +3,8 @@ package perconaservermongodb import ( "bytes" "context" + "slices" + "sort" "github.com/pkg/errors" appsv1 "k8s.io/api/apps/v1" @@ -87,6 +89,9 @@ func (r *ReconcilePerconaServerMongoDB) reconcileSSL(ctx context.Context, cr *ap } if !ok { if errSecret == nil && errInternalSecret == nil { + if r.needsManualSSLUpdate(ctx, cr, &secretObj) { + return r.updateSSLManually(ctx, cr) + } return nil } err = r.createSSLManually(ctx, cr) @@ -442,69 +447,151 @@ func (r *ReconcilePerconaServerMongoDB) applyCertManagerCertificates(ctx context } func (r *ReconcilePerconaServerMongoDB) createSSLManually(ctx context.Context, cr *api.PerconaServerMongoDB) error { - data := make(map[string][]byte) certificateDNSNames := tls.GetCertificateSans(cr) - caCert, tlsCert, key, err := tls.Issue(certificateDNSNames) - if err != nil { - return errors.Wrap(err, "create proxy certificate") - } - data["ca.crt"] = caCert - data["tls.crt"] = tlsCert - data["tls.key"] = key - owner, err := OwnerRef(cr, r.scheme) if err != nil { return err } ownerReferences := []metav1.OwnerReference{owner} - secretObj := corev1.Secret{ - ObjectMeta: metav1.ObjectMeta{ - Name: api.SSLSecretName(cr), - Namespace: cr.Namespace, - OwnerReferences: ownerReferences, - Labels: naming.ClusterLabels(cr), - }, - Data: data, - Type: corev1.SecretTypeTLS, + // Get or create CA secret + caCertPEM, caKeyPEM, err := r.getOrCreateManualCA(ctx, cr, ownerReferences, naming.ClusterLabels(cr)) + if err != nil { + return errors.Wrap(err, "get or create CA") + } + + // Issue TLS certs signed by the shared CA + for _, secretName := range []string{api.SSLSecretName(cr), api.SSLInternalSecretName(cr)} { + tlsCert, tlsKey, err := tls.IssueWithCA(certificateDNSNames, caCertPEM, caKeyPEM) + if err != nil { + return errors.Wrapf(err, "issue TLS certificate for %s", secretName) + } + + secretObj := corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: secretName, + Namespace: cr.Namespace, + OwnerReferences: ownerReferences, + Labels: naming.ClusterLabels(cr), + }, + Data: map[string][]byte{ + "ca.crt": caCertPEM, + "tls.crt": tlsCert, + "tls.key": tlsKey, + }, + Type: corev1.SecretTypeTLS, + } + if err := r.createSSLSecret(ctx, &secretObj); err != nil { + return errors.Wrapf(err, "create TLS secret %s", secretName) + } } - if cr.CompareVersion("1.17.0") < 0 { - secretObj.Labels = nil + + return nil +} + +// getOrCreateManualCA returns the CA cert and key from the existing CA secret, or creates a new one. +func (r *ReconcilePerconaServerMongoDB) getOrCreateManualCA(ctx context.Context, cr *api.PerconaServerMongoDB, ownerRefs []metav1.OwnerReference, labels map[string]string) (caCert, caKey []byte, err error) { + caSecretName := tls.ManualCASecretName(cr) + caSecret, err := r.getSecret(ctx, cr, caSecretName) + if err == nil { + return caSecret.Data["ca.crt"], caSecret.Data["ca.key"], nil } - err = r.createSSLSecret(ctx, &secretObj, certificateDNSNames) - if err != nil { - return errors.Wrap(err, "create TLS secret") + if !k8serr.IsNotFound(err) { + return nil, nil, errors.Wrap(err, "get CA secret") } - caCert, tlsCert, key, err = tls.Issue(certificateDNSNames) + caCertPEM, caKeyPEM, err := tls.IssueCA() if err != nil { - return errors.Wrap(err, "create psmdb certificate") + return nil, nil, errors.Wrap(err, "issue CA") } - data["ca.crt"] = caCert - data["tls.crt"] = tlsCert - data["tls.key"] = key - secretObjInternal := corev1.Secret{ + + caSecretObj := &corev1.Secret{ ObjectMeta: metav1.ObjectMeta{ - Name: api.SSLInternalSecretName(cr), + Name: caSecretName, Namespace: cr.Namespace, - OwnerReferences: ownerReferences, - Labels: naming.ClusterLabels(cr), + OwnerReferences: ownerRefs, + Labels: labels, + }, + Data: map[string][]byte{ + "ca.crt": caCertPEM, + "ca.key": caKeyPEM, }, - Data: data, - Type: corev1.SecretTypeTLS, } - if cr.CompareVersion("1.17.0") < 0 { - secretObjInternal.Labels = nil + if err := r.client.Create(ctx, caSecretObj); err != nil { + return nil, nil, errors.Wrap(err, "create CA secret") } - err = r.createSSLSecret(ctx, &secretObjInternal, certificateDNSNames) + + return caCertPEM, caKeyPEM, nil +} + +// needsManualSSLUpdate checks if the TLS certificate SANs differ from the expected SANs. +func (r *ReconcilePerconaServerMongoDB) needsManualSSLUpdate(ctx context.Context, cr *api.PerconaServerMongoDB, sslSecret *corev1.Secret) bool { + if sslSecret == nil || len(sslSecret.Data["tls.crt"]) == 0 { + return false + } + + currentSANs, err := tls.GetSANsFromCert(sslSecret.Data["tls.crt"]) if err != nil { - return errors.Wrap(err, "create TLS internal secret") + return false } + + expectedSANs := tls.GetCertificateSans(cr) + + sort.Strings(currentSANs) + sort.Strings(expectedSANs) + + return !slices.Equal(currentSANs, expectedSANs) +} + +// updateSSLManually re-signs TLS certificates with the existing CA when SANs change. +func (r *ReconcilePerconaServerMongoDB) updateSSLManually(ctx context.Context, cr *api.PerconaServerMongoDB) error { + log := logf.FromContext(ctx).WithName("updateSSLManually") + + caSecretName := tls.ManualCASecretName(cr) + caSecret, err := r.getSecret(ctx, cr, caSecretName) + if err != nil { + if k8serr.IsNotFound(err) { + // CA secret doesn't exist (created before this feature). Cannot re-sign safely. + log.Info("CA secret not found, skipping manual SSL update. Delete TLS secrets to trigger full regeneration.", "secret", caSecretName) + return nil + } + return errors.Wrap(err, "get CA secret") + } + + caCertPEM := caSecret.Data["ca.crt"] + caKeyPEM := caSecret.Data["ca.key"] + + certificateDNSNames := tls.GetCertificateSans(cr) + log.Info("SANs changed, re-signing TLS certificates with existing CA") + + for _, secretName := range []string{api.SSLSecretName(cr), api.SSLInternalSecretName(cr)} { + secret, err := r.getSecret(ctx, cr, secretName) + if err != nil { + if k8serr.IsNotFound(err) { + continue + } + return errors.Wrapf(err, "get secret %s", secretName) + } + + tlsCert, tlsKey, err := tls.IssueWithCA(certificateDNSNames, caCertPEM, caKeyPEM) + if err != nil { + return errors.Wrapf(err, "re-sign TLS certificate for %s", secretName) + } + + secret.Data["tls.crt"] = tlsCert + secret.Data["tls.key"] = tlsKey + + if err := r.client.Update(ctx, secret); err != nil { + return errors.Wrapf(err, "update secret %s", secretName) + } + log.Info("TLS certificate re-signed", "secret", secretName) + } + return nil } -func (r *ReconcilePerconaServerMongoDB) createSSLSecret(ctx context.Context, secret *corev1.Secret, DNSNames []string) error { +func (r *ReconcilePerconaServerMongoDB) createSSLSecret(ctx context.Context, secret *corev1.Secret) error { oldSecret := new(corev1.Secret) err := r.client.Get(ctx, types.NamespacedName{ diff --git a/pkg/psmdb/tls/manual_tls_test.go b/pkg/psmdb/tls/manual_tls_test.go new file mode 100644 index 0000000000..2c9c4fd6fe --- /dev/null +++ b/pkg/psmdb/tls/manual_tls_test.go @@ -0,0 +1,214 @@ +package tls + +import ( + "crypto/x509" + "encoding/pem" + "sort" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + api "github.com/percona/percona-server-mongodb-operator/pkg/apis/psmdb/v1" +) + +func TestIssueCA(t *testing.T) { + caCert, caKey, err := IssueCA() + require.NoError(t, err) + require.NotEmpty(t, caCert) + require.NotEmpty(t, caKey) + + // Parse and verify CA certificate + block, _ := pem.Decode(caCert) + require.NotNil(t, block, "failed to decode CA cert PEM") + + cert, err := x509.ParseCertificate(block.Bytes) + require.NoError(t, err) + + assert.True(t, cert.IsCA) + assert.Equal(t, "Root CA", cert.Subject.Organization[0]) + assert.True(t, cert.KeyUsage&x509.KeyUsageCertSign != 0) + + // Verify CA key is valid PEM + keyBlock, _ := pem.Decode(caKey) + require.NotNil(t, keyBlock, "failed to decode CA key PEM") + assert.Equal(t, "RSA PRIVATE KEY", keyBlock.Type) + + _, err = x509.ParsePKCS1PrivateKey(keyBlock.Bytes) + require.NoError(t, err) +} + +func TestIssueWithCA(t *testing.T) { + caCert, caKey, err := IssueCA() + require.NoError(t, err) + + hosts := []string{"mongo-0.example.com", "mongo-1.example.com", "localhost"} + + tlsCert, tlsKey, err := IssueWithCA(hosts, caCert, caKey) + require.NoError(t, err) + require.NotEmpty(t, tlsCert) + require.NotEmpty(t, tlsKey) + + // Parse TLS cert + block, _ := pem.Decode(tlsCert) + require.NotNil(t, block) + cert, err := x509.ParseCertificate(block.Bytes) + require.NoError(t, err) + + assert.False(t, cert.IsCA) + assert.Equal(t, "PSMDB", cert.Subject.Organization[0]) + + sort.Strings(cert.DNSNames) + sort.Strings(hosts) + assert.Equal(t, hosts, cert.DNSNames) + + // Verify TLS cert is signed by the CA + caBlock, _ := pem.Decode(caCert) + require.NotNil(t, caBlock) + caCertParsed, err := x509.ParseCertificate(caBlock.Bytes) + require.NoError(t, err) + + pool := x509.NewCertPool() + pool.AddCert(caCertParsed) + + _, err = cert.Verify(x509.VerifyOptions{ + Roots: pool, + }) + assert.NoError(t, err, "TLS cert should be verifiable by its CA") +} + +func TestIssueWithCA_ReSigningSameCA(t *testing.T) { + caCert, caKey, err := IssueCA() + require.NoError(t, err) + + // Issue first cert + hosts1 := []string{"mongo-0.example.com", "mongo-1.example.com"} + tlsCert1, _, err := IssueWithCA(hosts1, caCert, caKey) + require.NoError(t, err) + + // Issue second cert with different SANs (simulating splitHorizon addition) + hosts2 := []string{"mongo-0.example.com", "mongo-1.example.com", "external.example.com"} + tlsCert2, _, err := IssueWithCA(hosts2, caCert, caKey) + require.NoError(t, err) + + // Both certs should be verifiable by the same CA + caBlock, _ := pem.Decode(caCert) + require.NotNil(t, caBlock) + caCertParsed, err := x509.ParseCertificate(caBlock.Bytes) + require.NoError(t, err) + + pool := x509.NewCertPool() + pool.AddCert(caCertParsed) + + opts := x509.VerifyOptions{Roots: pool} + + block1, _ := pem.Decode(tlsCert1) + cert1, err := x509.ParseCertificate(block1.Bytes) + require.NoError(t, err) + _, err = cert1.Verify(opts) + assert.NoError(t, err, "first cert should be verifiable by CA") + + block2, _ := pem.Decode(tlsCert2) + cert2, err := x509.ParseCertificate(block2.Bytes) + require.NoError(t, err) + _, err = cert2.Verify(opts) + assert.NoError(t, err, "second cert (re-signed) should be verifiable by same CA") + + // Verify SANs differ + sort.Strings(cert1.DNSNames) + sort.Strings(cert2.DNSNames) + assert.NotEqual(t, cert1.DNSNames, cert2.DNSNames) + + expectedHosts2 := make([]string, len(hosts2)) + copy(expectedHosts2, hosts2) + sort.Strings(expectedHosts2) + assert.Equal(t, expectedHosts2, cert2.DNSNames) +} + +func TestIssueWithCA_InvalidInputs(t *testing.T) { + caCert, caKey, err := IssueCA() + require.NoError(t, err) + + t.Run("invalid CA cert", func(t *testing.T) { + _, _, err := IssueWithCA([]string{"host"}, []byte("not-a-cert"), caKey) + assert.Error(t, err) + }) + + t.Run("invalid CA key", func(t *testing.T) { + _, _, err := IssueWithCA([]string{"host"}, caCert, []byte("not-a-key")) + assert.Error(t, err) + }) + + t.Run("empty inputs", func(t *testing.T) { + _, _, err := IssueWithCA([]string{"host"}, nil, nil) + assert.Error(t, err) + }) +} + +func TestGetSANsFromCert(t *testing.T) { + hosts := []string{"localhost", "mongo-0.example.com", "*.mongo.ns"} + + caCert, caKey, err := IssueCA() + require.NoError(t, err) + + tlsCert, _, err := IssueWithCA(hosts, caCert, caKey) + require.NoError(t, err) + + sans, err := GetSANsFromCert(tlsCert) + require.NoError(t, err) + + sort.Strings(sans) + sort.Strings(hosts) + assert.Equal(t, hosts, sans) +} + +func TestGetSANsFromCert_InvalidInput(t *testing.T) { + _, err := GetSANsFromCert([]byte("not-a-cert")) + assert.Error(t, err) + + _, err = GetSANsFromCert(nil) + assert.Error(t, err) +} + +func TestIssueBackwardCompat(t *testing.T) { + hosts := []string{"localhost", "mongo-0.example.com"} + + caCert, tlsCert, tlsKey, err := Issue(hosts) + require.NoError(t, err) + require.NotEmpty(t, caCert) + require.NotEmpty(t, tlsCert) + require.NotEmpty(t, tlsKey) + + // CA should be valid + caBlock, _ := pem.Decode(caCert) + require.NotNil(t, caBlock) + ca, err := x509.ParseCertificate(caBlock.Bytes) + require.NoError(t, err) + assert.True(t, ca.IsCA) + + // TLS cert should be signed by the CA + pool := x509.NewCertPool() + pool.AddCert(ca) + + tlsBlock, _ := pem.Decode(tlsCert) + require.NotNil(t, tlsBlock) + cert, err := x509.ParseCertificate(tlsBlock.Bytes) + require.NoError(t, err) + + _, err = cert.Verify(x509.VerifyOptions{Roots: pool}) + assert.NoError(t, err) + + sort.Strings(cert.DNSNames) + sort.Strings(hosts) + assert.Equal(t, hosts, cert.DNSNames) +} + +func TestManualCASecretName(t *testing.T) { + cr := &api.PerconaServerMongoDB{ + ObjectMeta: metav1.ObjectMeta{ + Name: "my-cluster", + }, + } + assert.Equal(t, "my-cluster-ca-cert", ManualCASecretName(cr)) +} diff --git a/pkg/psmdb/tls/tls.go b/pkg/psmdb/tls/tls.go index ed123cd71a..078259a9f0 100644 --- a/pkg/psmdb/tls/tls.go +++ b/pkg/psmdb/tls/tls.go @@ -62,27 +62,22 @@ func isCertManagerSecretCreatedByUser(ctx context.Context, c client.Client, cr * return true, nil } -// Issue returns CA certificate, TLS certificate and TLS private key -func Issue(hosts []string) (caCert []byte, tlsCert []byte, tlsKey []byte, err error) { - rsaBits := 2048 - priv, err := rsa.GenerateKey(rand.Reader, rsaBits) +// IssueCA generates a new self-signed CA certificate and returns the CA cert and CA private key in PEM format. +func IssueCA() (caCertPEM []byte, caKeyPEM []byte, err error) { + priv, err := rsa.GenerateKey(rand.Reader, 2048) if err != nil { - return nil, nil, nil, fmt.Errorf("generate rsa key: %v", err) + return nil, nil, fmt.Errorf("generate CA key: %v", err) } serialNumberLimit := new(big.Int).Lsh(big.NewInt(1), 128) serialNumber, err := rand.Int(rand.Reader, serialNumberLimit) if err != nil { - return nil, nil, nil, errors.Wrap(err, "generate serial number for root") - } - subject := pkix.Name{ - Organization: []string{"Root CA"}, - } - issuer := pkix.Name{ - Organization: []string{"Root CA"}, + return nil, nil, errors.Wrap(err, "generate serial number for CA") } caTemplate := x509.Certificate{ - SerialNumber: serialNumber, - Subject: subject, + SerialNumber: serialNumber, + Subject: pkix.Name{ + Organization: []string{"Root CA"}, + }, NotBefore: time.Now(), NotAfter: validityNotAfter, KeyUsage: x509.KeyUsageCertSign | x509.KeyUsageKeyEncipherment, @@ -93,26 +88,56 @@ func Issue(hosts []string) (caCert []byte, tlsCert []byte, tlsKey []byte, err er derBytes, err := x509.CreateCertificate(rand.Reader, &caTemplate, &caTemplate, &priv.PublicKey, priv) if err != nil { - return nil, nil, nil, fmt.Errorf("generate CA certificate: %v", err) + return nil, nil, fmt.Errorf("generate CA certificate: %v", err) } + certOut := &bytes.Buffer{} - err = pem.Encode(certOut, &pem.Block{Type: "CERTIFICATE", Bytes: derBytes}) + if err := pem.Encode(certOut, &pem.Block{Type: "CERTIFICATE", Bytes: derBytes}); err != nil { + return nil, nil, fmt.Errorf("encode CA certificate: %v", err) + } + + keyOut := &bytes.Buffer{} + if err := pem.Encode(keyOut, &pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(priv)}); err != nil { + return nil, nil, fmt.Errorf("encode CA private key: %v", err) + } + + return certOut.Bytes(), keyOut.Bytes(), nil +} + +// IssueWithCA generates a TLS certificate signed by the given CA and returns the TLS cert and TLS private key in PEM format. +func IssueWithCA(hosts []string, caCertPEM, caKeyPEM []byte) (tlsCert []byte, tlsKey []byte, err error) { + caBlock, _ := pem.Decode(caCertPEM) + if caBlock == nil { + return nil, nil, fmt.Errorf("failed to decode CA certificate PEM") + } + caCert, err := x509.ParseCertificate(caBlock.Bytes) if err != nil { - return nil, nil, nil, fmt.Errorf("encode CA certificate: %v", err) + return nil, nil, errors.Wrap(err, "parse CA certificate") } - cert := certOut.Bytes() - serialNumber, err = rand.Int(rand.Reader, serialNumberLimit) + caKeyBlock, _ := pem.Decode(caKeyPEM) + if caKeyBlock == nil { + return nil, nil, fmt.Errorf("failed to decode CA private key PEM") + } + caKey, err := x509.ParsePKCS1PrivateKey(caKeyBlock.Bytes) if err != nil { - return nil, nil, nil, errors.Wrap(err, "generate serial number for client") + return nil, nil, errors.Wrap(err, "parse CA private key") } - subject = pkix.Name{ - Organization: []string{"PSMDB"}, + + serialNumberLimit := new(big.Int).Lsh(big.NewInt(1), 128) + serialNumber, err := rand.Int(rand.Reader, serialNumberLimit) + if err != nil { + return nil, nil, errors.Wrap(err, "generate serial number for TLS cert") } + tlsTemplate := x509.Certificate{ - SerialNumber: serialNumber, - Subject: subject, - Issuer: issuer, + SerialNumber: serialNumber, + Subject: pkix.Name{ + Organization: []string{"PSMDB"}, + }, + Issuer: pkix.Name{ + Organization: []string{"Root CA"}, + }, NotBefore: time.Now(), NotAfter: validityNotAfter, DNSNames: hosts, @@ -121,30 +146,61 @@ func Issue(hosts []string) (caCert []byte, tlsCert []byte, tlsKey []byte, err er BasicConstraintsValid: true, IsCA: false, } + clientKey, err := rsa.GenerateKey(rand.Reader, 2048) if err != nil { - return nil, nil, nil, errors.Wrap(err, "generate client key") + return nil, nil, errors.Wrap(err, "generate TLS key") } - tlsDerBytes, err := x509.CreateCertificate(rand.Reader, &tlsTemplate, &caTemplate, &clientKey.PublicKey, priv) + + tlsDerBytes, err := x509.CreateCertificate(rand.Reader, &tlsTemplate, caCert, &clientKey.PublicKey, caKey) if err != nil { - return nil, nil, nil, err + return nil, nil, errors.Wrap(err, "generate TLS certificate") } + tlsCertOut := &bytes.Buffer{} - err = pem.Encode(tlsCertOut, &pem.Block{Type: "CERTIFICATE", Bytes: tlsDerBytes}) - if err != nil { - return nil, nil, nil, fmt.Errorf("encode TLS certificate: %v", err) + if err := pem.Encode(tlsCertOut, &pem.Block{Type: "CERTIFICATE", Bytes: tlsDerBytes}); err != nil { + return nil, nil, fmt.Errorf("encode TLS certificate: %v", err) } - tlsCert = tlsCertOut.Bytes() keyOut := &bytes.Buffer{} - block := &pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(clientKey)} - err = pem.Encode(keyOut, block) + if err := pem.Encode(keyOut, &pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(clientKey)}); err != nil { + return nil, nil, fmt.Errorf("encode TLS private key: %v", err) + } + + return tlsCertOut.Bytes(), keyOut.Bytes(), nil +} + +// Issue returns CA certificate, TLS certificate and TLS private key +func Issue(hosts []string) (caCert []byte, tlsCert []byte, tlsKey []byte, err error) { + caCertPEM, caKeyPEM, err := IssueCA() if err != nil { - return nil, nil, nil, fmt.Errorf("encode RSA private key: %v", err) + return nil, nil, nil, errors.Wrap(err, "issue CA") } - privKey := keyOut.Bytes() - return cert, tlsCert, privKey, nil + tlsCertPEM, tlsKeyPEM, err := IssueWithCA(hosts, caCertPEM, caKeyPEM) + if err != nil { + return nil, nil, nil, errors.Wrap(err, "issue TLS cert") + } + + return caCertPEM, tlsCertPEM, tlsKeyPEM, nil +} + +// GetSANsFromCert extracts DNS SANs from a PEM-encoded TLS certificate. +func GetSANsFromCert(tlsCertPEM []byte) ([]string, error) { + block, _ := pem.Decode(tlsCertPEM) + if block == nil { + return nil, fmt.Errorf("failed to decode TLS certificate PEM") + } + cert, err := x509.ParseCertificate(block.Bytes) + if err != nil { + return nil, errors.Wrap(err, "parse TLS certificate") + } + return cert.DNSNames, nil +} + +// ManualCASecretName returns the name of the CA secret for manual TLS management. +func ManualCASecretName(cr *api.PerconaServerMongoDB) string { + return cr.Name + "-ca-cert" } // Config returns tls.Config to be used in mongo.Config