OCIRepository client cert auth
Signed-off-by: Rashed Kamal <krashed@vmware.com>
This commit is contained in:
parent
4506acb9d6
commit
942d92834b
|
@ -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
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
Loading…
Reference in New Issue