Impersonator config controller writes CA cert & key to different Secret

- The CA cert will end up in the end user's kubeconfig on their client
  machine, so if it changes they would need to fetch the new one and
  update their kubeconfig. Therefore, we should avoid changing it as
  much as possible.
- Now the controller writes the CA to a different Secret. It writes both
  the cert and the key so it can reuse them to create more TLS
  certificates in the future.
- For now, it only needs to make more TLS certificates if the old
  TLS cert Secret gets deleted or updated to be invalid. This allows
  for manual rotation of the TLS certs by simply deleting the Secret.
  In the future, we may want to implement some kind of auto rotation.
- For now, rotation of both the CA and TLS certs will also happen if
  you manually delete the CA Secret. However, this would cause the end
  users to immediately need to get the new CA into their kubeconfig,
  so this is not as elegant as a normal rotation flow where you would
  have a window of time where you have more than one CA.
This commit is contained in:
Ryan Richard 2021-03-01 17:02:08 -08:00
parent f1eeae8c71
commit a2ecd05240
6 changed files with 892 additions and 504 deletions

View File

@ -1,4 +1,4 @@
// Copyright 2020 the Pinniped contributors. All Rights Reserved.
// Copyright 2020-2021 the Pinniped contributors. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0
// Package certauthority implements a simple x509 certificate authority suitable for use in an aggregated API service.
@ -44,12 +44,17 @@ type env struct {
// CA holds the state for a simple x509 certificate authority suitable for use in an aggregated API service.
type CA struct {
// caCert is the DER-encoded certificate for the current CA.
// caCertBytes is the DER-encoded certificate for the current CA.
caCertBytes []byte
// signer is the private key for the current CA.
signer crypto.Signer
// privateKey is the same private key represented by signer, but in a format which allows export.
// It is only set by New, not by Load, since Load can handle various types of PrivateKey but New
// only needs to create keys of type ecdsa.PrivateKey.
privateKey *ecdsa.PrivateKey
// env is our reference to the outside world (clocks and random number generation).
env env
}
@ -99,11 +104,11 @@ func newInternal(subject pkix.Name, ttl time.Duration, env env) (*CA, error) {
}
// Generate a new P256 keypair.
privateKey, err := ecdsa.GenerateKey(elliptic.P256(), env.keygenRNG)
ca.privateKey, err = ecdsa.GenerateKey(elliptic.P256(), env.keygenRNG)
if err != nil {
return nil, fmt.Errorf("could not generate CA private key: %w", err)
}
ca.signer = privateKey
ca.signer = ca.privateKey
// Make a CA certificate valid for some ttl and backdated by some amount.
now := env.clock()
@ -123,7 +128,7 @@ func newInternal(subject pkix.Name, ttl time.Duration, env env) (*CA, error) {
}
// Self-sign the CA to get the DER certificate.
caCertBytes, err := x509.CreateCertificate(env.signingRNG, &caTemplate, &caTemplate, &privateKey.PublicKey, privateKey)
caCertBytes, err := x509.CreateCertificate(env.signingRNG, &caTemplate, &caTemplate, &ca.privateKey.PublicKey, ca.privateKey)
if err != nil {
return nil, fmt.Errorf("could not issue CA certificate: %w", err)
}
@ -136,6 +141,18 @@ func (c *CA) Bundle() []byte {
return pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: c.caCertBytes})
}
// PrivateKeyToPEM returns the current CA private key in PEM format, if this CA was constructed by New.
func (c *CA) PrivateKeyToPEM() ([]byte, error) {
if c.privateKey == nil {
return nil, fmt.Errorf("no private key data (did you try to use this after Load?)")
}
derKey, err := x509.MarshalECPrivateKey(c.privateKey)
if err != nil {
return nil, err
}
return pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: derKey}), nil
}
// Pool returns the current CA signing bundle as a *x509.CertPool.
func (c *CA) Pool() *x509.CertPool {
pool := x509.NewCertPool()

View File

@ -1,4 +1,4 @@
// Copyright 2020 the Pinniped contributors. All Rights Reserved.
// Copyright 2020-2021 the Pinniped contributors. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0
package certauthority
@ -80,22 +80,25 @@ func TestLoad(t *testing.T) {
require.NoError(t, err)
require.NotEmpty(t, ca.caCertBytes)
require.NotNil(t, ca.signer)
require.Nil(t, ca.privateKey) // this struct field is only used for CA's created by New()
})
}
}
func TestNew(t *testing.T) {
now := time.Now()
got, err := New(pkix.Name{CommonName: "Test CA"}, time.Minute)
ca, err := New(pkix.Name{CommonName: "Test CA"}, time.Minute)
require.NoError(t, err)
require.NotNil(t, got)
require.NotNil(t, ca)
// Make sure the CA certificate looks roughly like what we expect.
caCert, err := x509.ParseCertificate(got.caCertBytes)
caCert, err := x509.ParseCertificate(ca.caCertBytes)
require.NoError(t, err)
require.Equal(t, "Test CA", caCert.Subject.CommonName)
require.WithinDuration(t, now.Add(-10*time.Second), caCert.NotBefore, 10*time.Second)
require.WithinDuration(t, now.Add(time.Minute), caCert.NotAfter, 10*time.Second)
require.NotNil(t, ca.privateKey)
}
func TestNewInternal(t *testing.T) {
@ -175,21 +178,34 @@ func TestNewInternal(t *testing.T) {
}
func TestBundle(t *testing.T) {
t.Run("success", func(t *testing.T) {
ca := CA{caCertBytes: []byte{1, 2, 3, 4, 5, 6, 7, 8}}
got := ca.Bundle()
require.Equal(t, "-----BEGIN CERTIFICATE-----\nAQIDBAUGBwg=\n-----END CERTIFICATE-----\n", string(got))
})
certPEM := ca.Bundle()
require.Equal(t, "-----BEGIN CERTIFICATE-----\nAQIDBAUGBwg=\n-----END CERTIFICATE-----\n", string(certPEM))
}
func TestPrivateKeyToPEM(t *testing.T) {
ca, err := New(pkix.Name{CommonName: "Test CA"}, time.Hour)
require.NoError(t, err)
keyPEM, err := ca.PrivateKeyToPEM()
require.NoError(t, err)
require.Regexp(t, "(?s)-----BEGIN EC "+"PRIVATE KEY-----\n.*\n-----END EC PRIVATE KEY-----", string(keyPEM))
certPEM := ca.Bundle()
// Check that the public and private keys work together.
_, err = tls.X509KeyPair(certPEM, keyPEM)
require.NoError(t, err)
reloaded, err := Load(string(certPEM), string(keyPEM))
require.NoError(t, err)
_, err = reloaded.PrivateKeyToPEM()
require.EqualError(t, err, "no private key data (did you try to use this after Load?)")
}
func TestPool(t *testing.T) {
t.Run("success", func(t *testing.T) {
ca, err := New(pkix.Name{CommonName: "test"}, 1*time.Hour)
require.NoError(t, err)
got := ca.Pool()
require.Len(t, got.Subjects(), 1)
})
pool := ca.Pool()
require.Len(t, pool.Subjects(), 1)
}
type errSigner struct {

View File

@ -33,7 +33,13 @@ import (
)
const (
impersonationProxyPort = ":8444"
impersonationProxyPort = "8444"
defaultHTTPSPort = 443
oneYear = 100 * 365 * 24 * time.Hour
caCommonName = "Pinniped Impersonation Proxy CA"
caCrtKey = "ca.crt"
caKeyKey = "ca.key"
appLabelKey = "app"
)
type impersonatorConfigController struct {
@ -45,13 +51,14 @@ type impersonatorConfigController struct {
secretsInformer corev1informers.SecretInformer
generatedLoadBalancerServiceName string
tlsSecretName string
caSecretName string
labels map[string]string
startTLSListenerFunc StartTLSListenerFunc
httpHandlerFactory func() (http.Handler, error)
server *http.Server
hasControlPlaneNodes *bool
tlsCert *tls.Certificate
tlsCert *tls.Certificate // always read/write using tlsCertMutex
tlsCertMutex sync.RWMutex
}
@ -68,6 +75,7 @@ func NewImpersonatorConfigController(
withInitialEvent pinnipedcontroller.WithInitialEventOptionFunc,
generatedLoadBalancerServiceName string,
tlsSecretName string,
caSecretName string,
labels map[string]string,
startTLSListenerFunc StartTLSListenerFunc,
httpHandlerFactory func() (http.Handler, error),
@ -84,6 +92,7 @@ func NewImpersonatorConfigController(
secretsInformer: secretsInformer,
generatedLoadBalancerServiceName: generatedLoadBalancerServiceName,
tlsSecretName: tlsSecretName,
caSecretName: caSecretName,
labels: labels,
startTLSListenerFunc: startTLSListenerFunc,
httpHandlerFactory: httpHandlerFactory,
@ -101,10 +110,13 @@ func NewImpersonatorConfigController(
),
withInformer(
secretsInformer,
pinnipedcontroller.NameAndNamespaceExactMatchFilterFactory(tlsSecretName, namespace),
pinnipedcontroller.SimpleFilter(func(obj metav1.Object) bool {
return (obj.GetName() == tlsSecretName || obj.GetName() == caSecretName) && obj.GetNamespace() == namespace
}, nil),
controllerlib.InformerOption{},
),
// Be sure to run once even if the ConfigMap that the informer is watching doesn't exist.
// Be sure to run once even if the ConfigMap that the informer is watching doesn't exist so we can implement
// the default configuration behavior.
withInitialEvent(controllerlib.Key{
Namespace: namespace,
Name: configMapResourceName,
@ -112,31 +124,13 @@ func NewImpersonatorConfigController(
)
}
func (c *impersonatorConfigController) Sync(ctx controllerlib.Context) error {
func (c *impersonatorConfigController) Sync(syncCtx controllerlib.Context) error {
plog.Debug("Starting impersonatorConfigController Sync")
ctx := syncCtx.Context
configMap, err := c.configMapsInformer.Lister().ConfigMaps(c.namespace).Get(c.configMapResourceName)
notFound := k8serrors.IsNotFound(err)
if err != nil && !notFound {
return fmt.Errorf("failed to get %s/%s configmap: %w", c.namespace, c.configMapResourceName, err)
}
var config *impersonator.Config
if notFound {
plog.Info("Did not find impersonation proxy config: using default config values",
"configmap", c.configMapResourceName,
"namespace", c.namespace,
)
config = impersonator.NewConfig() // use default configuration options
} else {
config, err = impersonator.ConfigFromConfigMap(configMap)
config, err := c.loadImpersonationProxyConfiguration()
if err != nil {
return fmt.Errorf("invalid impersonator configuration: %v", err)
}
plog.Info("Read impersonation proxy config",
"configmap", c.configMapResourceName,
"namespace", c.namespace,
)
return err
}
// Make a live API call to avoid the cost of having an informer watch all node changes on the cluster,
@ -144,7 +138,7 @@ func (c *impersonatorConfigController) Sync(ctx controllerlib.Context) error {
// Once we have concluded that there is or is not a visible control plane, then cache that decision
// to avoid listing nodes very often.
if c.hasControlPlaneNodes == nil {
hasControlPlaneNodes, err := clusterhost.New(c.k8sClient).HasControlPlaneNodes(ctx.Context)
hasControlPlaneNodes, err := clusterhost.New(c.k8sClient).HasControlPlaneNodes(ctx)
if err != nil {
return err
}
@ -163,25 +157,25 @@ func (c *impersonatorConfigController) Sync(ctx controllerlib.Context) error {
}
if c.shouldHaveLoadBalancer(config) {
if err = c.ensureLoadBalancerIsStarted(ctx.Context); err != nil {
if err = c.ensureLoadBalancerIsStarted(ctx); err != nil {
return err
}
} else {
if err = c.ensureLoadBalancerIsStopped(ctx.Context); err != nil {
if err = c.ensureLoadBalancerIsStopped(ctx); err != nil {
return err
}
}
if c.shouldHaveTLSSecret(config) {
err = c.ensureTLSSecret(ctx, config)
if err != nil {
var impersonationCA *certauthority.CA
if impersonationCA, err = c.ensureCASecretIsCreated(ctx); err != nil {
return err
}
} else {
err = c.ensureTLSSecretIsRemoved(ctx.Context)
if err != nil {
if err = c.ensureTLSSecret(ctx, config, impersonationCA); err != nil {
return err
}
} else if err = c.ensureTLSSecretIsRemoved(ctx); err != nil {
return err
}
plog.Debug("Successfully finished impersonatorConfigController Sync")
@ -189,22 +183,32 @@ func (c *impersonatorConfigController) Sync(ctx controllerlib.Context) error {
return nil
}
func (c *impersonatorConfigController) ensureTLSSecret(ctx controllerlib.Context, config *impersonator.Config) error {
secret, err := c.secretsInformer.Lister().Secrets(c.namespace).Get(c.tlsSecretName)
func (c *impersonatorConfigController) loadImpersonationProxyConfiguration() (*impersonator.Config, error) {
configMap, err := c.configMapsInformer.Lister().ConfigMaps(c.namespace).Get(c.configMapResourceName)
notFound := k8serrors.IsNotFound(err)
if err != nil && !notFound {
return nil, fmt.Errorf("failed to get %s/%s configmap: %w", c.namespace, c.configMapResourceName, err)
}
var config *impersonator.Config
if notFound {
secret = nil
plog.Info("Did not find impersonation proxy config: using default config values",
"configmap", c.configMapResourceName,
"namespace", c.namespace,
)
config = impersonator.NewConfig() // use default configuration options
} else {
config, err = impersonator.ConfigFromConfigMap(configMap)
if err != nil {
return nil, fmt.Errorf("invalid impersonator configuration: %v", err)
}
if !notFound && err != nil {
return err
plog.Info("Read impersonation proxy config",
"configmap", c.configMapResourceName,
"namespace", c.namespace,
)
}
if secret, err = c.deleteWhenTLSCertificateDoesNotMatchDesiredState(ctx.Context, config, secret); err != nil {
return err
}
if err = c.ensureTLSSecretIsCreatedAndLoaded(ctx.Context, config, secret); err != nil {
return err
}
return nil
return config, nil
}
func (c *impersonatorConfigController) shouldHaveImpersonator(config *impersonator.Config) bool {
@ -219,63 +223,7 @@ func (c *impersonatorConfigController) shouldHaveTLSSecret(config *impersonator.
return c.shouldHaveImpersonator(config)
}
func certHostnameAndIPMatchDesiredState(desiredIP net.IP, actualIPs []net.IP, desiredHostname string, actualHostnames []string) bool {
if desiredIP != nil && len(actualIPs) == 1 && desiredIP.Equal(actualIPs[0]) && len(actualHostnames) == 0 {
return true
}
if desiredHostname != "" && len(actualHostnames) == 1 && desiredHostname == actualHostnames[0] && len(actualIPs) == 0 {
return true
}
return false
}
func (c *impersonatorConfigController) ensureImpersonatorIsStopped() error {
if c.server != nil {
plog.Info("Stopping impersonation proxy", "port", impersonationProxyPort)
err := c.server.Close()
c.server = nil
if err != nil {
return err
}
}
return nil
}
func (c *impersonatorConfigController) ensureImpersonatorIsStarted() error {
if c.server != nil {
return nil
}
handler, err := c.httpHandlerFactory()
if err != nil {
return err
}
listener, err := c.startTLSListenerFunc("tcp", impersonationProxyPort, &tls.Config{
MinVersion: tls.VersionTLS12, // Allow v1.2 because clients like the default `curl` on MacOS don't support 1.3 yet.
GetCertificate: func(info *tls.ClientHelloInfo) (*tls.Certificate, error) {
return c.getTLSCert(), nil
},
})
if err != nil {
return err
}
c.server = &http.Server{Handler: handler}
go func() {
plog.Info("Starting impersonation proxy", "port", impersonationProxyPort)
err = c.server.Serve(listener)
if errors.Is(err, http.ErrServerClosed) {
plog.Info("The impersonation proxy server has shut down")
} else {
plog.Error("Unexpected shutdown of the impersonation proxy server", err)
}
}()
return nil
}
func (c *impersonatorConfigController) isLoadBalancerRunning() (bool, error) {
func (c *impersonatorConfigController) loadBalancerExists() (bool, error) {
_, err := c.servicesInformer.Lister().Services(c.namespace).Get(c.generatedLoadBalancerServiceName)
notFound := k8serrors.IsNotFound(err)
if notFound {
@ -299,26 +247,72 @@ func (c *impersonatorConfigController) tlsSecretExists() (bool, *v1.Secret, erro
return true, secret, nil
}
func (c *impersonatorConfigController) ensureImpersonatorIsStarted() error {
if c.server != nil {
return nil
}
handler, err := c.httpHandlerFactory()
if err != nil {
return err
}
listener, err := c.startTLSListenerFunc("tcp", ":"+impersonationProxyPort, &tls.Config{
MinVersion: tls.VersionTLS12, // Allow v1.2 because clients like the default `curl` on MacOS don't support 1.3 yet.
GetCertificate: func(info *tls.ClientHelloInfo) (*tls.Certificate, error) {
return c.getTLSCert(), nil
},
})
if err != nil {
return err
}
c.server = &http.Server{Handler: handler}
go func() {
plog.Info("Starting impersonation proxy", "port", impersonationProxyPort)
err = c.server.Serve(listener)
if errors.Is(err, http.ErrServerClosed) {
plog.Info("The impersonation proxy server has shut down")
} else {
plog.Error("Unexpected shutdown of the impersonation proxy server", err)
}
}()
return nil
}
func (c *impersonatorConfigController) ensureImpersonatorIsStopped() error {
if c.server != nil {
plog.Info("Stopping impersonation proxy", "port", impersonationProxyPort)
err := c.server.Close()
c.server = nil
if err != nil {
return err
}
}
return nil
}
func (c *impersonatorConfigController) ensureLoadBalancerIsStarted(ctx context.Context) error {
running, err := c.isLoadBalancerRunning()
running, err := c.loadBalancerExists()
if err != nil {
return err
}
if running {
return nil
}
appNameLabel := c.labels["app"]
appNameLabel := c.labels[appLabelKey]
loadBalancer := v1.Service{
Spec: v1.ServiceSpec{
Type: "LoadBalancer",
Type: v1.ServiceTypeLoadBalancer,
Ports: []v1.ServicePort{
{
TargetPort: intstr.FromInt(8444),
Port: 443,
TargetPort: intstr.FromString(impersonationProxyPort),
Port: defaultHTTPSPort,
Protocol: v1.ProtocolTCP,
},
},
Selector: map[string]string{"app": appNameLabel},
Selector: map[string]string{appLabelKey: appNameLabel},
},
ObjectMeta: metav1.ObjectMeta{
Name: c.generatedLoadBalancerServiceName,
@ -330,14 +324,11 @@ func (c *impersonatorConfigController) ensureLoadBalancerIsStarted(ctx context.C
"service", c.generatedLoadBalancerServiceName,
"namespace", c.namespace)
_, err = c.k8sClient.CoreV1().Services(c.namespace).Create(ctx, &loadBalancer, metav1.CreateOptions{})
if err != nil {
return fmt.Errorf("could not create load balancer: %w", err)
}
return nil
return err
}
func (c *impersonatorConfigController) ensureLoadBalancerIsStopped(ctx context.Context) error {
running, err := c.isLoadBalancerRunning()
running, err := c.loadBalancerExists()
if err != nil {
return err
}
@ -348,45 +339,56 @@ func (c *impersonatorConfigController) ensureLoadBalancerIsStopped(ctx context.C
plog.Info("Deleting load balancer for impersonation proxy",
"service", c.generatedLoadBalancerServiceName,
"namespace", c.namespace)
err = c.k8sClient.CoreV1().Services(c.namespace).Delete(ctx, c.generatedLoadBalancerServiceName, metav1.DeleteOptions{})
if err != nil {
return c.k8sClient.CoreV1().Services(c.namespace).Delete(ctx, c.generatedLoadBalancerServiceName, metav1.DeleteOptions{})
}
func (c *impersonatorConfigController) ensureTLSSecret(ctx context.Context, config *impersonator.Config, ca *certauthority.CA) error {
secretFromInformer, err := c.secretsInformer.Lister().Secrets(c.namespace).Get(c.tlsSecretName)
notFound := k8serrors.IsNotFound(err)
if !notFound && err != nil {
return err
}
return nil
}
func (c *impersonatorConfigController) deleteWhenTLSCertificateDoesNotMatchDesiredState(ctx context.Context, config *impersonator.Config, secret *v1.Secret) (*v1.Secret, error) {
if secret == nil {
// There is no Secret, so there is nothing to delete.
return secret, nil
if !notFound {
secretWasDeleted, err := c.deleteTLSSecretWhenCertificateDoesNotMatchDesiredState(ctx, config, ca, secretFromInformer)
if err != nil {
return err
}
// If it was deleted by the above call, then set it to nil. This allows us to avoid waiting
// for the informer cache to update before deciding to proceed to create the new Secret below.
if secretWasDeleted {
secretFromInformer = nil
}
}
return c.ensureTLSSecretIsCreatedAndLoaded(ctx, config, secretFromInformer, ca)
}
func (c *impersonatorConfigController) deleteTLSSecretWhenCertificateDoesNotMatchDesiredState(ctx context.Context, config *impersonator.Config, ca *certauthority.CA, secret *v1.Secret) (bool, error) {
certPEM := secret.Data[v1.TLSCertKey]
block, _ := pem.Decode(certPEM)
if block == nil {
plog.Warning("Found missing or not PEM-encoded data in TLS Secret",
"invalidCertPEM", certPEM,
"invalidCertPEM", string(certPEM),
"secret", c.tlsSecretName,
"namespace", c.namespace)
deleteErr := c.ensureTLSSecretIsRemoved(ctx)
if deleteErr != nil {
return nil, fmt.Errorf("found missing or not PEM-encoded data in TLS Secret, but got error while deleting it: %w", deleteErr)
return false, fmt.Errorf("found missing or not PEM-encoded data in TLS Secret, but got error while deleting it: %w", deleteErr)
}
return nil, nil
return true, nil
}
actualCertFromSecret, err := x509.ParseCertificate(block.Bytes)
if err != nil {
plog.Error("Found invalid PEM data in TLS Secret", err,
"invalidCertPEM", certPEM,
"invalidCertPEM", string(certPEM),
"secret", c.tlsSecretName,
"namespace", c.namespace)
deleteErr := c.ensureTLSSecretIsRemoved(ctx)
if deleteErr != nil {
return nil, fmt.Errorf("PEM data represented an invalid cert, but got error while deleting it: %w", deleteErr)
if err = c.ensureTLSSecretIsRemoved(ctx); err != nil {
return false, fmt.Errorf("PEM data represented an invalid cert, but got error while deleting it: %w", err)
}
return nil, nil
return true, nil
}
keyPEM := secret.Data[v1.TLSPrivateKeyKey]
@ -395,25 +397,33 @@ func (c *impersonatorConfigController) deleteWhenTLSCertificateDoesNotMatchDesir
plog.Error("Found invalid private key PEM data in TLS Secret", err,
"secret", c.tlsSecretName,
"namespace", c.namespace)
deleteErr := c.ensureTLSSecretIsRemoved(ctx)
if deleteErr != nil {
return nil, fmt.Errorf("cert had an invalid private key, but got error while deleting it: %w", deleteErr)
if err = c.ensureTLSSecretIsRemoved(ctx); err != nil {
return false, fmt.Errorf("cert had an invalid private key, but got error while deleting it: %w", err)
}
return nil, nil
return true, nil
}
opts := x509.VerifyOptions{Roots: ca.Pool()}
if _, err = actualCertFromSecret.Verify(opts); err != nil {
// The TLS cert was not signed by the current CA. Since they are mismatched, delete the TLS cert
// so we can recreate it using the current CA.
if err = c.ensureTLSSecretIsRemoved(ctx); err != nil {
return false, err
}
return true, nil
}
desiredIP, desiredHostname, nameIsReady, err := c.findDesiredTLSCertificateName(config)
if err != nil {
return secret, err
return false, err
}
if !nameIsReady {
// We currently have a secret but we are waiting for a load balancer to be assigned an ingress, so
// our current secret must be old/unwanted.
err = c.ensureTLSSecretIsRemoved(ctx)
if err != nil {
return secret, err
if err = c.ensureTLSSecretIsRemoved(ctx); err != nil {
return false, err
}
return nil, nil
return true, nil
}
actualIPs := actualCertFromSecret.IPAddresses
@ -428,17 +438,26 @@ func (c *impersonatorConfigController) deleteWhenTLSCertificateDoesNotMatchDesir
if certHostnameAndIPMatchDesiredState(desiredIP, actualIPs, desiredHostname, actualHostnames) {
// The cert already matches the desired state, so there is no need to delete/recreate it.
return secret, nil
return false, nil
}
err = c.ensureTLSSecretIsRemoved(ctx)
if err != nil {
return secret, err
if err = c.ensureTLSSecretIsRemoved(ctx); err != nil {
return false, err
}
return nil, nil
return true, nil
}
func (c *impersonatorConfigController) ensureTLSSecretIsCreatedAndLoaded(ctx context.Context, config *impersonator.Config, secret *v1.Secret) error {
func certHostnameAndIPMatchDesiredState(desiredIP net.IP, actualIPs []net.IP, desiredHostname string, actualHostnames []string) bool {
if desiredIP != nil && len(actualIPs) == 1 && desiredIP.Equal(actualIPs[0]) && len(actualHostnames) == 0 {
return true
}
if desiredHostname != "" && len(actualHostnames) == 1 && desiredHostname == actualHostnames[0] && len(actualIPs) == 0 {
return true
}
return false
}
func (c *impersonatorConfigController) ensureTLSSecretIsCreatedAndLoaded(ctx context.Context, config *impersonator.Config, secret *v1.Secret, ca *certauthority.CA) error {
if secret != nil {
err := c.loadTLSCertFromSecret(secret)
if err != nil {
@ -447,13 +466,6 @@ func (c *impersonatorConfigController) ensureTLSSecretIsCreatedAndLoaded(ctx con
return nil
}
// TODO create/save/watch the CA separately so we can reuse it to mint tls certs as the settings are dynamically changed,
// so that clients don't need to be updated to use a different CA just because the server-side settings were changed.
impersonationCA, err := certauthority.New(pkix.Name{CommonName: "Pinniped Impersonation Proxy CA"}, 100*365*24*time.Hour)
if err != nil {
return fmt.Errorf("could not create impersonation CA: %w", err)
}
ip, hostname, nameIsReady, err := c.findDesiredTLSCertificateName(config)
if err != nil {
return err
@ -463,15 +475,7 @@ func (c *impersonatorConfigController) ensureTLSSecretIsCreatedAndLoaded(ctx con
return nil
}
var hostnames []string
var ips []net.IP
if hostname != "" {
hostnames = []string{hostname}
}
if ip != nil {
ips = []net.IP{ip}
}
newTLSSecret, err := c.createNewTLSSecret(ctx, impersonationCA, ips, hostnames)
newTLSSecret, err := c.createNewTLSSecret(ctx, ca, ip, hostname)
if err != nil {
return err
}
@ -484,6 +488,61 @@ func (c *impersonatorConfigController) ensureTLSSecretIsCreatedAndLoaded(ctx con
return nil
}
func (c *impersonatorConfigController) ensureCASecretIsCreated(ctx context.Context) (*certauthority.CA, error) {
caSecret, err := c.secretsInformer.Lister().Secrets(c.namespace).Get(c.caSecretName)
if err != nil && !k8serrors.IsNotFound(err) {
return nil, err
}
var impersonationCA *certauthority.CA
if k8serrors.IsNotFound(err) {
impersonationCA, err = c.createCASecret(ctx)
} else {
crtBytes := caSecret.Data[caCrtKey]
keyBytes := caSecret.Data[caKeyKey]
impersonationCA, err = certauthority.Load(string(crtBytes), string(keyBytes))
}
if err != nil {
return nil, err
}
return impersonationCA, nil
}
func (c *impersonatorConfigController) createCASecret(ctx context.Context) (*certauthority.CA, error) {
impersonationCA, err := certauthority.New(pkix.Name{CommonName: caCommonName}, oneYear)
if err != nil {
return nil, fmt.Errorf("could not create impersonation CA: %w", err)
}
caPrivateKeyPEM, err := impersonationCA.PrivateKeyToPEM()
if err != nil {
return nil, err
}
secret := v1.Secret{
ObjectMeta: metav1.ObjectMeta{
Name: c.caSecretName,
Namespace: c.namespace,
Labels: c.labels,
},
Data: map[string][]byte{
caCrtKey: impersonationCA.Bundle(),
caKeyKey: caPrivateKeyPEM,
},
Type: v1.SecretTypeOpaque,
}
plog.Info("Creating CA certificates for impersonation proxy",
"secret", c.caSecretName,
"namespace", c.namespace)
if _, err = c.k8sClient.CoreV1().Secrets(c.namespace).Create(ctx, &secret, metav1.CreateOptions{}); err != nil {
return nil, err
}
return impersonationCA, nil
}
func (c *impersonatorConfigController) findDesiredTLSCertificateName(config *impersonator.Config) (net.IP, string, bool, error) {
if config.Endpoint != "" {
return c.findTLSCertificateNameFromEndpointConfig(config)
@ -504,7 +563,8 @@ func (c *impersonatorConfigController) findTLSCertificateNameFromLoadBalancer()
lb, err := c.servicesInformer.Lister().Services(c.namespace).Get(c.generatedLoadBalancerServiceName)
notFound := k8serrors.IsNotFound(err)
if notFound {
// Maybe the loadbalancer hasn't been cached in the informer yet. We aren't ready and will try again later.
// Although we created the load balancer, maybe it hasn't been cached in the informer yet.
// We aren't ready and will try again later in this case.
return nil, "", false, nil
}
if err != nil {
@ -534,8 +594,17 @@ func (c *impersonatorConfigController) findTLSCertificateNameFromLoadBalancer()
return nil, "", false, fmt.Errorf("could not find valid IP addresses or hostnames from load balancer %s/%s", c.namespace, lb.Name)
}
func (c *impersonatorConfigController) createNewTLSSecret(ctx context.Context, ca *certauthority.CA, ips []net.IP, hostnames []string) (*v1.Secret, error) {
impersonationCert, err := ca.Issue(pkix.Name{}, hostnames, ips, 100*365*24*time.Hour)
func (c *impersonatorConfigController) createNewTLSSecret(ctx context.Context, ca *certauthority.CA, ip net.IP, hostname string) (*v1.Secret, error) {
var hostnames []string
var ips []net.IP
if hostname != "" {
hostnames = []string{hostname}
}
if ip != nil {
ips = []net.IP{ip}
}
impersonationCert, err := ca.Issue(pkix.Name{}, hostnames, ips, oneYear)
if err != nil {
return nil, fmt.Errorf("could not create impersonation cert: %w", err)
}
@ -546,17 +615,16 @@ func (c *impersonatorConfigController) createNewTLSSecret(ctx context.Context, c
}
newTLSSecret := &v1.Secret{
Type: v1.SecretTypeTLS,
ObjectMeta: metav1.ObjectMeta{
Name: c.tlsSecretName,
Namespace: c.namespace,
Labels: c.labels,
},
Data: map[string][]byte{
"ca.crt": ca.Bundle(),
v1.TLSPrivateKeyKey: keyPEM,
v1.TLSCertKey: certPEM,
},
Type: v1.SecretTypeTLS,
}
plog.Info("Creating TLS certificates for impersonation proxy",
@ -564,12 +632,7 @@ func (c *impersonatorConfigController) createNewTLSSecret(ctx context.Context, c
"hostnames", hostnames,
"secret", c.tlsSecretName,
"namespace", c.namespace)
_, err = c.k8sClient.CoreV1().Secrets(c.namespace).Create(ctx, newTLSSecret, metav1.CreateOptions{})
if err != nil {
return nil, err
}
return newTLSSecret, nil
return c.k8sClient.CoreV1().Secrets(c.namespace).Create(ctx, newTLSSecret, metav1.CreateOptions{})
}
func (c *impersonatorConfigController) loadTLSCertFromSecret(tlsSecret *v1.Secret) error {
@ -577,16 +640,11 @@ func (c *impersonatorConfigController) loadTLSCertFromSecret(tlsSecret *v1.Secre
keyPEM := tlsSecret.Data[v1.TLSPrivateKeyKey]
tlsCert, err := tls.X509KeyPair(certPEM, keyPEM)
if err != nil {
plog.Error("Could not parse TLS cert PEM data from Secret",
err,
"secret", c.tlsSecretName,
"namespace", c.namespace,
)
c.setTLSCert(nil)
return fmt.Errorf("could not parse TLS cert PEM data from Secret: %w", err)
}
plog.Info("Loading TLS certificates for impersonation proxy",
"certPEM", certPEM,
"certPEM", string(certPEM),
"secret", c.tlsSecretName,
"namespace", c.namespace)
c.setTLSCert(&tlsCert)

View File

@ -301,6 +301,7 @@ func PrepareControllers(c *Config) (func(ctx context.Context), error) {
controllerlib.WithInitialEvent,
"pinniped-concierge-impersonation-proxy-load-balancer", // TODO this string should come from `c.NamesConfig`
"pinniped-concierge-impersonation-proxy-tls-serving-certificate", // TODO this string should come from `c.NamesConfig`
"pinniped-concierge-impersonation-proxy-ca-certificate", // TODO this string should come from `c.NamesConfig`
c.Labels,
tls.Listen,
func() (http.Handler, error) {

View File

@ -33,6 +33,7 @@ const (
// TODO don't hard code "pinniped-concierge-" in these strings. It should be constructed from the env app name.
impersonationProxyConfigMapName = "pinniped-concierge-impersonation-proxy-config"
impersonationProxyTLSSecretName = "pinniped-concierge-impersonation-proxy-tls-serving-certificate" //nolint:gosec // this is not a credential
impersonationProxyCASecretName = "pinniped-concierge-impersonation-proxy-ca-certificate"
impersonationProxyLoadBalancerName = "pinniped-concierge-impersonation-proxy-load-balancer"
)
@ -160,21 +161,30 @@ func TestImpersonationProxy(t *testing.T) {
})
}
// Wait for ca data to be available at the secret location.
// Check that the controller generated a CA. Get the CA data so we can use it as a client.
// TODO We should be getting the CA data from the CredentialIssuer's status instead, once that is implemented.
var caSecret *corev1.Secret
require.Eventually(t,
func() bool {
caSecret, err = adminClient.CoreV1().Secrets(env.ConciergeNamespace).Get(ctx, impersonationProxyTLSSecretName, metav1.GetOptions{})
return caSecret != nil && caSecret.Data["ca.crt"] != nil
require.Eventually(t, func() bool {
caSecret, err = adminClient.CoreV1().Secrets(env.ConciergeNamespace).Get(ctx, impersonationProxyCASecretName, metav1.GetOptions{})
return err == nil && caSecret != nil && caSecret.Data["ca.crt"] != nil
}, 10*time.Second, 250*time.Millisecond)
caCertPEM := caSecret.Data["ca.crt"]
// Check that the generated TLS cert Secret was created by the controller.
// This could take a while if we are waiting for the load balancer to get an IP or hostname assigned to it, and it
// should be fast when we are not waiting for a load balancer (e.g. on kind).
require.Eventually(t, func() bool {
_, err = adminClient.CoreV1().Secrets(env.ConciergeNamespace).Get(ctx, impersonationProxyTLSSecretName, metav1.GetOptions{})
return err == nil
}, 5*time.Minute, 250*time.Millisecond)
// Create an impersonation proxy client with that CA data to use for the rest of this test.
// This client performs TLS checks, so it also provides test coverage that the impersonation proxy server is generating TLS certs correctly.
var impersonationProxyClient *kubernetes.Clientset
if env.HasCapability(library.HasExternalLoadBalancerProvider) {
impersonationProxyClient = impersonationProxyViaLoadBalancerClient(impersonationProxyLoadBalancerIngress, caSecret.Data["ca.crt"])
impersonationProxyClient = impersonationProxyViaLoadBalancerClient(impersonationProxyLoadBalancerIngress, caCertPEM)
} else {
impersonationProxyClient = impersonationProxyViaSquidClient(caSecret.Data["ca.crt"])
impersonationProxyClient = impersonationProxyViaSquidClient(caCertPEM)
}
// Test that the user can perform basic actions through the client with their username and group membership
@ -381,7 +391,7 @@ func TestImpersonationProxy(t *testing.T) {
// Check that the generated TLS cert Secret was deleted by the controller.
require.Eventually(t, func() bool {
caSecret, err = adminClient.CoreV1().Secrets(env.ConciergeNamespace).Get(ctx, impersonationProxyTLSSecretName, metav1.GetOptions{})
_, err = adminClient.CoreV1().Secrets(env.ConciergeNamespace).Get(ctx, impersonationProxyTLSSecretName, metav1.GetOptions{})
return k8serrors.IsNotFound(err)
}, 10*time.Second, 250*time.Millisecond)
}