Skip to content
Open
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
13 changes: 8 additions & 5 deletions backend/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import (
"github.com/openshift/faas-console-plugin/backend/handler"
"github.com/openshift/faas-console-plugin/backend/scm"
"github.com/openshift/faas-console-plugin/backend/scm/github"
"github.com/openshift/faas-console-plugin/backend/tlsreload"
)

const defaultCAPath = "/var/run/secrets/kubernetes.io/serviceaccount/ca.crt"
Expand Down Expand Up @@ -72,6 +73,12 @@ func main() {
_, certErr := os.Stat(*certFile)
_, keyErr := os.Stat(*keyFile)
if certErr == nil && keyErr == nil {
reloader, err := tlsreload.New(*certFile, *keyFile)
if err != nil {
log.Fatalf("Failed to load TLS certificate: %v", err)
}
go reloader.Run()

go func() {
ln, err := net.Listen("tcp", fmt.Sprintf(":%d", *httpPort))
if err != nil {
Expand All @@ -81,16 +88,12 @@ func main() {
log.Fatal(http.Serve(ln, muxHandler))
}()

cert, err := tls.LoadX509KeyPair(*certFile, *keyFile)
if err != nil {
log.Fatalf("Failed to load TLS certificate: %v", err)
}
ln, err := net.Listen("tcp", fmt.Sprintf(":%d", *httpsPort))
if err != nil {
log.Fatal(err)
}
tlsLn := tls.NewListener(ln, &tls.Config{
Certificates: []tls.Certificate{cert},
GetCertificate: reloader.GetCertificate,
})
log.Printf("Listening on https://%s", ln.Addr())
log.Fatal(http.Serve(tlsLn, muxHandler))
Expand Down
78 changes: 78 additions & 0 deletions backend/tlsreload/reloader.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
package tlsreload

import (
"crypto/sha256"
"crypto/tls"
"fmt"
"log/slog"
"os"
"sync/atomic"
"time"
)

const certificateReloadInterval = 5 * time.Minute

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This should be configurable, so that we don't need a new commit to change it.


type Reloader struct {
certFile string
keyFile string
current atomic.Pointer[tls.Certificate]
lastHash [sha256.Size]byte
}

func New(certFile, keyFile string) (*Reloader, error) {
reloader := &Reloader{certFile: certFile, keyFile: keyFile}
if err := reloader.reload(); err != nil {
return nil, err
}
return reloader, nil
}

func (r *Reloader) reload() error {
certPEM, err := os.ReadFile(r.certFile)
if err != nil {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This condition has no test.

return fmt.Errorf("read TLS certificate: %w", err)
}
keyPEM, err := os.ReadFile(r.keyFile)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This condition has no test.

if err != nil {
return fmt.Errorf("read TLS key: %w", err)
}

// Skip the parse and swap when the on-disk pair is unchanged.
digest := sha256.New()
digest.Write(certPEM)
digest.Write(keyPEM)
var hash [sha256.Size]byte
digest.Sum(hash[:0])
if r.current.Load() != nil && hash == r.lastHash {
return nil
}

cert, err := tls.X509KeyPair(certPEM, keyPEM)
if err != nil {
return fmt.Errorf("load TLS certificate: %w", err)

@pmeida pmeida Sep 3, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
return fmt.Errorf("load TLS certificate: %w", err)
return fmt.Errorf("parse TLS key pair %w", err)

Otherwise the log from main.go

		reloader, err := tlsreload.New(*certFile, *keyFile)
		if err != nil {
			log.Fatalf("Failed to load TLS certificate: %v", err)
		}

will result in an akward error message like Failed to load TLS certificate: load TLS certificate: ...

}
r.current.Store(&cert)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Shouldn't CompareAndSwap be used here? Or Swap? That would probably be "safer".

r.lastHash = hash

attrs := []any{"certFile", r.certFile}
if cert.Leaf != nil {
attrs = append(attrs, "notAfter", cert.Leaf.NotAfter)
}
slog.Info("loaded TLS certificate", attrs...)
return nil
}

func (r *Reloader) Run() {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We probably should have graceful shutdown here (and in main.go!). The change here is small, can you add it?

func (r *Reloader) Run(ctx context.Context) {
    ticker := time.NewTicker(certificateReloadInterval)
    defer ticker.Stop()

    for {
        select {
        case <-ctx.Done():
            return
        case <-ticker.C:
            if err := r.reload(); err != nil {
                slog.Error("failed to reload TLS certificate", "err", err)
            }
        }
    }
}

And for main.go -> pls create a ticket.

ticker := time.NewTicker(certificateReloadInterval)
defer ticker.Stop()

for range ticker.C {
if err := r.reload(); err != nil {
slog.Error("failed to reload TLS certificate", "err", err)
}
}
}

func (r *Reloader) GetCertificate(_ *tls.ClientHelloInfo) (*tls.Certificate, error) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a public method and Reloader can be initialized without using New -> so this can break.

I'd add correct error handling here.

And when this methods gets some logic it'll need test coverage.

return r.current.Load(), nil
}
100 changes: 100 additions & 0 deletions backend/tlsreload/reloader_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
package tlsreload

import (
"crypto/rand"
"crypto/rsa"
"crypto/tls"
"crypto/x509"
"crypto/x509/pkix"
"encoding/pem"
"math/big"
"os"
"path/filepath"
"time"

. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)

var _ = Describe("Reloader", func() {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pls test the package using it's public api, not internal methods.

It("uses the certificate loaded after a rotation", func() {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You can add a test case for when the certs were deleted and are missing.

dir := GinkgoT().TempDir()
certFile := filepath.Join(dir, "tls.crt")
keyFile := filepath.Join(dir, "tls.key")

writeCertificatePair(certFile, keyFile, "old.example.com")
reloader, err := New(certFile, keyFile)
Expect(err).NotTo(HaveOccurred())

writeCertificatePair(certFile, keyFile, "new.example.com")
Expect(reloader.reload()).To(Succeed())

cert, err := reloader.GetCertificate(nil)
Expect(err).NotTo(HaveOccurred())
Expect(certificateCommonName(cert)).To(Equal("new.example.com"))
})

It("keeps the current certificate when the replacement is invalid", func() {
dir := GinkgoT().TempDir()
certFile := filepath.Join(dir, "tls.crt")
keyFile := filepath.Join(dir, "tls.key")

writeCertificatePair(certFile, keyFile, "current.example.com")
reloader, err := New(certFile, keyFile)
Expect(err).NotTo(HaveOccurred())

Expect(os.WriteFile(certFile, []byte("invalid certificate"), 0600)).To(Succeed())
Expect(reloader.reload()).To(MatchError(ContainSubstring("load TLS certificate")))

cert, err := reloader.GetCertificate(nil)
Expect(err).NotTo(HaveOccurred())
Expect(certificateCommonName(cert)).To(Equal("current.example.com"))
})

It("skips reparsing when the certificate pair is unchanged", func() {
dir := GinkgoT().TempDir()
certFile := filepath.Join(dir, "tls.crt")
keyFile := filepath.Join(dir, "tls.key")

writeCertificatePair(certFile, keyFile, "stable.example.com")
reloader, err := New(certFile, keyFile)
Expect(err).NotTo(HaveOccurred())

before, err := reloader.GetCertificate(nil)
Expect(err).NotTo(HaveOccurred())

Expect(reloader.reload()).To(Succeed())

after, err := reloader.GetCertificate(nil)
Expect(err).NotTo(HaveOccurred())
Expect(after).To(BeIdenticalTo(before))
})
})

func writeCertificatePair(certFile, keyFile, commonName string) {
key, err := rsa.GenerateKey(rand.Reader, 2048)
Expect(err).NotTo(HaveOccurred())

template := &x509.Certificate{
SerialNumber: big.NewInt(1),
Subject: pkix.Name{CommonName: commonName},
DNSNames: []string{commonName},
NotBefore: time.Now().Add(-time.Minute),
NotAfter: time.Now().Add(time.Hour),
KeyUsage: x509.KeyUsageKeyEncipherment | x509.KeyUsageDigitalSignature,
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
}
der, err := x509.CreateCertificate(rand.Reader, template, template, &key.PublicKey, key)
Expect(err).NotTo(HaveOccurred())

certPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der})
keyPEM := pem.EncodeToMemory(&pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(key)})
Expect(os.WriteFile(certFile, certPEM, 0600)).To(Succeed())
Expect(os.WriteFile(keyFile, keyPEM, 0600)).To(Succeed())
}

