| /* |
| Copyright 2021 The Kubernetes Authors. |
| |
| Licensed under the Apache License, Version 2.0 (the "License"); |
| you may not use this file except in compliance with the License. |
| You may obtain a copy of the License at |
| |
| http://www.apache.org/licenses/LICENSE-2.0 |
| |
| Unless required by applicable law or agreed to in writing, software |
| distributed under the License is distributed on an "AS IS" BASIS, |
| WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
| See the License for the specific language governing permissions and |
| limitations under the License. |
| */ |
| |
| package certificates |
| |
| import ( |
| "context" |
| "crypto/ecdsa" |
| "crypto/elliptic" |
| "crypto/rand" |
| "crypto/x509/pkix" |
| "encoding/pem" |
| "os" |
| "path" |
| "strings" |
| "testing" |
| "time" |
| |
| "github.com/google/go-cmp/cmp" |
| |
| certificatesv1 "k8s.io/api/certificates/v1" |
| v1 "k8s.io/api/core/v1" |
| metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" |
| "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured/unstructuredscheme" |
| "k8s.io/apimachinery/pkg/runtime/schema" |
| "k8s.io/apiserver/pkg/server/dynamiccertificates" |
| "k8s.io/client-go/informers" |
| clientset "k8s.io/client-go/kubernetes" |
| "k8s.io/client-go/rest" |
| certutil "k8s.io/client-go/util/cert" |
| "k8s.io/client-go/util/certificate/csr" |
| "k8s.io/client-go/util/keyutil" |
| "k8s.io/klog/v2/ktesting" |
| kubeapiservertesting "k8s.io/kubernetes/cmd/kube-apiserver/app/testing" |
| "k8s.io/kubernetes/pkg/controller/certificates/signer" |
| "k8s.io/kubernetes/test/integration/framework" |
| "k8s.io/utils/pointer" |
| ) |
| |
| func TestCSRDuration(t *testing.T) { |
| t.Parallel() |
| |
| s := kubeapiservertesting.StartTestServerOrDie(t, nil, nil, framework.SharedEtcd()) |
| t.Cleanup(s.TearDownFn) |
| |
| _, ctx := ktesting.NewTestContext(t) |
| ctx, cancel := context.WithTimeout(ctx, 3*time.Minute) |
| t.Cleanup(cancel) |
| |
| // assert that the metrics we collect during the test run match expectations |
| // we have 7 valid test cases below that request a duration of which 6 should have their duration honored |
| wantMetricStrings := []string{ |
| `apiserver_certificates_registry_csr_honored_duration_total{signerName="kubernetes.io/kube-apiserver-client"} 6`, |
| `apiserver_certificates_registry_csr_requested_duration_total{signerName="kubernetes.io/kube-apiserver-client"} 7`, |
| } |
| t.Cleanup(func() { |
| copyConfig := rest.CopyConfig(s.ClientConfig) |
| copyConfig.GroupVersion = &schema.GroupVersion{} |
| copyConfig.NegotiatedSerializer = unstructuredscheme.NewUnstructuredNegotiatedSerializer() |
| rc, err := rest.RESTClientFor(copyConfig) |
| if err != nil { |
| t.Fatal(err) |
| } |
| body, err := rc.Get().AbsPath("/metrics").DoRaw(ctx) |
| if err != nil { |
| t.Fatal(err) |
| } |
| var gotMetricStrings []string |
| for _, line := range strings.Split(string(body), "\n") { |
| if strings.HasPrefix(line, "apiserver_certificates_registry_") { |
| gotMetricStrings = append(gotMetricStrings, line) |
| } |
| } |
| if diff := cmp.Diff(wantMetricStrings, gotMetricStrings); diff != "" { |
| t.Errorf("unexpected metrics diff (-want +got): %s", diff) |
| } |
| }) |
| |
| client := clientset.NewForConfigOrDie(s.ClientConfig) |
| informerFactory := informers.NewSharedInformerFactory(client, 0) |
| |
| caPrivateKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) |
| if err != nil { |
| t.Fatal(err) |
| } |
| caCert, err := certutil.NewSelfSignedCACert(certutil.Config{CommonName: "test-ca"}, caPrivateKey) |
| if err != nil { |
| t.Fatal(err) |
| } |
| caPublicKeyFile := path.Join(s.TmpDir, "test-ca-public-key") |
| if err := os.WriteFile(caPublicKeyFile, pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: caCert.Raw}), os.FileMode(0600)); err != nil { |
| t.Fatal(err) |
| } |
| caPrivateKeyBytes, err := keyutil.MarshalPrivateKeyToPEM(caPrivateKey) |
| if err != nil { |
| t.Fatal(err) |
| } |
| caPrivateKeyFile := path.Join(s.TmpDir, "test-ca-private-key") |
| if err := os.WriteFile(caPrivateKeyFile, caPrivateKeyBytes, os.FileMode(0600)); err != nil { |
| t.Fatal(err) |
| } |
| |
| c, err := signer.NewKubeAPIServerClientCSRSigningController(ctx, client, informerFactory.Certificates().V1().CertificateSigningRequests(), caPublicKeyFile, caPrivateKeyFile, 24*time.Hour) |
| if err != nil { |
| t.Fatal(err) |
| } |
| |
| informerFactory.Start(ctx.Done()) |
| go c.Run(ctx, 1) |
| |
| tests := []struct { |
| name, csrName string |
| duration *time.Duration |
| wantDuration time.Duration |
| wantError string |
| }{ |
| { |
| name: "no duration set", |
| duration: nil, |
| wantDuration: 24 * time.Hour, |
| wantError: "", |
| }, |
| { |
| name: "same duration set as certTTL", |
| duration: pointer.Duration(24 * time.Hour), |
| wantDuration: 24 * time.Hour, |
| wantError: "", |
| }, |
| { |
| name: "longer duration than certTTL", |
| duration: pointer.Duration(48 * time.Hour), |
| wantDuration: 24 * time.Hour, |
| wantError: "", |
| }, |
| { |
| name: "slightly shorter duration set", |
| duration: pointer.Duration(20 * time.Hour), |
| wantDuration: 20 * time.Hour, |
| wantError: "", |
| }, |
| { |
| name: "even shorter duration set", |
| duration: pointer.Duration(10 * time.Hour), |
| wantDuration: 10 * time.Hour, |
| wantError: "", |
| }, |
| { |
| name: "short duration set", |
| duration: pointer.Duration(2 * time.Hour), |
| wantDuration: 2*time.Hour + 5*time.Minute, |
| wantError: "", |
| }, |
| { |
| name: "very short duration set", |
| duration: pointer.Duration(30 * time.Minute), |
| wantDuration: 30*time.Minute + 5*time.Minute, |
| wantError: "", |
| }, |
| { |
| name: "shortest duration set", |
| duration: pointer.Duration(10 * time.Minute), |
| wantDuration: 10*time.Minute + 5*time.Minute, |
| wantError: "", |
| }, |
| { |
| name: "just too short duration set", |
| csrName: "invalid-csr-001", |
| duration: pointer.Duration(10*time.Minute - time.Second), |
| wantDuration: 0, |
| wantError: `cannot create certificate signing request: ` + |
| `CertificateSigningRequest.certificates.k8s.io "invalid-csr-001" is invalid: spec.expirationSeconds: Invalid value: 599: may not specify a duration less than 600 seconds (10 minutes)`, |
| }, |
| { |
| name: "really too short duration set", |
| csrName: "invalid-csr-002", |
| duration: pointer.Duration(3 * time.Minute), |
| wantDuration: 0, |
| wantError: `cannot create certificate signing request: ` + |
| `CertificateSigningRequest.certificates.k8s.io "invalid-csr-002" is invalid: spec.expirationSeconds: Invalid value: 180: may not specify a duration less than 600 seconds (10 minutes)`, |
| }, |
| { |
| name: "negative duration set", |
| csrName: "invalid-csr-003", |
| duration: pointer.Duration(-7 * time.Minute), |
| wantDuration: 0, |
| wantError: `cannot create certificate signing request: ` + |
| `CertificateSigningRequest.certificates.k8s.io "invalid-csr-003" is invalid: spec.expirationSeconds: Invalid value: -420: may not specify a duration less than 600 seconds (10 minutes)`, |
| }, |
| } |
| for _, tt := range tests { |
| tt := tt |
| t.Run(tt.name, func(t *testing.T) { |
| t.Parallel() |
| |
| privateKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) |
| if err != nil { |
| t.Fatal(err) |
| } |
| csrData, err := certutil.MakeCSR(privateKey, &pkix.Name{CommonName: "panda"}, nil, nil) |
| if err != nil { |
| t.Fatal(err) |
| } |
| |
| csrName, csrUID, errReq := csr.RequestCertificate(client, csrData, tt.csrName, certificatesv1.KubeAPIServerClientSignerName, |
| tt.duration, []certificatesv1.KeyUsage{certificatesv1.UsageClientAuth}, privateKey) |
| |
| if diff := cmp.Diff(tt.wantError, errStr(errReq)); len(diff) > 0 { |
| t.Fatalf("CSR input duration %v err diff (-want, +got):\n%s", tt.duration, diff) |
| } |
| |
| if len(tt.wantError) > 0 { |
| return |
| } |
| |
| csrObj, err := client.CertificatesV1().CertificateSigningRequests().Get(ctx, csrName, metav1.GetOptions{}) |
| if err != nil { |
| t.Fatal(err) |
| } |
| csrObj.Status.Conditions = []certificatesv1.CertificateSigningRequestCondition{ |
| { |
| Type: certificatesv1.CertificateApproved, |
| Status: v1.ConditionTrue, |
| Reason: "TestCSRDuration", |
| Message: t.Name(), |
| }, |
| } |
| _, err = client.CertificatesV1().CertificateSigningRequests().UpdateApproval(ctx, csrName, csrObj, metav1.UpdateOptions{}) |
| if err != nil { |
| t.Fatal(err) |
| } |
| |
| certData, err := csr.WaitForCertificate(ctx, client, csrName, csrUID) |
| if err != nil { |
| t.Fatal(err) |
| } |
| |
| certs, err := certutil.ParseCertsPEM(certData) |
| if err != nil { |
| t.Fatal(err) |
| } |
| |
| switch l := len(certs); l { |
| case 1: |
| // good |
| default: |
| t.Errorf("expected 1 cert, got %d", l) |
| for i, certificate := range certs { |
| t.Log(i, dynamiccertificates.GetHumanCertDetail(certificate)) |
| } |
| t.FailNow() |
| } |
| |
| cert := certs[0] |
| |
| if got := cert.NotAfter.Sub(cert.NotBefore); got != tt.wantDuration { |
| t.Errorf("CSR input duration %v got duration = %v, want %v\n%s", tt.duration, got, tt.wantDuration, dynamiccertificates.GetHumanCertDetail(cert)) |
| } |
| }) |
| } |
| } |
| |
| func errStr(err error) string { |
| if err == nil { |
| return "" |
| } |
| es := err.Error() |
| if len(es) == 0 { |
| panic("invalid empty error") |
| } |
| return es |
| } |