Skip to content

Commit

Permalink
pemfile: Move file watcher plugin from advancedtls to gRPC (#3981)
Browse files Browse the repository at this point in the history
  • Loading branch information
easwars committed Oct 30, 2020
1 parent fe9c99f commit 4e179b8
Show file tree
Hide file tree
Showing 5 changed files with 699 additions and 436 deletions.
252 changes: 252 additions & 0 deletions credentials/tls/certprovider/pemfile/watcher.go
@@ -0,0 +1,252 @@
/*
*
* Copyright 2020 gRPC authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
*/

// Package pemfile provides a file watching certificate provider plugin
// implementation which works for files with PEM contents.
//
// Experimental
//
// Notice: All APIs in this package are experimental and may be removed in a
// later release.
package pemfile

import (
"bytes"
"context"
"crypto/tls"
"crypto/x509"
"fmt"
"io/ioutil"
"time"

"google.golang.org/grpc/credentials/tls/certprovider"
"google.golang.org/grpc/grpclog"
)

const (
defaultCertRefreshDuration = 1 * time.Hour
defaultRootRefreshDuration = 2 * time.Hour
)

var (
// For overriding from unit tests.
newDistributor = func() distributor { return certprovider.NewDistributor() }

logger = grpclog.Component("pemfile")
)

// Options configures a certificate provider plugin that watches a specified set
// of files that contain certificates and keys in PEM format.
type Options struct {
// CertFile is the file that holds the identity certificate.
// Optional. If this is set, KeyFile must also be set.
CertFile string
// KeyFile is the file that holds identity private key.
// Optional. If this is set, CertFile must also be set.
KeyFile string
// RootFile is the file that holds trusted root certificate(s).
// Optional.
RootFile string
// CertRefreshDuration is the amount of time the plugin waits before
// checking for updates in the specified identity certificate and key file.
// Optional. If not set, a default value (1 hour) will be used.
CertRefreshDuration time.Duration
// RootRefreshDuration is the amount of time the plugin waits before
// checking for updates in the specified root file.
// Optional. If not set, a default value (2 hour) will be used.
RootRefreshDuration time.Duration
}

// NewProvider returns a new certificate provider plugin that is configured to
// watch the PEM files specified in the passed in options.
func NewProvider(o Options) (certprovider.Provider, error) {
if o.CertFile == "" && o.KeyFile == "" && o.RootFile == "" {
return nil, fmt.Errorf("pemfile: at least one credential file needs to be specified")
}
if keySpecified, certSpecified := o.KeyFile != "", o.CertFile != ""; keySpecified != certSpecified {
return nil, fmt.Errorf("pemfile: private key file and identity cert file should be both specified or not specified")
}
if o.CertRefreshDuration == 0 {
o.CertRefreshDuration = defaultCertRefreshDuration
}
if o.RootRefreshDuration == 0 {
o.RootRefreshDuration = defaultRootRefreshDuration
}

provider := &watcher{opts: o}
if o.CertFile != "" && o.KeyFile != "" {
provider.identityDistributor = newDistributor()
}
if o.RootFile != "" {
provider.rootDistributor = newDistributor()
}

ctx, cancel := context.WithCancel(context.Background())
provider.cancel = cancel
go provider.run(ctx)

return provider, nil
}

// watcher is a certificate provider plugin that implements the
// certprovider.Provider interface. It watches a set of certificate and key
// files and provides the most up-to-date key material for consumption by
// credentials implementation.
type watcher struct {
identityDistributor distributor
rootDistributor distributor
opts Options
certFileContents []byte
keyFileContents []byte
rootFileContents []byte
cancel context.CancelFunc
}

// distributor wraps the methods on certprovider.Distributor which are used by
// the plugin. This is very useful in tests which need to know exactly when the
// plugin updates its key material.
type distributor interface {
KeyMaterial(ctx context.Context) (*certprovider.KeyMaterial, error)
Set(km *certprovider.KeyMaterial, err error)
Stop()
}

// updateIdentityDistributor checks if the cert/key files that the plugin is
// watching have changed, and if so, reads the new contents and updates the
// identityDistributor with the new key material.
//
// Skips updates when file reading or parsing fails.
// TODO(easwars): Retry with limit (on the number of retries or the amount of
// time) upon failures.
func (w *watcher) updateIdentityDistributor() {
if w.identityDistributor == nil {
return
}

certFileContents, err := ioutil.ReadFile(w.opts.CertFile)
if err != nil {
logger.Warningf("certFile (%s) read failed: %v", w.opts.CertFile, err)
return
}
keyFileContents, err := ioutil.ReadFile(w.opts.KeyFile)
if err != nil {
logger.Warningf("keyFile (%s) read failed: %v", w.opts.KeyFile, err)
return
}
// If the file contents have not changed, skip updating the distributor.
if bytes.Equal(w.certFileContents, certFileContents) && bytes.Equal(w.keyFileContents, keyFileContents) {
return
}

cert, err := tls.X509KeyPair(certFileContents, keyFileContents)
if err != nil {
logger.Warningf("tls.X509KeyPair(%q, %q) failed: %v", certFileContents, keyFileContents, err)
return
}
w.certFileContents = certFileContents
w.keyFileContents = keyFileContents
w.identityDistributor.Set(&certprovider.KeyMaterial{Certs: []tls.Certificate{cert}}, nil)
}

// updateRootDistributor checks if the root cert file that the plugin is
// watching hs changed, and if so, updates the rootDistributor with the new key
// material.
//
// Skips updates when root cert reading or parsing fails.
// TODO(easwars): Retry with limit (on the number of retries or the amount of
// time) upon failures.
func (w *watcher) updateRootDistributor() {
if w.rootDistributor == nil {
return
}

rootFileContents, err := ioutil.ReadFile(w.opts.RootFile)
if err != nil {
logger.Warningf("rootFile (%s) read failed: %v", w.opts.RootFile, err)
return
}
trustPool := x509.NewCertPool()
if !trustPool.AppendCertsFromPEM(rootFileContents) {
logger.Warning("failed to parse root certificate")
return
}
// If the file contents have not changed, skip updating the distributor.
if bytes.Equal(w.rootFileContents, rootFileContents) {
return
}

w.rootFileContents = rootFileContents
w.rootDistributor.Set(&certprovider.KeyMaterial{Roots: trustPool}, nil)
}

// run is a long running goroutine which watches the configured files for
// changes, and pushes new key material into the appropriate distributors which
// is returned from calls to KeyMaterial().
func (w *watcher) run(ctx context.Context) {
// Update both root and identity certs at the beginning. Subsequently,
// update only the appropriate file whose ticker has fired.
w.updateIdentityDistributor()
w.updateRootDistributor()

identityTicker := time.NewTicker(w.opts.CertRefreshDuration)
rootTicker := time.NewTicker(w.opts.RootRefreshDuration)
for {
select {
case <-ctx.Done():
identityTicker.Stop()
rootTicker.Stop()
if w.identityDistributor != nil {
w.identityDistributor.Stop()
}
if w.rootDistributor != nil {
w.rootDistributor.Stop()
}
return
case <-identityTicker.C:
w.updateIdentityDistributor()
case <-rootTicker.C:
w.updateRootDistributor()
}
}
}

// KeyMaterial returns the key material sourced by the watcher.
// Callers are expected to use the returned value as read-only.
func (w *watcher) KeyMaterial(ctx context.Context) (*certprovider.KeyMaterial, error) {
km := &certprovider.KeyMaterial{}
if w.identityDistributor != nil {
identityKM, err := w.identityDistributor.KeyMaterial(ctx)
if err != nil {
return nil, err
}
km.Certs = identityKM.Certs
}
if w.rootDistributor != nil {
rootKM, err := w.rootDistributor.KeyMaterial(ctx)
if err != nil {
return nil, err
}
km.Roots = rootKM.Roots
}
return km, nil
}

// Close cleans up resources allocated by the watcher.
func (w *watcher) Close() {
w.cancel()
}

0 comments on commit 4e179b8

Please sign in to comment.