OCIRepository client cert auth

Signed-off-by: Rashed Kamal <krashed@vmware.com>
This commit is contained in:
Rashed Kamal 2022-07-07 17:33:40 -04:00 committed by Stefan Prodan
parent 4506acb9d6
commit 942d92834b
No known key found for this signature in database
GPG Key ID: 3299AEB0E4085BAF
2 changed files with 362 additions and 13 deletions

View File

@ -18,8 +18,11 @@ package controllers
import (
"context"
"crypto/tls"
"crypto/x509"
"errors"
"fmt"
"net/http"
"os"
"sort"
"strings"
@ -31,6 +34,7 @@ import (
"github.com/google/go-containerregistry/pkg/crane"
"github.com/google/go-containerregistry/pkg/name"
gcrv1 "github.com/google/go-containerregistry/pkg/v1"
"github.com/google/go-containerregistry/pkg/v1/remote"
corev1 "k8s.io/api/core/v1"
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/apimachinery/pkg/types"
@ -61,6 +65,12 @@ import (
"github.com/fluxcd/source-controller/internal/util"
)
const (
ClientCert = "certFile"
ClientKey = "keyFile"
CACert = "caFile"
)
// ociRepositoryReadyCondition contains the information required to summarize a
// v1beta2.OCIRepository Ready Condition.
var ociRepositoryReadyCondition = summarize.Conditions{
@ -295,8 +305,16 @@ func (r *OCIRepositoryReconciler) reconcileSource(ctx context.Context, obj *sour
return sreconcile.ResultEmpty, e
}
// Generates transport for remote operations
transport, err := r.transport(ctx, obj)
if err != nil {
e := serror.NewGeneric(err, sourcev1.OCIOperationFailedReason)
conditions.MarkTrue(obj, sourcev1.FetchFailedCondition, e.Reason, e.Error())
return sreconcile.ResultEmpty, e
}
// Determine which artifact revision to pull
url, err := r.getArtifactURL(ctxTimeout, obj, keychain)
url, err := r.getArtifactURL(ctxTimeout, obj, keychain, transport)
if err != nil {
e := serror.NewGeneric(err, sourcev1.OCIOperationFailedReason)
conditions.MarkTrue(obj, sourcev1.FetchFailedCondition, e.Reason, e.Error())
@ -304,7 +322,7 @@ func (r *OCIRepositoryReconciler) reconcileSource(ctx context.Context, obj *sour
}
// Pull artifact from the remote container registry
img, err := crane.Pull(url, r.craneOptions(ctxTimeout, keychain)...)
img, err := crane.Pull(url, r.craneOptions(ctxTimeout, keychain, transport)...)
if err != nil {
e := serror.NewGeneric(err, sourcev1.OCIOperationFailedReason)
conditions.MarkTrue(obj, sourcev1.FetchFailedCondition, e.Reason, e.Error())
@ -382,7 +400,7 @@ func (r *OCIRepositoryReconciler) parseRepositoryURL(obj *sourcev1.OCIRepository
}
// getArtifactURL determines which tag or digest should be used and returns the OCI artifact FQN.
func (r *OCIRepositoryReconciler) getArtifactURL(ctx context.Context, obj *sourcev1.OCIRepository, keychain authn.Keychain) (string, error) {
func (r *OCIRepositoryReconciler) getArtifactURL(ctx context.Context, obj *sourcev1.OCIRepository, keychain authn.Keychain, transport http.RoundTripper) (string, error) {
url, err := r.parseRepositoryURL(obj)
if err != nil {
return "", err
@ -394,7 +412,7 @@ func (r *OCIRepositoryReconciler) getArtifactURL(ctx context.Context, obj *sourc
}
if obj.Spec.Reference.SemVer != "" {
tag, err := r.getTagBySemver(ctx, url, obj.Spec.Reference.SemVer, keychain)
tag, err := r.getTagBySemver(ctx, url, obj.Spec.Reference.SemVer, keychain, transport)
if err != nil {
return "", err
}
@ -411,8 +429,8 @@ func (r *OCIRepositoryReconciler) getArtifactURL(ctx context.Context, obj *sourc
// getTagBySemver call the remote container registry, fetches all the tags from the repository,
// and returns the latest tag according to the semver expression.
func (r *OCIRepositoryReconciler) getTagBySemver(ctx context.Context, url, exp string, keychain authn.Keychain) (string, error) {
tags, err := crane.ListTags(url, r.craneOptions(ctx, keychain)...)
func (r *OCIRepositoryReconciler) getTagBySemver(ctx context.Context, url, exp string, keychain authn.Keychain, transport http.RoundTripper) (string, error) {
tags, err := crane.ListTags(url, r.craneOptions(ctx, keychain, transport)...)
if err != nil {
return "", err
}
@ -486,13 +504,62 @@ func (r *OCIRepositoryReconciler) keychain(ctx context.Context, obj *sourcev1.OC
return k8schain.NewFromPullSecrets(ctx, imagePullSecrets)
}
// transport clones the default transport from remote.
// If certSecretRef is configured in the resource configuration,
// returned transport will iclude client and/or CA certifactes
func (r *OCIRepositoryReconciler) transport(ctx context.Context, obj *sourcev1.OCIRepository) (http.RoundTripper, error) {
if obj.Spec.CertSecretRef != nil {
var certSecret corev1.Secret
err := r.Get(ctx,
types.NamespacedName{Namespace: obj.Namespace, Name: obj.Spec.CertSecretRef.Name},
&certSecret)
if err != nil {
r.eventLogf(ctx, obj, events.EventSeverityTrace, "secret %q not found", obj.Spec.CertSecretRef.Name)
return nil, err
}
transport := remote.DefaultTransport.Clone()
tlsConfig := transport.TLSClientConfig
if clientCert, ok := certSecret.Data[ClientCert]; ok {
// parse and set client cert and secret
if clientKey, ok := certSecret.Data[ClientKey]; ok {
cert, err := tls.X509KeyPair(clientCert, clientKey)
if err != nil {
return nil, err
}
tlsConfig.Certificates = append(tlsConfig.Certificates, cert)
} else {
return nil, fmt.Errorf("client certificate found, but no key")
}
}
if caCert, ok := certSecret.Data[CACert]; ok {
syscerts, err := x509.SystemCertPool()
if err != nil {
return nil, err
}
syscerts.AppendCertsFromPEM(caCert)
tlsConfig.RootCAs = syscerts
}
return transport, nil
}
return nil, nil
}
// craneOptions sets the timeout and user agent for all operations against remote container registries.
func (r *OCIRepositoryReconciler) craneOptions(ctx context.Context, keychain authn.Keychain) []crane.Option {
return []crane.Option{
func (r *OCIRepositoryReconciler) craneOptions(ctx context.Context, keychain authn.Keychain, transport http.RoundTripper) []crane.Option {
options := []crane.Option{
crane.WithContext(ctx),
crane.WithUserAgent("flux/v2"),
crane.WithAuthFromKeychain(keychain),
}
if transport != nil {
options = append(options, crane.WithTransport(transport))
}
return options
}
// reconcileStorage ensures the current state of the storage matches the

View File

@ -16,7 +16,17 @@ limitations under the License.
package controllers
import (
"crypto/rand"
"crypto/rsa"
"crypto/tls"
"crypto/x509"
"crypto/x509/pkix"
"encoding/pem"
"errors"
"fmt"
"math/big"
"net"
"net/http"
"net/http/httptest"
"net/url"
"os"
@ -138,7 +148,7 @@ func TestOCIRepository_Reconcile(t *testing.T) {
return false
}
return len(obj.Finalizers) > 0
}, timeout).Should(BeFalse())
}, timeout).Should(BeTrue())
// Wait for the object to be Ready
g.Eventually(func() bool {
@ -336,7 +346,7 @@ func TestOCIRepository_SecretRef(t *testing.T) {
return false
}
return len(obj.Finalizers) > 0
}, timeout).Should(BeFalse())
}, timeout).Should(BeTrue())
// Wait for the object to be Ready
g.Eventually(func() bool {
@ -582,6 +592,167 @@ func TestOCIRepository_FailedAuth(t *testing.T) {
}
}
func TestOCIRepository_CertSecret(t *testing.T) {
g := NewWithT(t)
registryServer, err := registry.TLS("localhost")
g.Expect(err).ToNot(HaveOccurred())
defer registryServer.Close()
pi, err := createPodinfoImageFromTar("podinfo-6.1.6.tar", "6.1.6", registryServer)
g.Expect(err).ToNot(HaveOccurred())
ca_cert := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: registryServer.Certificate().Raw})
t.Logf("certdata: %v", string(ca_cert))
tlsSecretCACert := corev1.Secret{
StringData: map[string]string{
CACert: string(ca_cert),
},
}
srv, rootCertPEM, clientCertPEM, clientKeyPEM, clientTLSCert, err := createTLSServer()
g.Expect(err).ToNot(HaveOccurred())
srv.StartTLS()
defer srv.Close()
transport := &http.Transport{
TLSClientConfig: &tls.Config{},
}
// Use the server cert as a CA cert, so the client trusts the
// server cert. (Only works because the server uses the same
// cert in both roles).
pool := x509.NewCertPool()
pool.AddCert(srv.Certificate())
transport.TLSClientConfig.RootCAs = pool
transport.TLSClientConfig.Certificates = []tls.Certificate{clientTLSCert}
srv.Client().Transport = transport
pi2, err := createPodinfoImageFromTar("podinfo-6.1.5.tar", "6.1.5", srv)
g.Expect(err).NotTo(HaveOccurred())
tlsSecretClientCert := corev1.Secret{
StringData: map[string]string{
CACert: string(rootCertPEM),
ClientCert: string(clientCertPEM),
ClientKey: string(clientKeyPEM),
},
}
tests := []struct {
name string
url string
tag string
digest v1.Hash
certSecret *corev1.Secret
expectreadyconition bool
expectedstatusmessage string
}{
{
name: "test connection without CACert",
url: pi.url,
tag: pi.tag,
digest: pi.digest,
certSecret: nil,
expectreadyconition: false,
expectedstatusmessage: "unexpected status code 400 Bad Request: Client sent an HTTP request to an HTTPS server.",
},
{
name: "test connection with CACert",
url: pi.url,
tag: pi.tag,
digest: pi.digest,
certSecret: &tlsSecretCACert,
expectreadyconition: true,
expectedstatusmessage: fmt.Sprintf("stored artifact for revision '%s'", pi.digest.Hex),
},
{
name: "test connection with CACert, Client Cert and Private Key",
url: pi2.url,
tag: pi2.tag,
digest: pi2.digest,
certSecret: &tlsSecretClientCert,
expectreadyconition: true,
expectedstatusmessage: fmt.Sprintf("stored artifact for revision '%s'", pi2.digest.Hex),
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
g := NewWithT(t)
ns, err := testEnv.CreateNamespace(ctx, "ocirepository-test")
g.Expect(err).ToNot(HaveOccurred())
defer func() { g.Expect(testEnv.Delete(ctx, ns)).To(Succeed()) }()
obj := &sourcev1.OCIRepository{
ObjectMeta: metav1.ObjectMeta{
GenerateName: "ocirepository-test-resource",
Namespace: ns.Name,
},
Spec: sourcev1.OCIRepositorySpec{
URL: tt.url,
Interval: metav1.Duration{Duration: 60 * time.Minute},
Reference: &sourcev1.OCIRepositoryRef{Digest: tt.digest.String()},
},
}
if tt.certSecret != nil {
tt.certSecret.ObjectMeta = metav1.ObjectMeta{
GenerateName: "cert-secretref",
Namespace: ns.Name,
}
g.Expect(testEnv.CreateAndWait(ctx, tt.certSecret)).To(Succeed())
defer func() { g.Expect(testEnv.Delete(ctx, tt.certSecret)).To(Succeed()) }()
obj.Spec.CertSecretRef = &meta.LocalObjectReference{Name: tt.certSecret.Name}
}
g.Expect(testEnv.Create(ctx, obj)).To(Succeed())
key := client.ObjectKey{Name: obj.Name, Namespace: obj.Namespace}
resultobj := sourcev1.OCIRepository{}
// Wait for the finalizer to be set
g.Eventually(func() bool {
if err := testEnv.Get(ctx, key, &resultobj); err != nil {
return false
}
return len(resultobj.Finalizers) > 0
}, timeout).Should(BeTrue())
// Wait for the object to fail
g.Eventually(func() bool {
if err := testEnv.Get(ctx, key, &resultobj); err != nil {
return false
}
readyCondition := conditions.Get(&resultobj, meta.ReadyCondition)
if readyCondition == nil {
return false
}
return obj.Generation == readyCondition.ObservedGeneration &&
conditions.IsReady(&resultobj) == tt.expectreadyconition
}, timeout).Should(BeTrue())
readyCondition := conditions.Get(&resultobj, meta.ReadyCondition)
g.Expect(readyCondition.Message).Should(ContainSubstring(tt.expectedstatusmessage))
// Wait for the object to be deleted
g.Expect(testEnv.Delete(ctx, &resultobj)).To(Succeed())
g.Eventually(func() bool {
if err := testEnv.Get(ctx, key, &resultobj); err != nil {
return apierrors.IsNotFound(err)
}
return false
}, timeout).Should(BeTrue())
})
}
}
type artifactFixture struct {
expectedPath string
expectedChecksum string
@ -593,7 +764,6 @@ type podinfoImage struct {
}
func createPodinfoImageFromTar(tarFileName, tag string, imageServer *httptest.Server) (*podinfoImage, error) {
// Create Image
image, err := crane.Load(path.Join("testdata", "podinfo", tarFileName))
if err != nil {
@ -613,13 +783,14 @@ func createPodinfoImageFromTar(tarFileName, tag string, imageServer *httptest.Se
}
// Push image
err = crane.Push(image, repositoryURL)
err = crane.Push(image, repositoryURL, crane.WithTransport(imageServer.Client().Transport))
if err != nil {
return nil, err
}
// Tag the image
err = crane.Tag(repositoryURL, tag)
err = crane.Tag(repositoryURL, tag, crane.WithTransport(imageServer.Client().Transport))
if err != nil {
return nil, err
}
@ -630,3 +801,114 @@ func createPodinfoImageFromTar(tarFileName, tag string, imageServer *httptest.Se
digest: podinfoImageDigest,
}, nil
}
// These two taken verbatim from https://ericchiang.github.io/post/go-tls/
func certTemplate() (*x509.Certificate, error) {
// generate a random serial number (a real cert authority would
// have some logic behind this)
serialNumberLimit := new(big.Int).Lsh(big.NewInt(1), 128)
serialNumber, err := rand.Int(rand.Reader, serialNumberLimit)
if err != nil {
return nil, errors.New("failed to generate serial number: " + err.Error())
}
tmpl := x509.Certificate{
SerialNumber: serialNumber,
Subject: pkix.Name{Organization: []string{"Flux project"}},
SignatureAlgorithm: x509.SHA256WithRSA,
NotBefore: time.Now(),
NotAfter: time.Now().Add(time.Hour), // valid for an hour
BasicConstraintsValid: true,
}
return &tmpl, nil
}
func createCert(template, parent *x509.Certificate, pub interface{}, parentPriv interface{}) (
cert *x509.Certificate, certPEM []byte, err error) {
certDER, err := x509.CreateCertificate(rand.Reader, template, parent, pub, parentPriv)
if err != nil {
return
}
// parse the resulting certificate so we can use it again
cert, err = x509.ParseCertificate(certDER)
if err != nil {
return
}
// PEM encode the certificate (this is a standard TLS encoding)
b := pem.Block{Type: "CERTIFICATE", Bytes: certDER}
certPEM = pem.EncodeToMemory(&b)
return
}
// ----
func createTLSServer() (*httptest.Server, []byte, []byte, []byte, tls.Certificate, error) {
var clientTLSCert tls.Certificate
var rootCertPEM, clientCertPEM, clientKeyPEM []byte
srv := httptest.NewUnstartedServer(registry.New())
// Create a self-signed cert to use as the CA and server cert.
rootKey, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
return srv, rootCertPEM, clientCertPEM, clientKeyPEM, clientTLSCert, err
}
rootCertTmpl, err := certTemplate()
if err != nil {
return srv, rootCertPEM, clientCertPEM, clientKeyPEM, clientTLSCert, err
}
rootCertTmpl.IsCA = true
rootCertTmpl.KeyUsage = x509.KeyUsageCertSign | x509.KeyUsageDigitalSignature
rootCertTmpl.ExtKeyUsage = []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth, x509.ExtKeyUsageClientAuth}
rootCertTmpl.IPAddresses = []net.IP{net.ParseIP("127.0.0.1")}
var rootCert *x509.Certificate
rootCert, rootCertPEM, err = createCert(rootCertTmpl, rootCertTmpl, &rootKey.PublicKey, rootKey)
if err != nil {
return srv, rootCertPEM, clientCertPEM, clientKeyPEM, clientTLSCert, err
}
rootKeyPEM := pem.EncodeToMemory(&pem.Block{
Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(rootKey),
})
// Create a TLS cert using the private key and certificate.
rootTLSCert, err := tls.X509KeyPair(rootCertPEM, rootKeyPEM)
if err != nil {
return srv, rootCertPEM, clientCertPEM, clientKeyPEM, clientTLSCert, err
}
// To trust a client certificate, the server must be given a
// CA cert pool.
pool := x509.NewCertPool()
pool.AddCert(rootCert)
srv.TLS = &tls.Config{
ClientAuth: tls.RequireAndVerifyClientCert,
Certificates: []tls.Certificate{rootTLSCert},
ClientCAs: pool,
}
// Create a client cert, signed by the "CA".
clientKey, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
return srv, rootCertPEM, clientCertPEM, clientKeyPEM, clientTLSCert, err
}
clientCertTmpl, err := certTemplate()
if err != nil {
return srv, rootCertPEM, clientCertPEM, clientKeyPEM, clientTLSCert, err
}
clientCertTmpl.KeyUsage = x509.KeyUsageDigitalSignature
clientCertTmpl.ExtKeyUsage = []x509.ExtKeyUsage{x509.ExtKeyUsageClientAuth}
_, clientCertPEM, err = createCert(clientCertTmpl, rootCert, &clientKey.PublicKey, rootKey)
if err != nil {
return srv, rootCertPEM, clientCertPEM, clientKeyPEM, clientTLSCert, err
}
// Encode and load the cert and private key for the client.
clientKeyPEM = pem.EncodeToMemory(&pem.Block{
Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(clientKey),
})
clientTLSCert, err = tls.X509KeyPair(clientCertPEM, clientKeyPEM)
return srv, rootCertPEM, clientCertPEM, clientKeyPEM, clientTLSCert, err
}