func certificateCommonName(cert *tls.Certificate) string {
parsed, err := x509.ParseCertificate(cert.Certificate[0])
Expect(err).NotTo(HaveOccurred())
return parsed.Subject.CommonName
}
13 changes: 13 additions & 0 deletions backend/tlsreload/suite_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
package tlsreload

import (
"testing"

. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)

func TestTLSReload(t *testing.T) {
RegisterFailHandler(Fail)
RunSpecs(t, "TLS Reload Suite")
}
4 changes: 4 additions & 0 deletions docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,7 @@ Go + `net/http` standard library. Key dependencies:
| `scm` | SCM abstraction types (`Platform`, `Registry`, `Client`) and filesystem helpers |
| `scm/github` | go-github implementation of `scm.Client` |
| `config` | Package-level wiring vars (`SCMRegistry`, constants) |
| `tlsreload` | Periodic reload of the serving cert/key from disk, swapping an atomic `*tls.Certificate` via `GetCertificate` so rotated certs are served without a restart |

### Dependency Rules

Expand Down Expand Up @@ -145,6 +146,9 @@ Everything that wraps `knative.dev/func` lives in this one package, so there is
**External API URL resolved at Helm install time**
The URL embedded in generated kubeconfigs (`externalAPIServerURL`) comes from the Infrastructure CR (`config.openshift.io/v1/Infrastructure/cluster`) via Helm `lookup` at install time, injected as `--external-api-server-url`. It is not fetched at runtime. This eliminates the need for a `ClusterRole` to query the Infrastructure CR from within the pod.

**TLS serving certificate reloaded at runtime by stdlib polling**
The OCP service CA operator rotates the serving cert/key automatically. `tlsreload.Reloader` polls the mounted pair every 5 minutes and atomically swaps the cached `*tls.Certificate` served via `tls.Config.GetCertificate`, so a rotation is picked up without restarting the pod. Stdlib polling (Option B from SRVOCF-1002) was chosen over `k8s.io/apiserver/dynamiccertificates` to avoid pulling in its large transitive dependency tree, and is robust to the atomic `..data` symlink swap Kubernetes uses for mounted secrets (which naive inotify file watches miss). Reloads are content-hashed to skip re-parsing when the pair is unchanged, and the last valid pair is retained when an update is incomplete or invalid.

**SCM is abstracted behind a registry**
`scm.Registry` maps `scm.Platform` → `scm.ClientFactory`. The active registry lives at `config.SCMRegistry`, a package-level var that tests swap out via `withSCMMock`. Handlers never reference a concrete SCM client type. The platform is currently resolved statically (`scm.DefaultPlatform = GitHub`), but the registry is designed to support dynamic platform selection — the handler can later derive the platform from the request body or header without changes to the registry or client implementations.

Expand Down