diff --git a/cmd/app/app.go b/cmd/app/app.go index e4a8f5ce..dab41126 100644 --- a/cmd/app/app.go +++ b/cmd/app/app.go @@ -129,7 +129,7 @@ func NewCommand(ctx context.Context) *cobra.Command { } // Create a new TLS provider for the serving certificate and private key. - tls, err := tls.NewProvider(opts.Logr, cm, opts.TLS, cm) + tls, err := tls.NewProvider(opts.Logr, cm, opts.TLS, cm, cl) if err != nil { return fmt.Errorf("failed to create tls provider: %w", err) } diff --git a/cmd/app/options/options.go b/cmd/app/options/options.go index af9aa859..18bedc1e 100644 --- a/cmd/app/options/options.go +++ b/cmd/app/options/options.go @@ -134,8 +134,10 @@ func (o *Options) Complete() error { } // Ensure there is at least one DNS name to set in the serving certificate - // to ensure clients can properly verify the serving certificate - if len(o.TLS.ServingCertificateDNSNames) == 0 { + // to ensure clients can properly verify the serving certificate. + // Not required when loading a pre-provisioned Secret whose cert already + // embeds the desired DNS SANs. + if len(o.TLS.ServingCertificateDNSNames) == 0 && o.TLS.ServingCertificateSecretName == "" { return fmt.Errorf("the list of DNS names to add to the serving certificate is empty") } @@ -261,6 +263,18 @@ func (o *Options) addTLSFlags(fs *pflag.FlagSet) { "serving-signature-algorithm", "RSA", "The type of signature algorithm to use when generating private keys. "+ "Currently only RSA and ECDSA are supported. By default RSA is used.") + + fs.StringVar(&o.TLS.ServingCertificateSecretName, + "serving-certificate-secret-name", "", + "Name of a pre-provisioned Secret containing a cert-manager-issued TLS certificate "+ + "and key for the gRPC serving endpoint. When set, istio-csr loads its serving cert "+ + "from this Secret (tls.crt, tls.key, ca.crt) instead of requesting a CertificateRequest. "+ + "Use this to supply a SPIFFE URI SAN that cert-manager Certificates support but "+ + "the built-in CertificateRequest path does not.") + + fs.StringVar(&o.TLS.ServingCertificateSecretNamespace, + "serving-certificate-secret-namespace", "", + "Namespace of the Secret named by --serving-certificate-secret-name.") } func (o *Options) addCertManagerFlags(fs *pflag.FlagSet) { diff --git a/pkg/tls/tls.go b/pkg/tls/tls.go index cc80d9af..0694c492 100644 --- a/pkg/tls/tls.go +++ b/pkg/tls/tls.go @@ -35,6 +35,8 @@ import ( "github.com/prometheus/client_golang/prometheus" "istio.io/istio/pkg/spiffe" pkiutil "istio.io/istio/security/pkg/pki/util" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/client-go/kubernetes" "sigs.k8s.io/controller-runtime/pkg/event" "sigs.k8s.io/controller-runtime/pkg/metrics" @@ -101,6 +103,18 @@ type Options struct { // ServingSignatureAlgorithm is the type of key of serving signature algorithm // used, RSA or ECDSA, The default is RSA. ServingSignatureAlgorithm string + + // ServingCertificateSecretName is the name of a pre-provisioned Secret + // (in ServingCertificateSecretNamespace) containing a cert-manager-issued + // TLS certificate for the gRPC serving endpoint. When set, istio-csr loads + // its serving cert from this Secret instead of creating a CertificateRequest. + // The Secret must contain tls.crt, tls.key, and (if no --root-ca-file is set) + // ca.crt, following the standard cert-manager Secret format. + ServingCertificateSecretName string + + // ServingCertificateSecretNamespace is the namespace of the Secret named by + // ServingCertificateSecretName. + ServingCertificateSecretNamespace string } // Provider is used to provide a tls config containing an automatically renewed @@ -114,7 +128,8 @@ type Provider struct { rootCAs rootca.RootCAs - cm certmanager.Signer + cm certmanager.Signer + k8sClient kubernetes.Interface lock sync.RWMutex tlsConfig *tls.Config @@ -124,11 +139,12 @@ type Provider struct { } // NewProvider will return a new provider where a TLS config is ready to be fetched. -func NewProvider(log logr.Logger, cm certmanager.Signer, opts Options, issuerChangeNotifier certmanager.IssuerChangeNotifier) (*Provider, error) { +func NewProvider(log logr.Logger, cm certmanager.Signer, opts Options, issuerChangeNotifier certmanager.IssuerChangeNotifier, k8sClient kubernetes.Interface) (*Provider, error) { return &Provider{ - opts: opts, - log: log.WithName("tls-provider"), - cm: cm, + opts: opts, + log: log.WithName("tls-provider"), + cm: cm, + k8sClient: k8sClient, issuerChangeNotifier: issuerChangeNotifier, }, nil @@ -346,6 +362,10 @@ func (p *Provider) RootCAs(ctx context.Context) *rootca.RootCAs { // fails, returns error. // Returns the NotAfter timestamp that the new signed certificate expires. func (p *Provider) fetchCertificate(ctx context.Context) (time.Time, error) { + if p.opts.ServingCertificateSecretName != "" { + return p.loadFromSecret(ctx) + } + // Increment certificate request metric by 1. Success label is 0 unless there // is no error where it is changed to 1. success := "0" @@ -449,6 +469,88 @@ func (p *Provider) fetchCertificate(ctx context.Context) (time.Time, error) { return leafCert.NotAfter, nil } +// loadFromSecret loads the serving certificate and private key from the +// pre-provisioned Kubernetes Secret named by opts.ServingCertificateSecretName. +// It builds a tls.Config identical to the one produced by fetchCertificate. +// cert-manager rotates the Secret contents; each renewal tick re-reads it. +func (p *Provider) loadFromSecret(ctx context.Context) (time.Time, error) { + success := "0" + defer func() { metricCertRequest.With(prometheus.Labels{"success": success}).Inc() }() + + secret, err := p.k8sClient.CoreV1().Secrets(p.opts.ServingCertificateSecretNamespace).Get( + ctx, p.opts.ServingCertificateSecretName, metav1.GetOptions{}) + if err != nil { + return time.Time{}, fmt.Errorf("failed to get serving certificate Secret %s/%s: %w", + p.opts.ServingCertificateSecretNamespace, p.opts.ServingCertificateSecretName, err) + } + + certPEM := secret.Data["tls.crt"] + keyPEM := secret.Data["tls.key"] + + if len(certPEM) == 0 { + return time.Time{}, fmt.Errorf("Secret %s/%s missing tls.crt", + p.opts.ServingCertificateSecretNamespace, p.opts.ServingCertificateSecretName) + } + if len(keyPEM) == 0 { + return time.Time{}, fmt.Errorf("Secret %s/%s missing tls.key", + p.opts.ServingCertificateSecretNamespace, p.opts.ServingCertificateSecretName) + } + + if len(p.opts.RootCAsCertFile) == 0 { + caPEM := secret.Data["ca.crt"] + if len(caPEM) == 0 { + return time.Time{}, fmt.Errorf("Secret %s/%s missing ca.crt", + p.opts.ServingCertificateSecretNamespace, p.opts.ServingCertificateSecretName) + } + if err := p.loadCAsRoot(caPEM); err != nil { + return time.Time{}, fmt.Errorf("failed to load CA from Secret: %w", err) + } + } + + p.lock.Lock() + defer p.lock.Unlock() + + if len(p.rootCAs.PEM) == 0 || p.rootCAs.CertPool == nil { + return time.Time{}, errors.New("root CA certificate is not defined") + } + + peerCertVerifier := spiffe.NewPeerCertVerifier() + if err := peerCertVerifier.AddMappingFromPEM(p.opts.TrustDomain, p.rootCAs.PEM); err != nil { + return time.Time{}, fmt.Errorf("failed to add root CAs to SPIFFE peer certificate verifier: %w", err) + } + + tlsCert, err := tls.X509KeyPair(certPEM, keyPEM) + if err != nil { + return time.Time{}, fmt.Errorf("failed to parse serving certificate from Secret: %w", err) + } + + leafCert, err := pki.DecodeX509CertificateBytes(certPEM) + if err != nil { + return time.Time{}, fmt.Errorf("failed to parse signed certificate: %w", err) + } + + p.tlsConfig = &tls.Config{ + MinVersion: tls.VersionTLS12, + Certificates: []tls.Certificate{tlsCert}, + NextProtos: []string{"h2"}, + ClientAuth: tls.VerifyClientCertIfGiven, + ClientCAs: peerCertVerifier.GetGeneralCertPool(), + SessionTicketsDisabled: true, + VerifyPeerCertificate: func(rawCerts [][]byte, verifiedChains [][]*x509.Certificate) error { + err := peerCertVerifier.VerifyPeerCert(rawCerts, verifiedChains) + if err != nil { + p.log.Error(err, "could not verify certificate") + } + return err + }, + } + + p.log.Info("serving certificate loaded from Secret", + "secret", p.opts.ServingCertificateSecretNamespace+"/"+p.opts.ServingCertificateSecretName) + success = "1" + return leafCert.NotAfter, nil +} + func (p *Provider) TrustDomain() string { return p.opts.TrustDomain }