├── .gitignore ├── go.mod ├── ticker ├── real_ticker.go ├── ticker_test.go └── ticker.go ├── .github └── workflows │ ├── semgrep.yml │ └── test.yaml ├── certinel.go ├── go.sum ├── LICENSE.md ├── internel └── pkitest │ └── pkitest.go ├── fswatcher ├── fswatcher_atomic_test.go ├── fswatcher_test.go └── fswatcher.go └── README.md /.gitignore: -------------------------------------------------------------------------------- 1 | vendor/ 2 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/cloudflare/certinel 2 | 3 | go 1.23.0 4 | 5 | require ( 6 | github.com/fsnotify/fsnotify v1.8.0 7 | github.com/google/go-cmp v0.7.0 8 | github.com/jonboulle/clockwork v0.5.0 9 | golang.org/x/sync v0.15.0 10 | gotest.tools/v3 v3.5.2 11 | ) 12 | 13 | require golang.org/x/sys v0.13.0 // indirect 14 | -------------------------------------------------------------------------------- /ticker/real_ticker.go: -------------------------------------------------------------------------------- 1 | package ticker 2 | 3 | import "time" 4 | 5 | // realTicker wraps a [time.Ticker] to satisfy the [ticker] interface. 6 | type realTicker struct { 7 | *time.Ticker 8 | } 9 | 10 | func (r realTicker) Chan() <-chan time.Time { 11 | return r.C 12 | } 13 | 14 | func newRealTicker(d time.Duration) ticker { 15 | return realTicker{ 16 | Ticker: time.NewTicker(d), 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /.github/workflows/semgrep.yml: -------------------------------------------------------------------------------- 1 | on: 2 | pull_request: {} 3 | workflow_dispatch: {} 4 | push: 5 | branches: 6 | - master 7 | schedule: 8 | - cron: "0 0 * * *" 9 | name: Semgrep config 10 | jobs: 11 | semgrep: 12 | name: semgrep/ci 13 | runs-on: ubuntu-latest 14 | env: 15 | SEMGREP_APP_TOKEN: ${{ secrets.SEMGREP_APP_TOKEN }} 16 | SEMGREP_URL: https://cloudflare.semgrep.dev 17 | SEMGREP_APP_URL: https://cloudflare.semgrep.dev 18 | SEMGREP_VERSION_CHECK_URL: https://cloudflare.semgrep.dev/api/check-version 19 | container: 20 | image: returntocorp/semgrep 21 | steps: 22 | - uses: actions/checkout@v4 23 | - run: semgrep ci 24 | -------------------------------------------------------------------------------- /.github/workflows/test.yaml: -------------------------------------------------------------------------------- 1 | name: Test 2 | on: 3 | - pull_request 4 | - push 5 | jobs: 6 | unit: 7 | strategy: 8 | matrix: 9 | go: ["stable", "oldstable"] 10 | os: ["macos-latest", "ubuntu-latest", "windows-latest"] 11 | runs-on: ${{ matrix.os }} 12 | name: "Go ${{ matrix.go }} (${{ matrix.os }}) Test" 13 | steps: 14 | - uses: actions/checkout@v4 15 | - uses: actions/setup-go@v4 16 | with: 17 | go-version: ${{ matrix.go }} 18 | - run: go test ./... 19 | lint: 20 | runs-on: ubuntu-latest 21 | steps: 22 | - uses: actions/checkout@v4 23 | - uses: actions/setup-go@v4 24 | with: 25 | go-version: "stable" 26 | - uses: dominikh/staticcheck-action@v1 27 | with: 28 | build-tags: suite 29 | install-go: false 30 | -------------------------------------------------------------------------------- /certinel.go: -------------------------------------------------------------------------------- 1 | // Package certinel defines the Certinel and Runnable interfaces for watching 2 | // for implementing zero-hit rotations of TLS certificates. 3 | package certinel 4 | 5 | import ( 6 | "context" 7 | "crypto/tls" 8 | ) 9 | 10 | // A Certinel implementation watches certificates for changes, and returns the 11 | // desired certificate when requested by Go's crypto/tls implementation. 12 | type Certinel interface { 13 | GetCertificate(chi *tls.ClientHelloInfo) (*tls.Certificate, error) 14 | GetClientCertificate(cri *tls.CertificateRequestInfo) (*tls.Certificate, error) 15 | } 16 | 17 | // Runnable is implemented by Certinel instances that perform asynchronous actions. 18 | type Runnable interface { 19 | // Start starts running the asynchronous actions that make up the Certinel 20 | // instances. The instance will stop running when the context is closed. 21 | // Start blocks until the context is closed or an error occures. 22 | Start(ctx context.Context) error 23 | } 24 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/fsnotify/fsnotify v1.8.0 h1:dAwr6QBTBZIkG8roQaJjGof0pp0EeF+tNV7YBP3F/8M= 2 | github.com/fsnotify/fsnotify v1.8.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= 3 | github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= 4 | github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= 5 | github.com/jonboulle/clockwork v0.5.0 h1:Hyh9A8u51kptdkR+cqRpT1EebBwTn1oK9YfGYbdFz6I= 6 | github.com/jonboulle/clockwork v0.5.0/go.mod h1:3mZlmanh0g2NDKO5TWZVJAfofYk64M7XN3SzBPjZF60= 7 | golang.org/x/sync v0.15.0 h1:KWH3jNZsfyT6xfAfKiz6MRNmd46ByHDYaZ7KSkCtdW8= 8 | golang.org/x/sync v0.15.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= 9 | golang.org/x/sys v0.13.0 h1:Af8nKPmuFypiUBjVoU9V20FiaFXOcuZI21p0ycVYYGE= 10 | golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 11 | gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q= 12 | gotest.tools/v3 v3.5.2/go.mod h1:LtdLGcnqToBH83WByAAi/wiwSFCArdFIUV/xxN4pcjA= 13 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | Copyright (c) 2017-2020, Cloudflare, Inc. 2 | All rights reserved. 3 | 4 | Redistribution and use in source and binary forms, with or without 5 | modification, are permitted provided that the following conditions are met: 6 | 7 | 1. Redistributions of source code must retain the above copyright notice, this 8 | list of conditions and the following disclaimer. 9 | 10 | 2. Redistributions in binary form must reproduce the above copyright notice, 11 | this list of conditions and the following disclaimer in the documentation 12 | and/or other materials provided with the distribution. 13 | 14 | 3. Neither the name of the copyright holder nor the names of its 15 | contributors may be used to endorse or promote products derived from 16 | this software without specific prior written permission. 17 | 18 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 19 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 20 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 21 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 22 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 23 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 24 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 25 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 26 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 27 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 28 | 29 | -------------------------------------------------------------------------------- /internel/pkitest/pkitest.go: -------------------------------------------------------------------------------- 1 | // Package pkitest provides a few utility functions shared across tests. 2 | package pkitest 3 | 4 | import ( 5 | "crypto/ecdsa" 6 | "crypto/elliptic" 7 | "crypto/rand" 8 | "crypto/x509" 9 | "encoding/pem" 10 | "math/big" 11 | "testing" 12 | 13 | "gotest.tools/v3/assert" 14 | ) 15 | 16 | // CmpBigInt implements a functions that compares big.Ints and is 17 | // compatible with cmp.Comparer. 18 | func CmpBigInt(x, y *big.Int) bool { 19 | c := x.Cmp(y) 20 | return c == 0 21 | } 22 | 23 | // NewPrivateKey is a test helper that creates a new ECDSA private key. 24 | func NewPrivateKey(t *testing.T) *ecdsa.PrivateKey { 25 | t.Helper() 26 | priv, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) 27 | assert.NilError(t, err, "generating ecdsa private key") 28 | return priv 29 | } 30 | 31 | // EncodePrivateKey is a test heper that x509 encodes the provided 32 | // ECDSA private key. 33 | func EncodePrivateKey(t *testing.T, key *ecdsa.PrivateKey) []byte { 34 | t.Helper() 35 | p, err := x509.MarshalECPrivateKey(key) 36 | assert.NilError(t, err, "marshaling x509") 37 | block := &pem.Block{Type: "EC PRIVATE KEY", Bytes: p} 38 | return pem.EncodeToMemory(block) 39 | } 40 | 41 | // PemEncodeCertificate is a test helper that encodes the provided 42 | // certificate with the public key derived from the ECDSA private key 43 | // and encodes into PEM. 44 | func PemEncodeCertificate(t *testing.T, cert x509.Certificate, key *ecdsa.PrivateKey) []byte { 45 | t.Helper() 46 | p, err := x509.CreateCertificate(rand.Reader, &cert, &cert, &key.PublicKey, key) 47 | assert.NilError(t, err, "creating certificate") 48 | block := &pem.Block{Type: "CERTIFICATE", Bytes: p} 49 | return pem.EncodeToMemory(block) 50 | } 51 | -------------------------------------------------------------------------------- /ticker/ticker_test.go: -------------------------------------------------------------------------------- 1 | package ticker 2 | 3 | import ( 4 | "context" 5 | "crypto/x509" 6 | "math/big" 7 | "testing" 8 | "time" 9 | 10 | "github.com/cloudflare/certinel/internel/pkitest" 11 | "github.com/google/go-cmp/cmp" 12 | "github.com/jonboulle/clockwork" 13 | "golang.org/x/sync/errgroup" 14 | "gotest.tools/v3/assert" 15 | is "gotest.tools/v3/assert/cmp" 16 | "gotest.tools/v3/fs" 17 | "gotest.tools/v3/poll" 18 | ) 19 | 20 | func TestTicker(t *testing.T) { 21 | t.Parallel() 22 | 23 | ctx, cancel := context.WithCancel(context.Background()) 24 | clock := clockwork.NewFakeClock() 25 | 26 | pk := pkitest.NewPrivateKey(t) 27 | pemPK := pkitest.EncodePrivateKey(t, pk) 28 | 29 | dir := fs.NewDir(t, "test-ticker", 30 | fs.WithFile("my.key", "", fs.WithBytes(pemPK)), 31 | fs.WithFile("my.crt", "", fs.WithBytes( 32 | pkitest.PemEncodeCertificate(t, x509.Certificate{ 33 | SerialNumber: big.NewInt(1), 34 | }, pk), 35 | )), 36 | ) 37 | defer dir.Remove() 38 | 39 | watcher, err := New(dir.Join("my.crt"), dir.Join("my.key"), 1*time.Minute) 40 | assert.NilError(t, err) 41 | 42 | watcher.ticker = func(d time.Duration) ticker { 43 | return clock.NewTicker(d) 44 | } 45 | 46 | cert, err := watcher.GetCertificate(nil) 47 | assert.NilError(t, err) 48 | assert.DeepEqual(t, cert.Leaf.SerialNumber, big.NewInt(1), cmp.Comparer(pkitest.CmpBigInt)) 49 | 50 | g, gctx := errgroup.WithContext(ctx) 51 | g.Go(func() error { 52 | return watcher.Start(gctx) 53 | }) 54 | 55 | fs.Apply(t, dir, fs.WithFile("my.crt", "", fs.WithBytes( 56 | pkitest.PemEncodeCertificate(t, x509.Certificate{ 57 | SerialNumber: big.NewInt(10000), 58 | }, pk), 59 | ))) 60 | 61 | clock.BlockUntilContext(ctx, 1) 62 | clock.Advance(1 * time.Minute) 63 | 64 | poll.WaitOn(t, func(t poll.LogT) poll.Result { 65 | cert, err := watcher.GetCertificate(nil) 66 | if err != nil { 67 | return poll.Error(err) 68 | } 69 | 70 | return poll.Compare(is.DeepEqual(cert.Leaf.SerialNumber, big.NewInt(10000), cmp.Comparer(pkitest.CmpBigInt))) 71 | }) 72 | 73 | cancel() 74 | assert.ErrorIs(t, g.Wait(), context.Canceled) 75 | } 76 | -------------------------------------------------------------------------------- /ticker/ticker.go: -------------------------------------------------------------------------------- 1 | // Package ticker implements the Certinel interface by polling the filesystem 2 | // at a regular interval. This provides a reasonable alternative for environments 3 | // not supported by the fsnotify package. 4 | package ticker 5 | 6 | import ( 7 | "context" 8 | "crypto/tls" 9 | "crypto/x509" 10 | "fmt" 11 | "sync/atomic" 12 | "time" 13 | ) 14 | 15 | // ticker allows swapping for a fake [time.Ticker] in tests. 16 | type ticker interface { 17 | Chan() <-chan time.Time 18 | Stop() 19 | } 20 | 21 | // Sentinel polls the filesystem for changed certificates. 22 | type Sentinel struct { 23 | certPath, keyPath string 24 | duration time.Duration 25 | ticker func(d time.Duration) ticker 26 | certificate atomic.Pointer[tls.Certificate] 27 | } 28 | 29 | func New(cert, key string, duration time.Duration) (*Sentinel, error) { 30 | w := &Sentinel{ 31 | certPath: cert, 32 | keyPath: key, 33 | duration: duration, 34 | ticker: newRealTicker, 35 | } 36 | 37 | if err := w.loadCertificate(); err != nil { 38 | return nil, fmt.Errorf("unable to load initial certificate: %w", err) 39 | } 40 | 41 | return w, nil 42 | } 43 | 44 | func (w *Sentinel) loadCertificate() error { 45 | certificate, err := tls.LoadX509KeyPair(w.certPath, w.keyPath) 46 | if err != nil { 47 | return fmt.Errorf("unable to load certificate: %w", err) 48 | } 49 | 50 | leaf, err := x509.ParseCertificate(certificate.Certificate[0]) 51 | if err != nil { 52 | return fmt.Errorf("unable to load certificate: %w", err) 53 | } 54 | 55 | certificate.Leaf = leaf 56 | 57 | w.certificate.Store(&certificate) 58 | return nil 59 | } 60 | 61 | func (w *Sentinel) Start(ctx context.Context) error { 62 | t := w.ticker(w.duration) 63 | defer t.Stop() 64 | 65 | for { 66 | select { 67 | case <-ctx.Done(): 68 | return ctx.Err() 69 | case <-t.Chan(): 70 | if err := w.loadCertificate(); err != nil { 71 | return err 72 | } 73 | } 74 | } 75 | } 76 | 77 | func (w *Sentinel) GetCertificate(chi *tls.ClientHelloInfo) (*tls.Certificate, error) { 78 | return w.certificate.Load(), nil 79 | } 80 | 81 | func (w *Sentinel) GetClientCertificate(cri *tls.CertificateRequestInfo) (*tls.Certificate, error) { 82 | cert := w.certificate.Load() 83 | if cert == nil { 84 | cert = &tls.Certificate{} 85 | } 86 | 87 | return cert, nil 88 | } 89 | -------------------------------------------------------------------------------- /fswatcher/fswatcher_atomic_test.go: -------------------------------------------------------------------------------- 1 | // +build !windows 2 | //go:build !windows 3 | 4 | package fswatcher 5 | 6 | import ( 7 | "context" 8 | "crypto/x509" 9 | "math/big" 10 | "os" 11 | "path/filepath" 12 | "testing" 13 | "time" 14 | 15 | "github.com/cloudflare/certinel/internel/pkitest" 16 | "github.com/google/go-cmp/cmp" 17 | "golang.org/x/sync/errgroup" 18 | "gotest.tools/v3/assert" 19 | is "gotest.tools/v3/assert/cmp" 20 | "gotest.tools/v3/fs" 21 | "gotest.tools/v3/poll" 22 | ) 23 | 24 | const ( 25 | atomicDirName = "..data" 26 | atomicNewDirName = "..data_tmp" 27 | ) 28 | 29 | func TestWatcher_AtomicWriter(t *testing.T) { 30 | t.Parallel() 31 | 32 | ctx, cancel := context.WithCancel(context.Background()) 33 | 34 | pk1 := pkitest.NewPrivateKey(t) 35 | pemPK1 := pkitest.EncodePrivateKey(t, pk1) 36 | 37 | pk2 := pkitest.NewPrivateKey(t) 38 | pemPK2 := pkitest.EncodePrivateKey(t, pk2) 39 | 40 | dir := fs.NewDir(t, "test-symlink", 41 | fs.WithDir(".first", 42 | fs.WithFile("my.crt", "", fs.WithBytes( 43 | pkitest.PemEncodeCertificate(t, x509.Certificate{ 44 | SerialNumber: big.NewInt(1), 45 | }, pk1), 46 | )), 47 | fs.WithFile("my.key", "", fs.WithBytes(pemPK1)), 48 | ), 49 | fs.WithDir(".second", 50 | fs.WithFile("my.crt", "", fs.WithBytes( 51 | pkitest.PemEncodeCertificate(t, x509.Certificate{ 52 | SerialNumber: big.NewInt(2), 53 | }, pk2), 54 | )), 55 | fs.WithFile("my.key", "", fs.WithBytes(pemPK2)), 56 | ), 57 | fs.WithSymlink(atomicDirName, ".first"), 58 | fs.WithSymlink("my.key", filepath.Join(atomicDirName, "my.key")), 59 | fs.WithSymlink("my.crt", filepath.Join(atomicDirName, "my.crt")), 60 | ) 61 | defer dir.Remove() 62 | 63 | watcher, err := New(dir.Join("my.crt"), dir.Join("my.key")) 64 | assert.NilError(t, err) 65 | 66 | cert, err := watcher.GetCertificate(nil) 67 | assert.NilError(t, err) 68 | assert.DeepEqual(t, cert.Leaf.SerialNumber, big.NewInt(1), cmp.Comparer(pkitest.CmpBigInt)) 69 | 70 | g, gctx := errgroup.WithContext(ctx) 71 | g.Go(func() error { 72 | return watcher.Start(gctx) 73 | }) 74 | <-time.After(100 * time.Millisecond) 75 | 76 | fs.Apply(t, dir, fs.WithSymlink(atomicNewDirName, ".second")) 77 | 78 | err = os.Rename(dir.Join(atomicNewDirName), dir.Join(atomicDirName)) 79 | assert.NilError(t, err) 80 | 81 | poll.WaitOn(t, func(t poll.LogT) poll.Result { 82 | cert, err := watcher.GetCertificate(nil) 83 | if err != nil { 84 | return poll.Error(err) 85 | } 86 | 87 | return poll.Compare(is.DeepEqual(cert.Leaf.SerialNumber, big.NewInt(2), cmp.Comparer(pkitest.CmpBigInt))) 88 | }) 89 | 90 | cancel() 91 | assert.ErrorIs(t, g.Wait(), context.Canceled) 92 | } 93 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # certinel [![pkg.go.dev][godev-badge]][godev] 2 | 3 | [godev-badge]: https://pkg.go.dev/badge/github.com/cloudflare/certinel.svg 4 | [godev]: https://pkg.go.dev/github.com/cloudflare/certinel 5 | 6 | Certinel is a Go library that makes it even easier to implement zero-hit 7 | TLS certificate changes by watching for certificate changes for you. The 8 | methods required by `tls.Config` are already implemented for you. 9 | 10 | | Package | Note | 11 | |------------------------|--------------------------------------------------------------------------------| 12 | | [fswatcher][fswatcher] | Filesystem watcher for standard filesystems on Linux, BSD, macOS, and Windows. | 13 | | [ticker][ticker] | Filesystem watcher that polls at a defined interval. | 14 | 15 | [fswatcher]: https://pkg.go.dev/github.com/cloudflare/certinel/fswatcher 16 | [ticker]: https://pkg.go.dev/github.com/cloudflare/certinel/ticker 17 | 18 | ## Usage 19 | 20 | Create the certinel instance, start it with `Start`, then pass the 21 | `GetCertificate` method to your `tls.Config` instance. 22 | 23 | ```go 24 | package main 25 | 26 | import ( 27 | "crypto/tls" 28 | "log" 29 | "net/http" 30 | 31 | "github.com/cloudflare/certinel/fswatcher" 32 | "github.com/oklog/run" 33 | ) 34 | 35 | func main() { 36 | ctx, cancel := context.WithCancel(context.Background()) 37 | 38 | certinel, err := fswatcher.New("/etc/ssl/app.pem", "/etc/ssl/app.key") 39 | if err != nil { 40 | log.Fatalf("fatal: unable to read server certificate. err='%s'", err) 41 | } 42 | 43 | g := run.Group{} 44 | { 45 | g.Add(func() error { 46 | return certinel.Start(ctx) 47 | }, func(err error) { 48 | cancel() 49 | }) 50 | } 51 | { 52 | ln, _ := tls.Listen("tcp", ":8000", &tls.Config{ 53 | GetCertificate: certinel.GetCertificate, 54 | }) 55 | g.Add(func() error { 56 | return http.Serve(ln, nil) 57 | }, func(err error) { 58 | ln.Close() 59 | }) 60 | } 61 | 62 | if err := g.Run(); err != nil { 63 | log.Fatalf("err='%s'", err) 64 | } 65 | } 66 | ``` 67 | 68 | ### Use Atomic Update Pattern 69 | 70 | If using a filesystem-based certinel with in an environment where both 71 | the keys and certificates are updated, care should be taken to update 72 | both atomically. This is automatically done in some environments, such 73 | as mounting a secret as a volume in Kubernetes. 74 | 75 | 1. Create the initial key and certificate in a hidden directory. 76 | (`./ssl/.first/app.pem` and `./ssl/.first/app.key`) 77 | 2. Create hidden symlink to this directory (`./ssl/..data` -> 78 | `./ssl/.first`) 79 | 3. Create visible files through the hidden symlink (`./ssl/app.pem` -> 80 | `./ssl/..data/app.pem`) 81 | 4. When updating the key and certificate, write them into a new hidden 82 | directory (`./ssl/.second/app.pem` and `./ssl/.second/app.key`) 83 | 5. Create a new hidden symlink to this new directory (`./ssl/..data_new` 84 | -> `./ssl/.second`) 85 | 6. Finally, move this new hidden symlink over the old one (`mv 86 | ./ssl/..data_new ./ssl/..data`) 87 | -------------------------------------------------------------------------------- /fswatcher/fswatcher_test.go: -------------------------------------------------------------------------------- 1 | package fswatcher 2 | 3 | import ( 4 | "context" 5 | "crypto/x509" 6 | "math/big" 7 | "os" 8 | "testing" 9 | "time" 10 | 11 | "github.com/cloudflare/certinel/internel/pkitest" 12 | "github.com/google/go-cmp/cmp" 13 | "golang.org/x/sync/errgroup" 14 | "gotest.tools/v3/assert" 15 | is "gotest.tools/v3/assert/cmp" 16 | "gotest.tools/v3/fs" 17 | "gotest.tools/v3/poll" 18 | ) 19 | 20 | func TestWatcher_Symlink(t *testing.T) { 21 | t.Parallel() 22 | 23 | ctx, cancel := context.WithCancel(context.Background()) 24 | 25 | pk := pkitest.NewPrivateKey(t) 26 | pemPK := pkitest.EncodePrivateKey(t, pk) 27 | 28 | dir := fs.NewDir(t, "test-symlink", 29 | fs.WithDir(".first", fs.WithFile("my.crt", "", fs.WithBytes( 30 | pkitest.PemEncodeCertificate(t, x509.Certificate{ 31 | SerialNumber: big.NewInt(1), 32 | }, pk), 33 | ))), 34 | fs.WithDir(".second", fs.WithFile("my.crt", "", fs.WithBytes( 35 | pkitest.PemEncodeCertificate(t, x509.Certificate{ 36 | SerialNumber: big.NewInt(2), 37 | }, pk), 38 | ))), 39 | fs.WithFile("my.key", "", fs.WithBytes(pemPK)), 40 | fs.WithSymlink("my.crt", ".first/my.crt"), 41 | ) 42 | defer dir.Remove() 43 | 44 | watcher, err := New(dir.Join("my.crt"), dir.Join("my.key")) 45 | assert.NilError(t, err) 46 | 47 | cert, err := watcher.GetCertificate(nil) 48 | assert.NilError(t, err) 49 | assert.DeepEqual(t, cert.Leaf.SerialNumber, big.NewInt(1), cmp.Comparer(pkitest.CmpBigInt)) 50 | 51 | g, gctx := errgroup.WithContext(ctx) 52 | g.Go(func() error { 53 | return watcher.Start(gctx) 54 | }) 55 | <-time.After(100 * time.Millisecond) 56 | 57 | fs.Apply(t, dir, fs.WithSymlink(".my.new.crt", ".second/my.crt")) 58 | err = os.Rename(dir.Join(".my.new.crt"), dir.Join("my.crt")) 59 | assert.NilError(t, err) 60 | 61 | poll.WaitOn(t, func(t poll.LogT) poll.Result { 62 | cert, err := watcher.GetCertificate(nil) 63 | if err != nil { 64 | return poll.Error(err) 65 | } 66 | 67 | return poll.Compare(is.DeepEqual(cert.Leaf.SerialNumber, big.NewInt(2), cmp.Comparer(pkitest.CmpBigInt))) 68 | }) 69 | 70 | cancel() 71 | assert.ErrorIs(t, g.Wait(), context.Canceled) 72 | } 73 | 74 | func TestWatcher_Replacement(t *testing.T) { 75 | t.Parallel() 76 | 77 | ctx, cancel := context.WithCancel(context.Background()) 78 | 79 | pk := pkitest.NewPrivateKey(t) 80 | pemPK := pkitest.EncodePrivateKey(t, pk) 81 | 82 | dir := fs.NewDir(t, "test-replacement", 83 | fs.WithFile("my.key", "", fs.WithBytes(pemPK)), 84 | fs.WithFile("my.crt", "", fs.WithBytes( 85 | pkitest.PemEncodeCertificate(t, x509.Certificate{ 86 | SerialNumber: big.NewInt(9999), 87 | }, pk), 88 | )), 89 | ) 90 | defer dir.Remove() 91 | 92 | watcher, err := New(dir.Join("my.crt"), dir.Join("my.key")) 93 | assert.NilError(t, err) 94 | 95 | cert, err := watcher.GetCertificate(nil) 96 | assert.NilError(t, err) 97 | assert.DeepEqual(t, cert.Leaf.SerialNumber, big.NewInt(9999), cmp.Comparer(pkitest.CmpBigInt)) 98 | 99 | g, gctx := errgroup.WithContext(ctx) 100 | g.Go(func() error { 101 | return watcher.Start(gctx) 102 | }) 103 | <-time.After(100 * time.Millisecond) 104 | 105 | fs.Apply(t, dir, fs.WithFile("cert.new", "", fs.WithBytes( 106 | pkitest.PemEncodeCertificate(t, x509.Certificate{ 107 | SerialNumber: big.NewInt(10000), 108 | }, pk), 109 | ))) 110 | err = os.Rename(dir.Join("cert.new"), dir.Join("my.crt")) 111 | assert.NilError(t, err) 112 | 113 | poll.WaitOn(t, func(t poll.LogT) poll.Result { 114 | cert, err := watcher.GetCertificate(nil) 115 | if err != nil { 116 | return poll.Error(err) 117 | } 118 | 119 | return poll.Compare(is.DeepEqual(cert.Leaf.SerialNumber, big.NewInt(10000), cmp.Comparer(pkitest.CmpBigInt))) 120 | }) 121 | 122 | cancel() 123 | assert.ErrorIs(t, g.Wait(), context.Canceled) 124 | } 125 | -------------------------------------------------------------------------------- /fswatcher/fswatcher.go: -------------------------------------------------------------------------------- 1 | // Package fswatcher implements the Certinel interface by watching for filesystem 2 | // change events using the cross-platform fsnotify package. 3 | // 4 | // This implementation watches the directory of the configured certificate to properly 5 | // notice replacements and symlink updates, this allows fswatcher to be used within 6 | // Kubernetes watching a certificate updated from a mounted ConfigMap or Secret. 7 | package fswatcher 8 | 9 | import ( 10 | "context" 11 | "crypto/tls" 12 | "crypto/x509" 13 | "fmt" 14 | "path/filepath" 15 | "sync/atomic" 16 | 17 | "github.com/fsnotify/fsnotify" 18 | ) 19 | 20 | // Sentinel watches for filesystem change events that effect the watched certificate. 21 | type Sentinel struct { 22 | certPath, keyPath string 23 | certificate atomic.Pointer[tls.Certificate] 24 | } 25 | 26 | const fsCreateOrWriteOpMask = fsnotify.Create | fsnotify.Write 27 | 28 | func New(cert, key string) (*Sentinel, error) { 29 | fsw := &Sentinel{ 30 | certPath: cert, 31 | keyPath: key, 32 | } 33 | 34 | if err := fsw.loadCertificate(); err != nil { 35 | return nil, fmt.Errorf("unable to load initial certificate: %w", err) 36 | } 37 | 38 | return fsw, nil 39 | } 40 | 41 | func (w *Sentinel) Start(ctx context.Context) error { 42 | watcher, err := fsnotify.NewWatcher() 43 | if err != nil { 44 | return fmt.Errorf("unable to create watcher: %w", err) 45 | } 46 | defer watcher.Close() 47 | 48 | certPath := filepath.Clean(w.certPath) 49 | certDir, _ := filepath.Split(certPath) 50 | realCertPath, _ := filepath.EvalSymlinks(certPath) 51 | 52 | if err := watcher.Add(certDir); err != nil { 53 | return fmt.Errorf("unable to create watcher: %w", err) 54 | } 55 | 56 | for { 57 | select { 58 | case <-ctx.Done(): 59 | return ctx.Err() 60 | case event := <-watcher.Events: 61 | // Portions of this case are inspired by spf13/viper's WatchConfig. 62 | // (c) 2014 Steve Francia. MIT Licensed. 63 | currentPath, err := filepath.EvalSymlinks(certPath) 64 | if err != nil { 65 | return err 66 | } 67 | 68 | switch { 69 | case eventCreatesOrWritesPath(event, certPath), symlinkModified(currentPath, realCertPath): 70 | realCertPath = currentPath 71 | 72 | if err := w.loadCertificate(); err != nil { 73 | return err 74 | } 75 | } 76 | case err := <-watcher.Errors: 77 | return err 78 | } 79 | } 80 | } 81 | 82 | func (w *Sentinel) loadCertificate() error { 83 | certificate, err := tls.LoadX509KeyPair(w.certPath, w.keyPath) 84 | if err != nil { 85 | return fmt.Errorf("unable to load certificate: %w", err) 86 | } 87 | 88 | leaf, err := x509.ParseCertificate(certificate.Certificate[0]) 89 | if err != nil { 90 | return fmt.Errorf("unable to load certificate: %w", err) 91 | } 92 | 93 | certificate.Leaf = leaf 94 | 95 | w.certificate.Store(&certificate) 96 | return nil 97 | } 98 | 99 | func (w *Sentinel) GetCertificate(chi *tls.ClientHelloInfo) (*tls.Certificate, error) { 100 | return w.certificate.Load(), nil 101 | } 102 | 103 | func (w *Sentinel) GetClientCertificate(cri *tls.CertificateRequestInfo) (*tls.Certificate, error) { 104 | cert := w.certificate.Load() 105 | if cert == nil { 106 | cert = &tls.Certificate{} 107 | } 108 | 109 | return cert, nil 110 | } 111 | 112 | // eventCreatesOrWritesPath predicate returns true for fsnotify.Create and fsnotify.Write 113 | // events that modify that specified path. 114 | func eventCreatesOrWritesPath(event fsnotify.Event, path string) bool { 115 | return filepath.Clean(event.Name) == path && event.Op&fsCreateOrWriteOpMask != 0 116 | } 117 | 118 | // symlinkModified predicate returns true when the current symlink path does 119 | // not match the previous resolved path. 120 | func symlinkModified(cur, prev string) bool { 121 | return cur != "" && cur != prev 122 | } 123 | --------------------------------------------------------------------------------