Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitattributes
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
* text=auto eol=lf
7 changes: 4 additions & 3 deletions .github/workflows/go.yml
Original file line number Diff line number Diff line change
Expand Up @@ -17,11 +17,12 @@ permissions:

jobs:
build:
name: Build Go ${{ matrix.go-version }}
runs-on: ubuntu-latest
name: Build Go ${{ matrix.go-version }} ${{ matrix.os }}
runs-on: ${{ matrix.os }}
strategy:
matrix:
go-version: [1.25.x]
os: [ubuntu-latest, macos-latest, windows-latest, ubuntu-24.04-arm]
go-version: [1.25.x, 1.26.x]
steps:
- name: Set up Go ${{ matrix.go-version }}
uses: actions/setup-go@v5
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/ldap.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ jobs:
LDAP_ADMIN_PASSWORD: "admin"
strategy:
matrix:
go-version: [1.25.x]
go-version: [1.26.x]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/vulncheck.yml
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ jobs:
runs-on: ubuntu-latest
strategy:
matrix:
go-version: [ 1.25.x ]
go-version: [ 1.26.x ]
steps:
- name: Check out code into the Go module directory
uses: actions/checkout@v4
Expand Down
2 changes: 1 addition & 1 deletion .golangci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ linters:
- examples$
formatters:
enable:
- gofmt
- gofumpt
- goimports
exclusions:
generated: lax
Expand Down
4 changes: 1 addition & 3 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -6,16 +6,14 @@ all: test

getdeps:
@mkdir -p ${GOPATH}/bin
@echo "Installing golangci-lint" && curl -sSfL https://raw.githubusercontent.com/golangci/golangci-lint/master/install.sh | sh -s -- -b $(GOPATH)/bin
@echo "Installing golangci-lint" && go install tool

lint: getdeps
@echo "Running $@ check"
@${GOPATH}/bin/golangci-lint cache clean
@${GOPATH}/bin/golangci-lint run --build-tags kqueue --timeout=10m --config ./.golangci.yml
Comment thread
klauspost marked this conversation as resolved.

lint-fix: getdeps
@echo "Running $@ check"
@${GOPATH}/bin/golangci-lint cache clean
@${GOPATH}/bin/golangci-lint run --build-tags kqueue --timeout=10m --config ./.golangci.yml --fix

test: lint
Expand Down
6 changes: 5 additions & 1 deletion certs/cert_pool_windows.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,11 @@ import (
func loadSystemRoots() (*x509.CertPool, error) {
const CRYPTENOTFOUND = 0x80092004

store, err := syscall.CertOpenSystemStore(0, syscall.StringToUTF16Ptr("ROOT"))
rootPtr, err := syscall.UTF16PtrFromString("ROOT")
if err != nil {
return nil, err
}
store, err := syscall.CertOpenSystemStore(0, rootPtr)
if err != nil {
return nil, err
}
Expand Down
24 changes: 11 additions & 13 deletions certs/certificate2.go
Original file line number Diff line number Diff line change
Expand Up @@ -203,19 +203,17 @@ func watchFile(ctx context.Context, path string, ch chan notify.EventInfo, wg *s
}
symLink := st.Mode()&os.ModeSymlink == os.ModeSymlink
if !symLink {
// Windows doesn't allow for watching file changes but instead allows
// for directory changes only, while we can still watch for changes
// on files on other platforms. For other platforms it's also better
// to watch the directory to catch all changes. Some updates are written
// to a new file and then renamed to the destination file. This method
// ensures we catch all such changes.
//
// Note: Certificate reloading relies on atomic file updates (write new
// file, then rename). If certificate files are updated in-place without
// atomicity, there is a window where partial/corrupted data may be read.
// The hash comparison will skip reloads when content hasn't changed, but
// does not protect against temporary inconsistency during partial writes.
return notify.Watch(filepath.Dir(path), ch, eventWrite...)
stop, err := watchDirSafe(filepath.Dir(path), path, ch, ctx.Done())
if err != nil {
return err
}
wg.Add(1)
go func() {
defer wg.Done()
<-ctx.Done()
stop()
}()
return nil
}

wg.Add(1)
Expand Down
14 changes: 12 additions & 2 deletions certs/certificate2_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import (
"os"
"path/filepath"
"reflect"
"runtime"
"testing"
"time"
)
Expand Down Expand Up @@ -89,6 +90,9 @@ func TestCertificate2_AutoReload(t *testing.T) {
}

func TestCertificate2_AutoReloadSymlink(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("symlinks require admin on Windows")
}
testCertificate2AutoReload(t, true)
}

Expand Down Expand Up @@ -177,6 +181,9 @@ func TestCertificate2_AutoReloadCertFileOnly(t *testing.T) {
}

func TestCertificate2_AutoReloadCertFileOnlySymlink(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("symlinks require admin on Windows")
}
testCertificate2AutoReloadCertFileOnly(t, true)
}

