ContainerImage.Pinniped/internal/controller/supervisorconfig/tls_cert_observer_test.go
Ryan Richard 38802c2184 Add a way to set a default supervisor TLS cert for when SNI won't work
- Setting a Secret in the supervisor's namespace with a special name
  will cause it to get picked up and served as the supervisor's TLS
  cert for any request which does not have a matching SNI cert.
- This is especially useful for when there is no DNS record for an
  issuer and the user will be accessing it via IP address. This
  is not how we would expect it to be used in production, but it
  might be useful for other cases.
- Includes a new integration test
- Also suppress all of the warnings about ignoring the error returned by
  Close() in lines like `defer x.Close()` to make GoLand happier
2020-10-27 16:33:08 -07:00

363 lines
14 KiB
Go

// Copyright 2020 the Pinniped contributors. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0
package supervisorconfig
import (
"context"
"crypto/tls"
"io/ioutil"
"net/url"
"testing"
"time"
"github.com/sclevine/spec"
"github.com/sclevine/spec/report"
"github.com/stretchr/testify/require"
corev1 "k8s.io/api/core/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
kubeinformers "k8s.io/client-go/informers"
kubernetesfake "k8s.io/client-go/kubernetes/fake"
"go.pinniped.dev/generated/1.19/apis/config/v1alpha1"
pinnipedfake "go.pinniped.dev/generated/1.19/client/clientset/versioned/fake"
pinnipedinformers "go.pinniped.dev/generated/1.19/client/informers/externalversions"
"go.pinniped.dev/internal/controllerlib"
"go.pinniped.dev/internal/testutil"
)
func TestTLSCertObserverControllerInformerFilters(t *testing.T) {
spec.Run(t, "informer filters", func(t *testing.T, when spec.G, it spec.S) {
var (
r *require.Assertions
observableWithInformerOption *testutil.ObservableWithInformerOption
secretsInformerFilter controllerlib.Filter
oidcProviderConfigInformerFilter controllerlib.Filter
)
it.Before(func() {
r = require.New(t)
observableWithInformerOption = testutil.NewObservableWithInformerOption()
secretsInformer := kubeinformers.NewSharedInformerFactory(nil, 0).Core().V1().Secrets()
oidcProviderConfigInformer := pinnipedinformers.NewSharedInformerFactory(nil, 0).Config().V1alpha1().OIDCProviderConfigs()
_ = NewTLSCertObserverController(
nil,
secretsInformer,
oidcProviderConfigInformer,
observableWithInformerOption.WithInformer, // make it possible to observe the behavior of the Filters
)
secretsInformerFilter = observableWithInformerOption.GetFilterForInformer(secretsInformer)
oidcProviderConfigInformerFilter = observableWithInformerOption.GetFilterForInformer(oidcProviderConfigInformer)
})
when("watching Secret objects", func() {
var (
subject controllerlib.Filter
secret, otherSecret *corev1.Secret
)
it.Before(func() {
subject = secretsInformerFilter
secret = &corev1.Secret{ObjectMeta: metav1.ObjectMeta{Name: "any-name", Namespace: "any-namespace"}}
otherSecret = &corev1.Secret{ObjectMeta: metav1.ObjectMeta{Name: "any-other-name", Namespace: "any-other-namespace"}}
})
when("any Secret changes", func() {
it("returns true to trigger the sync method", func() {
r.True(subject.Add(secret))
r.True(subject.Update(secret, otherSecret))
r.True(subject.Update(otherSecret, secret))
r.True(subject.Delete(secret))
})
})
})
when("watching OIDCProviderConfig objects", func() {
var (
subject controllerlib.Filter
provider, otherProvider *v1alpha1.OIDCProviderConfig
)
it.Before(func() {
subject = oidcProviderConfigInformerFilter
provider = &v1alpha1.OIDCProviderConfig{ObjectMeta: metav1.ObjectMeta{Name: "any-name", Namespace: "any-namespace"}}
otherProvider = &v1alpha1.OIDCProviderConfig{ObjectMeta: metav1.ObjectMeta{Name: "any-other-name", Namespace: "any-other-namespace"}}
})
when("any OIDCProviderConfig changes", func() {
it("returns true to trigger the sync method", func() {
r.True(subject.Add(provider))
r.True(subject.Update(provider, otherProvider))
r.True(subject.Update(otherProvider, provider))
r.True(subject.Delete(provider))
})
})
})
}, spec.Parallel(), spec.Report(report.Terminal{}))
}
type fakeIssuerTLSCertSetter struct {
setIssuerHostToTLSCertMapWasCalled bool
setDefaultTLSCertWasCalled bool
issuerHostToTLSCertMapReceived map[string]*tls.Certificate
setDefaultTLSCertReceived *tls.Certificate
}
func (f *fakeIssuerTLSCertSetter) SetIssuerHostToTLSCertMap(issuerHostToTLSCertMap map[string]*tls.Certificate) {
f.setIssuerHostToTLSCertMapWasCalled = true
f.issuerHostToTLSCertMapReceived = issuerHostToTLSCertMap
}
func (f *fakeIssuerTLSCertSetter) SetDefaultTLSCert(certificate *tls.Certificate) {
f.setDefaultTLSCertWasCalled = true
f.setDefaultTLSCertReceived = certificate
}
func TestTLSCertObserverControllerSync(t *testing.T) {
spec.Run(t, "Sync", func(t *testing.T, when spec.G, it spec.S) {
const installedInNamespace = "some-namespace"
var (
r *require.Assertions
subject controllerlib.Controller
pinnipedInformerClient *pinnipedfake.Clientset
kubeInformerClient *kubernetesfake.Clientset
pinnipedInformers pinnipedinformers.SharedInformerFactory
kubeInformers kubeinformers.SharedInformerFactory
timeoutContext context.Context
timeoutContextCancel context.CancelFunc
syncContext *controllerlib.Context
issuerTLSCertSetter *fakeIssuerTLSCertSetter
)
// Defer starting the informers until the last possible moment so that the
// nested Before's can keep adding things to the informer caches.
var startInformersAndController = func() {
// Set this at the last second to allow for injection of server override.
subject = NewTLSCertObserverController(
issuerTLSCertSetter,
kubeInformers.Core().V1().Secrets(),
pinnipedInformers.Config().V1alpha1().OIDCProviderConfigs(),
controllerlib.WithInformer,
)
// Set this at the last second to support calling subject.Name().
syncContext = &controllerlib.Context{
Context: timeoutContext,
Name: subject.Name(),
Key: controllerlib.Key{
Namespace: installedInNamespace,
Name: "any-name",
},
}
// Must start informers before calling TestRunSynchronously()
kubeInformers.Start(timeoutContext.Done())
pinnipedInformers.Start(timeoutContext.Done())
controllerlib.TestRunSynchronously(t, subject)
}
var readTestFile = func(path string) []byte {
data, err := ioutil.ReadFile(path)
r.NoError(err)
return data
}
it.Before(func() {
r = require.New(t)
timeoutContext, timeoutContextCancel = context.WithTimeout(context.Background(), time.Second*3)
kubeInformerClient = kubernetesfake.NewSimpleClientset()
kubeInformers = kubeinformers.NewSharedInformerFactory(kubeInformerClient, 0)
pinnipedInformerClient = pinnipedfake.NewSimpleClientset()
pinnipedInformers = pinnipedinformers.NewSharedInformerFactory(pinnipedInformerClient, 0)
issuerTLSCertSetter = &fakeIssuerTLSCertSetter{}
unrelatedSecret := &corev1.Secret{
ObjectMeta: metav1.ObjectMeta{
Name: "some other unrelated secret",
Namespace: installedInNamespace,
},
}
r.NoError(kubeInformerClient.Tracker().Add(unrelatedSecret))
})
it.After(func() {
timeoutContextCancel()
})
when("there are no OIDCProviderConfigs and no TLS Secrets yet", func() {
it("sets the issuerTLSCertSetter's map to be empty", func() {
startInformersAndController()
err := controllerlib.TestSync(t, subject, *syncContext)
r.NoError(err)
r.True(issuerTLSCertSetter.setIssuerHostToTLSCertMapWasCalled)
r.Empty(issuerTLSCertSetter.issuerHostToTLSCertMapReceived)
r.True(issuerTLSCertSetter.setDefaultTLSCertWasCalled)
r.Nil(issuerTLSCertSetter.setDefaultTLSCertReceived)
})
})
when("there are OIDCProviderConfigs where some have corresponding TLS Secrets and some don't", func() {
var (
expectedCertificate1, expectedCertificate2 tls.Certificate
)
it.Before(func() {
var err error
oidcProviderConfigWithoutSecret1 := &v1alpha1.OIDCProviderConfig{
ObjectMeta: metav1.ObjectMeta{
Name: "no-secret-oidcproviderconfig1",
Namespace: installedInNamespace,
},
Spec: v1alpha1.OIDCProviderConfigSpec{Issuer: "https://no-secret-issuer1.com"}, // no SNICertificateSecretName field
}
oidcProviderConfigWithoutSecret2 := &v1alpha1.OIDCProviderConfig{
ObjectMeta: metav1.ObjectMeta{
Name: "no-secret-oidcproviderconfig2",
Namespace: installedInNamespace,
},
Spec: v1alpha1.OIDCProviderConfigSpec{
Issuer: "https://no-secret-issuer2.com",
SNICertificateSecretName: "",
},
}
oidcProviderConfigWithBadSecret := &v1alpha1.OIDCProviderConfig{
ObjectMeta: metav1.ObjectMeta{
Name: "bad-secret-oidcproviderconfig",
Namespace: installedInNamespace,
},
Spec: v1alpha1.OIDCProviderConfigSpec{
Issuer: "https://bad-secret-issuer.com",
SNICertificateSecretName: "bad-tls-secret-name",
},
}
// Also add one with a URL that cannot be parsed to make sure that the controller is not confused by invalid URLs.
invalidIssuerURL := ":/host//path"
_, err = url.Parse(invalidIssuerURL) //nolint:staticcheck // Yes, this URL is intentionally invalid.
r.Error(err)
oidcProviderConfigWithBadIssuer := &v1alpha1.OIDCProviderConfig{
ObjectMeta: metav1.ObjectMeta{
Name: "bad-issuer-oidcproviderconfig",
Namespace: installedInNamespace,
},
Spec: v1alpha1.OIDCProviderConfigSpec{Issuer: invalidIssuerURL},
}
oidcProviderConfigWithGoodSecret1 := &v1alpha1.OIDCProviderConfig{
ObjectMeta: metav1.ObjectMeta{
Name: "good-secret-oidcproviderconfig1",
Namespace: installedInNamespace,
},
// Issuer hostname should be treated in a case-insensitive way and SNI ignores port numbers. Test without a port number.
Spec: v1alpha1.OIDCProviderConfigSpec{
Issuer: "https://www.iSSuer-wiTh-goOd-secRet1.cOm/path",
SNICertificateSecretName: "good-tls-secret-name1",
},
}
oidcProviderConfigWithGoodSecret2 := &v1alpha1.OIDCProviderConfig{
ObjectMeta: metav1.ObjectMeta{
Name: "good-secret-oidcproviderconfig2",
Namespace: installedInNamespace,
},
// Issuer hostname should be treated in a case-insensitive way and SNI ignores port numbers. Test with a port number.
Spec: v1alpha1.OIDCProviderConfigSpec{
Issuer: "https://www.issUEr-WIth-gOOd-seCret2.com:1234/path",
SNICertificateSecretName: "good-tls-secret-name2",
},
}
testCrt1 := readTestFile("testdata/test.crt")
r.NotEmpty(testCrt1)
testCrt2 := readTestFile("testdata/test2.crt")
r.NotEmpty(testCrt2)
testKey1 := readTestFile("testdata/test.key")
r.NotEmpty(testKey1)
testKey2 := readTestFile("testdata/test2.key")
r.NotEmpty(testKey2)
expectedCertificate1, err = tls.X509KeyPair(testCrt1, testKey1)
r.NoError(err)
expectedCertificate2, err = tls.X509KeyPair(testCrt2, testKey2)
r.NoError(err)
goodTLSSecret1 := &corev1.Secret{
ObjectMeta: metav1.ObjectMeta{Name: "good-tls-secret-name1", Namespace: installedInNamespace},
Data: map[string][]byte{"tls.crt": testCrt1, "tls.key": testKey1},
}
goodTLSSecret2 := &corev1.Secret{
ObjectMeta: metav1.ObjectMeta{Name: "good-tls-secret-name2", Namespace: installedInNamespace},
Data: map[string][]byte{"tls.crt": testCrt2, "tls.key": testKey2},
}
badTLSSecret := &corev1.Secret{
ObjectMeta: metav1.ObjectMeta{Name: "bad-tls-secret-name", Namespace: installedInNamespace},
Data: map[string][]byte{"junk": nil},
}
r.NoError(pinnipedInformerClient.Tracker().Add(oidcProviderConfigWithoutSecret1))
r.NoError(pinnipedInformerClient.Tracker().Add(oidcProviderConfigWithoutSecret2))
r.NoError(pinnipedInformerClient.Tracker().Add(oidcProviderConfigWithBadSecret))
r.NoError(pinnipedInformerClient.Tracker().Add(oidcProviderConfigWithBadIssuer))
r.NoError(pinnipedInformerClient.Tracker().Add(oidcProviderConfigWithGoodSecret1))
r.NoError(pinnipedInformerClient.Tracker().Add(oidcProviderConfigWithGoodSecret2))
r.NoError(kubeInformerClient.Tracker().Add(goodTLSSecret1))
r.NoError(kubeInformerClient.Tracker().Add(goodTLSSecret2))
r.NoError(kubeInformerClient.Tracker().Add(badTLSSecret))
})
it("updates the issuerTLSCertSetter's map to include only the issuers that had valid certs", func() {
startInformersAndController()
r.NoError(controllerlib.TestSync(t, subject, *syncContext))
r.True(issuerTLSCertSetter.setDefaultTLSCertWasCalled)
r.Nil(issuerTLSCertSetter.setDefaultTLSCertReceived)
r.True(issuerTLSCertSetter.setIssuerHostToTLSCertMapWasCalled)
r.Len(issuerTLSCertSetter.issuerHostToTLSCertMapReceived, 2)
// They keys in the map should be lower case and should not include the port numbers, because
// TLS SNI says that SNI hostnames must be DNS names (not ports) and must be case insensitive.
// See https://tools.ietf.org/html/rfc3546#section-3.1
actualCertificate1 := issuerTLSCertSetter.issuerHostToTLSCertMapReceived["www.issuer-with-good-secret1.com"]
r.NotNil(actualCertificate1)
// The actual cert should match the one from the test fixture that was put into the secret.
r.Equal(expectedCertificate1, *actualCertificate1)
actualCertificate2 := issuerTLSCertSetter.issuerHostToTLSCertMapReceived["www.issuer-with-good-secret2.com"]
r.NotNil(actualCertificate2)
r.Equal(expectedCertificate2, *actualCertificate2)
})
when("there is also a default TLS cert secret called default-tls-certificate", func() {
var (
expectedDefaultCertificate tls.Certificate
)
it.Before(func() {
var err error
testCrt := readTestFile("testdata/test3.crt")
r.NotEmpty(testCrt)
testKey := readTestFile("testdata/test3.key")
r.NotEmpty(testKey)
expectedDefaultCertificate, err = tls.X509KeyPair(testCrt, testKey)
r.NoError(err)
defaultTLSCertSecret := &corev1.Secret{
ObjectMeta: metav1.ObjectMeta{Name: "default-tls-certificate", Namespace: installedInNamespace},
Data: map[string][]byte{"tls.crt": testCrt, "tls.key": testKey},
}
r.NoError(kubeInformerClient.Tracker().Add(defaultTLSCertSecret))
})
it("updates the issuerTLSCertSetter's map as before but also updates the default certificate", func() {
startInformersAndController()
r.NoError(controllerlib.TestSync(t, subject, *syncContext))
r.True(issuerTLSCertSetter.setDefaultTLSCertWasCalled)
actualDefaultCertificate := issuerTLSCertSetter.setDefaultTLSCertReceived
r.NotNil(actualDefaultCertificate)
r.Equal(expectedDefaultCertificate, *actualDefaultCertificate)
r.True(issuerTLSCertSetter.setIssuerHostToTLSCertMapWasCalled)
r.Len(issuerTLSCertSetter.issuerHostToTLSCertMapReceived, 2)
})
})
})
}, spec.Parallel(), spec.Report(report.Terminal{}))
}