
Close outbound connections when using a cert callback and certificates rotate. This means that we won't get into a situation where we have open TLS connections using expires certs, which would get unauthorized errors at the apiserver Attempt to retrieve a new certificate if open connections near expiry, to prevent the case where the cert expires but we haven't yet opened a new TLS connection and so GetClientCertificate hasn't been called. Move certificate rotation logic to a separate function Rely on generic transport approach to handle closing TLS client connections in exec plugin; no need to use a custom dialer as this is now the default behaviour of the transport when faced with a cert callback. As a result of handling this case, it is now safe to apply the transport approach even in cases where there is a custom Dialer (this will not affect kubelet connrotation behaviour, because that uses a custom transport, not just a dialer). Check expiry of the full TLS certificate chain that will be presented, not only the leaf. Only do this check when the certificate actually rotates. Start the certificate as a zero value, not nil, so that we don't see a rotation when there is in fact no client certificate Drain the timer when we first initialize it, to prevent immediate rotation. Additionally, calling Stop() on the timer isn't necessary. Don't close connections on the first 'rotation' Remove RotateCertFromDisk and RotateClientCertFromDisk flags. Instead simply default to rotating certificates from disk whenever files are exclusively provided. Add integration test for client certificate rotation Simplify logic; rotate every 5 mins Instead of trying to be clever and checking for rotation just before an expiry, let's match the logic of the new apiserver cert rotation logic as much as possible. We write a controller that checks for rotation every 5 mins. We also check on every new connection. Respond to review Fix kubelet certificate rotation logic The kubelet rotation logic seems to be broken because it expects its cert files to end up as cert data whereas in fact they end up as a callback. We should just call the tlsConfig GetCertificate callback as this obtains a current cert even in cases where a static cert is provided, and check that for validity. Later on we can refactor all of the kubelet logic so that all it does is write files to disk, and the cert rotation work does the rest. Only read certificates once a second at most Respond to review 1) Don't blat the cert file names 2) Make it more obvious where we have a neverstop 3) Naming 4) Verbosity Avoid cache busting Use filenames as cache keys when rotation is enabled, and add the rotation later in the creation of the transport. Caller should start the rotating dialer Add continuous request rotation test Rebase: use context in List/Watch Swap goroutine around Retry GETs on net.IsProbableEOF Refactor certRotatingDialer For simplicity, don't affect cert callbacks To reduce change surface, lets not try to handle the case of a changing GetCert callback in this PR. Reverting this commit should be sufficient to handle that case in a later PR. This PR will focus only on rotating certificate and key files. Therefore, we don't need to modify the exec auth plugin. Fix copyright year
215 lines
5.7 KiB
Go
215 lines
5.7 KiB
Go
/*
|
|
Copyright 2020 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 client
|
|
|
|
import (
|
|
"context"
|
|
"crypto/rand"
|
|
"crypto/rsa"
|
|
"crypto/x509"
|
|
"crypto/x509/pkix"
|
|
"encoding/pem"
|
|
"io/ioutil"
|
|
"math"
|
|
"math/big"
|
|
"os"
|
|
"path"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/stretchr/testify/assert"
|
|
v1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
|
clientset "k8s.io/client-go/kubernetes"
|
|
"k8s.io/client-go/transport"
|
|
"k8s.io/client-go/util/cert"
|
|
apiservertesting "k8s.io/kubernetes/cmd/kube-apiserver/app/testing"
|
|
"k8s.io/kubernetes/test/integration/framework"
|
|
"k8s.io/kubernetes/test/utils"
|
|
)
|
|
|
|
func TestCertRotation(t *testing.T) {
|
|
stopCh := make(chan struct{})
|
|
defer close(stopCh)
|
|
|
|
clientSigningKey, err := utils.NewPrivateKey()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
clientSigningCert, err := cert.NewSelfSignedCACert(cert.Config{CommonName: "client-ca"}, clientSigningKey)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
transport.CertCallbackRefreshDuration = 1 * time.Second
|
|
|
|
certDir := os.TempDir()
|
|
clientCAFilename := path.Join(certDir, "ca.crt")
|
|
|
|
if err := ioutil.WriteFile(clientCAFilename, utils.EncodeCertPEM(clientSigningCert), 0644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
server := apiservertesting.StartTestServerOrDie(t, apiservertesting.NewDefaultTestServerOptions(), []string{
|
|
"--client-ca-file=" + clientCAFilename,
|
|
}, framework.SharedEtcd())
|
|
defer server.TearDownFn()
|
|
|
|
writeCerts(t, clientSigningCert, clientSigningKey, certDir, 30*time.Second)
|
|
|
|
kubeconfig := server.ClientConfig
|
|
kubeconfig.CertFile = path.Join(certDir, "client.crt")
|
|
kubeconfig.KeyFile = path.Join(certDir, "client.key")
|
|
kubeconfig.BearerToken = ""
|
|
|
|
client := clientset.NewForConfigOrDie(kubeconfig)
|
|
ctx := context.Background()
|
|
|
|
w, err := client.CoreV1().ServiceAccounts("default").Watch(ctx, v1.ListOptions{})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
select {
|
|
case <-w.ResultChan():
|
|
t.Fatal("Watch closed before rotation")
|
|
default:
|
|
}
|
|
|
|
writeCerts(t, clientSigningCert, clientSigningKey, certDir, 5*time.Minute)
|
|
|
|
time.Sleep(10 * time.Second)
|
|
|
|
// Should have had a rotation; connections will have been closed
|
|
select {
|
|
case _, ok := <-w.ResultChan():
|
|
assert.Equal(t, false, ok)
|
|
default:
|
|
t.Fatal("Watch wasn't closed despite rotation")
|
|
}
|
|
|
|
// Wait for old cert to expire (30s)
|
|
time.Sleep(30 * time.Second)
|
|
|
|
// Ensure we make requests with the new cert
|
|
_, err = client.CoreV1().ServiceAccounts("default").List(ctx, v1.ListOptions{})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
|
|
func TestCertRotationContinuousRequests(t *testing.T) {
|
|
stopCh := make(chan struct{})
|
|
defer close(stopCh)
|
|
|
|
clientSigningKey, err := utils.NewPrivateKey()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
clientSigningCert, err := cert.NewSelfSignedCACert(cert.Config{CommonName: "client-ca"}, clientSigningKey)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
transport.CertCallbackRefreshDuration = 1 * time.Second
|
|
|
|
certDir := os.TempDir()
|
|
clientCAFilename := path.Join(certDir, "ca.crt")
|
|
|
|
if err := ioutil.WriteFile(clientCAFilename, utils.EncodeCertPEM(clientSigningCert), 0644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
server := apiservertesting.StartTestServerOrDie(t, apiservertesting.NewDefaultTestServerOptions(), []string{
|
|
"--client-ca-file=" + clientCAFilename,
|
|
}, framework.SharedEtcd())
|
|
defer server.TearDownFn()
|
|
|
|
writeCerts(t, clientSigningCert, clientSigningKey, certDir, 30*time.Second)
|
|
|
|
kubeconfig := server.ClientConfig
|
|
kubeconfig.CertFile = path.Join(certDir, "client.crt")
|
|
kubeconfig.KeyFile = path.Join(certDir, "client.key")
|
|
kubeconfig.BearerToken = ""
|
|
|
|
client := clientset.NewForConfigOrDie(kubeconfig)
|
|
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
|
|
go func() {
|
|
time.Sleep(10 * time.Second)
|
|
|
|
writeCerts(t, clientSigningCert, clientSigningKey, certDir, 5*time.Minute)
|
|
|
|
// Wait for old cert to expire (30s)
|
|
time.Sleep(30 * time.Second)
|
|
cancel()
|
|
}()
|
|
|
|
for range time.Tick(time.Second) {
|
|
_, err := client.CoreV1().ServiceAccounts("default").List(ctx, v1.ListOptions{})
|
|
if err != nil {
|
|
if err == ctx.Err() {
|
|
return
|
|
}
|
|
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
}
|
|
|
|
func writeCerts(t *testing.T, clientSigningCert *x509.Certificate, clientSigningKey *rsa.PrivateKey, certDir string, duration time.Duration) {
|
|
clientKey, err := utils.NewPrivateKey()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
privBytes, err := x509.MarshalPKCS8PrivateKey(clientKey)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
if err := ioutil.WriteFile(path.Join(certDir, "client.key"), pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: privBytes}), 0666); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
serial, err := rand.Int(rand.Reader, new(big.Int).SetInt64(math.MaxInt64))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
certTmpl := x509.Certificate{
|
|
Subject: pkix.Name{
|
|
CommonName: "foo",
|
|
Organization: []string{"system:masters"},
|
|
},
|
|
SerialNumber: serial,
|
|
NotBefore: clientSigningCert.NotBefore,
|
|
NotAfter: time.Now().Add(duration).UTC(),
|
|
KeyUsage: x509.KeyUsageKeyEncipherment | x509.KeyUsageDigitalSignature,
|
|
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageClientAuth},
|
|
}
|
|
|
|
certDERBytes, err := x509.CreateCertificate(rand.Reader, &certTmpl, clientSigningCert, clientKey.Public(), clientSigningKey)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
if err := ioutil.WriteFile(path.Join(certDir, "client.crt"), pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: certDERBytes}), 0666); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|