Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
pemfile: Move file watcher plugin from advancedtls to gRPC (#3981)
- Loading branch information
Showing
5 changed files
with
699 additions
and
436 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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() | ||
} |
Oops, something went wrong.