Expand Down Expand Up @@ -216,6 +223,9 @@ func TestCertificate2_InvalidReloadIgnored(t *testing.T) {
}

func TestCertificate2_InvalidReloadIgnoredSymlink(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("symlinks require admin on Windows")
}
testCertificate2InvalidReloadIgnored(t, true)
}

Expand Down Expand Up @@ -286,8 +296,8 @@ func overwriteFile(t *testing.T, src, dst string, symlink bool) {
func updateCertWithWait(t *testing.T, cert *Certificate2, symlink bool, update func()) {
done := make(chan struct{})
wait := time.Second
if symlink {
wait = wait + symlinkReloadInterval // can take up to symlinkReloadInterval to detect changes
if symlink || runtime.GOOS == "windows" {
wait = wait + symlinkReloadInterval
}
ctx, cancel := context.WithTimeout(context.Background(), wait)
defer cancel()
Expand Down
11 changes: 7 additions & 4 deletions certs/certs.go
Original file line number Diff line number Diff line change
Expand Up @@ -149,14 +149,17 @@ func (c *Certificate) Watch(ctx context.Context, interval time.Duration, signals
if !certFileSymLink && !keyFileSymLink && !isk8s {
go func() {
events := make(chan notify.EventInfo, 1)
if err := notify.Watch(filepath.Dir(c.certFile), events, eventWrite...); err != nil {
stop1, err := watchDirSafe(filepath.Dir(c.certFile), c.certFile, events, ctx.Done())
if err != nil {
return
}
if err := notify.Watch(filepath.Dir(c.keyFile), events, eventWrite...); err != nil {
notify.Stop(events)
stop2, err := watchDirSafe(filepath.Dir(c.keyFile), c.keyFile, events, ctx.Done())
if err != nil {
stop1()
return
}
defer notify.Stop(events)
defer stop1()
defer stop2()
for {
select {
case <-events:
Expand Down
10 changes: 8 additions & 2 deletions certs/certs_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import (
"io"
"os"
"reflect"
"runtime"
"testing"
"time"

Expand Down Expand Up @@ -102,8 +103,13 @@ func TestValidPairAfterWrite(t *testing.T) {
updateCerts("new-public.crt", "new-private.key")
defer updateCerts("original-public.crt", "original-private.key")

// Wait for the write event..
time.Sleep(200 * time.Millisecond)
// Wait for the write event. On Windows, file watching uses polling
// instead of fsnotify, so we need a longer wait.
wait := 200 * time.Millisecond
if runtime.GOOS == "windows" {
wait = 2 * time.Second
}
time.Sleep(wait)

hello := &tls.ClientHelloInfo{}
gcert, err := c.GetCertificate(hello)
Expand Down
37 changes: 37 additions & 0 deletions certs/event.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,9 @@
package certs

import (
"runtime"
"time"

"github.com/rjeczalik/notify"
)

Expand All @@ -30,3 +33,37 @@ func isWriteEvent(event notify.Event) bool {
}
return false
}

// watchDirSafe watches a directory for write events and sends notifications
// to ch. On Windows, rjeczalik/notify uses unsafe pointer casts that crash
// under Go's checkptr validation, so we fall back to polling with eventPath
// as the reported path in synthetic events.
func watchDirSafe(dir, eventPath string, ch chan notify.EventInfo, done <-chan struct{}) (stop func(), err error) {
if runtime.GOOS == "windows" {
quit := make(chan struct{})
exited := make(chan struct{})
go func() {
defer close(exited)
t := time.NewTicker(symlinkReloadInterval)
defer t.Stop()
for {
select {
case <-quit:
return
case <-done:
return
case <-t.C:
select {
case ch <- eventInfo{eventPath, notify.Write}:
default:
}
}
}
}()
return func() { close(quit); <-exited }, nil
}
if err := notify.Watch(dir, ch, eventWrite...); err != nil {
return nil, err
}
return func() { notify.Stop(ch) }, nil
}
2 changes: 1 addition & 1 deletion certs/event_others.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,4 +22,4 @@ package certs
import "github.com/rjeczalik/notify"

// eventWrite contains the notify events that will cause a write
var eventWrite = []notify.Event{notify.Create, notify.Write}
var eventWrite = []notify.Event{notify.Create, notify.Write, notify.Rename}
14 changes: 11 additions & 3 deletions certs/manager.go
Original file line number Diff line number Diff line change
Expand Up @@ -197,13 +197,21 @@ func (m *Manager) AddCertificate(certFile, keyFile string) (err error) {
// for simplicity.
events := make(chan notify.EventInfo, 1)

if err = notify.Watch(filepath.Dir(certFile), events, eventWrite...); err != nil {
stop1, err := watchDirSafe(filepath.Dir(certFile), certFile, events, m.done)
if err != nil {
return err
}
if err = notify.Watch(filepath.Dir(keyFile), events, eventWrite...); err != nil {
stop2, err := watchDirSafe(filepath.Dir(keyFile), keyFile, events, m.done)
if err != nil {
stop1()
return err
}
go m.watchFileEvents(p, events, m.reloader())
reload := m.reloader()
go func() {
defer stop1()
defer stop2()
m.watchFileEvents(p, events, reload)
}()
}
}
return nil
Expand Down
72 changes: 0 additions & 72 deletions certs/manager2_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,6 @@ package certs
import (
"context"
"crypto/tls"
"reflect"
"syscall"
"testing"
"time"
)
Expand Down Expand Up @@ -69,76 +67,6 @@ func TestManager2_CloseMultipleTimes(t *testing.T) {
mgr.Close()
}

func TestManager2_ReloadOnSIGHUP(t *testing.T) {
callCount := 0
loadCerts := func() ([]*Certificate2, error) {
certFile, keyFile := "public.crt", "private.key"
if callCount%2 == 1 {
certFile, keyFile = "new-public.crt", "new-private.key"
}
callCount++

cert, err := NewCertificate2(certFile, keyFile)
if err != nil {
return nil, err
}
return []*Certificate2{cert}, nil
}

mgr, err := NewManager2(loadCerts)
if err != nil {
t.Fatalf("Failed to create manager: %v", err)
}
defer mgr.Close()

originalCerts := mgr.certs.Load()
originalCert := (*originalCerts)[0].Load()

updateReloadWithWait(t, mgr, func() {
if err := syscall.Kill(syscall.Getpid(), syscall.SIGHUP); err != nil {
t.Fatalf("Failed to send SIGHUP: %v", err)
}
})

newCerts := mgr.certs.Load()
newCert := (*newCerts)[0].Load()

if reflect.DeepEqual(originalCert.Certificate, newCert.Certificate) {
t.Error("Expected certificates to be reloaded after SIGHUP")
}

expectedCert, err := tls.LoadX509KeyPair("new-public.crt", "new-private.key")
if err != nil {
t.Fatalf("Failed to load expected certificate: %v", err)
}

if !reflect.DeepEqual(newCert.Certificate, expectedCert.Certificate) {
t.Error("Reloaded certificate doesn't match expected certificate")
}
}

func updateReloadWithWait(t *testing.T, mgr *Manager2, update func()) {
done := make(chan struct{})
ctx, cancel := context.WithTimeout(context.Background(), time.Minute)
defer cancel()

unsub := mgr.Subscribe(func(c *Certificate2) {
if c == nil {
close(done)
}
})
defer unsub()

update()

select {
case <-done:
// expected result
case <-ctx.Done():
t.Error("Timeout waiting for certificate update")
}
}

// TestManager2_NoCertificates tests GetCertificate with no loaded certificates.
func TestManager2_NoCertificates(t *testing.T) {
loadCerts := func() ([]*Certificate2, error) {
Expand Down
Loading
Loading