OCSP stapling

This commit is contained in:
Alessandro Chitolina 2025-06-06 17:44:04 +02:00 committed by GitHub
parent 2949995abc
commit b39ee8ede5
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
30 changed files with 1576 additions and 178 deletions

View File

@ -182,7 +182,9 @@ func setupServer(staticConfiguration *static.Configuration) (*server.Server, err
// ACME // ACME
tlsManager := traefiktls.NewManager() tlsManager := traefiktls.NewManager(staticConfiguration.OCSP)
routinesPool.GoCtx(tlsManager.Run)
httpChallengeProvider := acme.NewChallengeHTTP() httpChallengeProvider := acme.NewChallengeHTTP()
tlsChallengeProvider := acme.NewChallengeTLSALPN() tlsChallengeProvider := acme.NewChallengeTLSALPN()

View File

@ -0,0 +1,71 @@
---
title: "Traefik OCSP Documentation"
description: "Learn how to configure Traefik to use OCSP. Read the technical documentation."
---
# OCSP
Check certificate status and perform OCSP stapling.
{: .subtitle }
## Overview
### OCSP Stapling
When OCSP is enabled, Traefik checks the status of every certificate in the store that provides an OCSP responder URL,
including the default certificate, and staples the OCSP response to the TLS handshake.
The OCSP check is performed when the certificate is loaded,
and once every hour until it is successful at the halfway point before the update date.
### Caching
Traefik caches the OCSP response as long as the associated certificate is provided by the configuration.
When a certificate is no longer provided,
the OCSP response has a 24 hour TTL waiting to be provided again or eventually removed.
The OCSP response is cached in memory and is not persisted between Traefik restarts.
## Configuration
### General
Enabling OCSP is part of the [static configuration](../getting-started/configuration-overview.md#the-static-configuration).
It can be defined by using a file (YAML or TOML) or CLI arguments:
```yaml tab="File (YAML)"
## Static configuration
ocsp: {}
```
```toml tab="File (TOML)"
## Static configuration
[ocsp]
```
```bash tab="CLI"
## Static configuration
--ocsp=true
```
### Responder Overrides
The `responderOverrides` option defines the OCSP responder URLs to use instead of the one provided by the certificate.
This is useful when you want to use a different OCSP responder.
```yaml tab="File (YAML)"
## Static configuration
ocsp:
responderOverrides:
foo: bar
```
```toml tab="File (TOML)"
## Static configuration
[ocsp]
[ocsp.responderOverrides]
foo = "bar"
```
```bash tab="CLI"
## Static configuration
-ocsp.responderoverrides.foo=bar
```

View File

@ -0,0 +1,71 @@
---
title: "Traefik OCSP Documentation"
description: "Learn how to configure Traefik to use OCSP. Read the technical documentation."
---
# OCSP
Check certificate status and perform OCSP stapling.
{: .subtitle }
## Overview
### OCSP Stapling
When OCSP is enabled, Traefik checks the status of every certificate in the store that provides an OCSP responder URL,
including the default certificate, and staples the OCSP response to the TLS handshake.
The OCSP check is performed when the certificate is loaded,
and once every hour until it is successful at the halfway point before the update date.
### Caching
Traefik caches the OCSP response as long as the associated certificate is provided by the configuration.
When a certificate is no longer provided,
the OCSP response has a 24 hour TTL waiting to be provided again or eventually removed.
The OCSP response is cached in memory and is not persisted between Traefik restarts.
## Configuration
### General
Enabling OCSP is part of the [static configuration](../getting-started/configuration-overview.md#the-static-configuration).
It can be defined by using a file (YAML or TOML) or CLI arguments:
```yaml tab="File (YAML)"
## Static configuration
ocsp: {}
```
```toml tab="File (TOML)"
## Static configuration
[ocsp]
```
```bash tab="CLI"
## Static configuration
--ocsp=true
```
### Responder Overrides
The `responderOverrides` option defines the OCSP responder URLs to use instead of the one provided by the certificate.
This is useful when you want to use a different OCSP responder.
```yaml tab="File (YAML)"
## Static configuration
ocsp:
responderOverrides:
foo: bar
```
```toml tab="File (TOML)"
## Static configuration
[ocsp]
[ocsp.responderOverrides]
foo = "bar"
```
```bash tab="CLI"
## Static configuration
-ocsp.responderoverrides.foo=bar
```

View File

@ -642,6 +642,12 @@ Prefix to use for metrics collection. (Default: ```traefik```)
`--metrics.statsd.pushinterval`: `--metrics.statsd.pushinterval`:
StatsD push interval. (Default: ```10```) StatsD push interval. (Default: ```10```)
`--ocsp`:
OCSP configuration. (Default: ```false```)
`--ocsp.responderoverrides.<name>`:
Defines a map of OCSP responders to replace for querying OCSP servers.
`--ping`: `--ping`:
Enable ping. (Default: ```false```) Enable ping. (Default: ```false```)

View File

@ -642,6 +642,12 @@ Prefix to use for metrics collection. (Default: ```traefik```)
`TRAEFIK_METRICS_STATSD_PUSHINTERVAL`: `TRAEFIK_METRICS_STATSD_PUSHINTERVAL`:
StatsD push interval. (Default: ```10```) StatsD push interval. (Default: ```10```)
`TRAEFIK_OCSP`:
OCSP configuration. (Default: ```false```)
`TRAEFIK_OCSP_RESPONDEROVERRIDES_<NAME>`:
Defines a map of OCSP responders to replace for querying OCSP servers.
`TRAEFIK_PING`: `TRAEFIK_PING`:
Enable ping. (Default: ```false```) Enable ping. (Default: ```false```)

View File

@ -609,3 +609,8 @@
[spiffe] [spiffe]
workloadAPIAddr = "foobar" workloadAPIAddr = "foobar"
[ocsp]
[ocsp.responderOverrides]
name0 = "foobar"
name1 = "foobar"

View File

@ -675,3 +675,7 @@ core:
defaultRuleSyntax: foobar defaultRuleSyntax: foobar
spiffe: spiffe:
workloadAPIAddr: foobar workloadAPIAddr: foobar
ocsp:
responderOverrides:
name0: foobar
name1: foobar

View File

@ -110,6 +110,7 @@ nav:
- 'Let''s Encrypt': 'https/acme.md' - 'Let''s Encrypt': 'https/acme.md'
- 'Tailscale': 'https/tailscale.md' - 'Tailscale': 'https/tailscale.md'
- 'SPIFFE': 'https/spiffe.md' - 'SPIFFE': 'https/spiffe.md'
- 'OCSP': 'https/ocsp.md'
- 'Middlewares': - 'Middlewares':
- 'Overview': 'middlewares/overview.md' - 'Overview': 'middlewares/overview.md'
- 'HTTP': - 'HTTP':
@ -227,6 +228,7 @@ nav:
- "ACME" : 'reference/install-configuration/tls/certificate-resolvers/acme.md' - "ACME" : 'reference/install-configuration/tls/certificate-resolvers/acme.md'
- "Tailscale" : 'reference/install-configuration/tls/certificate-resolvers/tailscale.md' - "Tailscale" : 'reference/install-configuration/tls/certificate-resolvers/tailscale.md'
- "SPIFFE" : 'reference/install-configuration/tls/spiffe.md' - "SPIFFE" : 'reference/install-configuration/tls/spiffe.md'
- "OCSP" : 'reference/install-configuration/tls/ocsp.md'
- 'Observability': - 'Observability':
- 'Metrics' : 'reference/install-configuration/observability/metrics.md' - 'Metrics' : 'reference/install-configuration/observability/metrics.md'
- 'Tracing': 'reference/install-configuration/observability/tracing.md' - 'Tracing': 'reference/install-configuration/observability/tracing.md'

2
go.mod
View File

@ -95,6 +95,7 @@ require (
go.opentelemetry.io/otel/sdk/log v0.8.0 go.opentelemetry.io/otel/sdk/log v0.8.0
go.opentelemetry.io/otel/sdk/metric v1.34.0 go.opentelemetry.io/otel/sdk/metric v1.34.0
go.opentelemetry.io/otel/trace v1.34.0 go.opentelemetry.io/otel/trace v1.34.0
golang.org/x/crypto v0.37.0
golang.org/x/mod v0.23.0 golang.org/x/mod v0.23.0
golang.org/x/net v0.39.0 golang.org/x/net v0.39.0
golang.org/x/sync v0.13.0 golang.org/x/sync v0.13.0
@ -366,7 +367,6 @@ require (
go.uber.org/ratelimit v0.3.0 // indirect go.uber.org/ratelimit v0.3.0 // indirect
go.uber.org/zap v1.27.0 // indirect go.uber.org/zap v1.27.0 // indirect
golang.org/x/arch v0.4.0 // indirect golang.org/x/arch v0.4.0 // indirect
golang.org/x/crypto v0.37.0 // indirect
golang.org/x/exp v0.0.0-20241210194714-1829a127f884 // indirect golang.org/x/exp v0.0.0-20241210194714-1829a127f884 // indirect
golang.org/x/oauth2 v0.28.0 // indirect golang.org/x/oauth2 v0.28.0 // indirect
golang.org/x/term v0.31.0 // indirect golang.org/x/term v0.31.0 // indirect

View File

@ -0,0 +1,11 @@
-----BEGIN CERTIFICATE-----
MIIBhjCCASygAwIBAgIBATAKBggqhkjOPQQDAjASMRAwDgYDVQQDEwdUZXN0IENB
MB4XDTI1MDQyNDEzNTIzOFoXDTM1MDQyMjEzNTIzOFowEjEQMA4GA1UEAxMHVGVz
dCBDQTBZMBMGByqGSM49AgEGCCqGSM49AwEHA0IABPp8MoNUBbUxp3jW6FcDH+lg
Zft1SIpnGjkMVjLSbW9EzmRQ/oMRHQqJvE7wJbwDs/JUTigRtfZL0vOojnhHcPej
czBxMA4GA1UdDwEB/wQEAwIBBjASBgNVHRMBAf8ECDAGAQH/AgEBMB0GA1UdDgQW
BBQXRlWLK295lmDy+931a4Ha8XVNNjAsBggrBgEFBQcBAQQgMB4wHAYIKwYBBQUH
MAGGEG9jc3AuZXhhbXBsZS5jb20wCgYIKoZIzj0EAwIDSAAwRQIgYH6lnce9jxcp
YIVhY4z55rnOKXqaI/5rUQKwjJ3dRsUCIQDThtkFgOPT/67xOYCTCEVSMSTwh2Gq
jbeucU+4c/InVg==
-----END CERTIFICATE-----

View File

@ -0,0 +1,5 @@
-----BEGIN EC PRIVATE KEY-----
MHcCAQEEIGV6FPfHeA42xfjVtpnyATG6tKCCu0QoY0OlBR/0xn2toAoGCCqGSM49
AwEHoUQDQgAE+nwyg1QFtTGneNboVwMf6WBl+3VIimcaOQxWMtJtb0TOZFD+gxEd
Com8TvAlvAOz8lROKBG19kvS86iOeEdw9w==
-----END EC PRIVATE KEY-----

View File

@ -0,0 +1,11 @@
-----BEGIN CERTIFICATE-----
MIIBjzCCATWgAwIBAgIIGDlFgswljYAwCgYIKoZIzj0EAwIwEjEQMA4GA1UEAxMH
VGVzdCBDQTAeFw0yNTA0MjQxMzUyMzhaFw0yNjA0MjQxMzUyMzhaMBgxFjAUBgNV
BAMTDWRlZmF1bHQubG9jYWwwWTATBgcqhkjOPQIBBggqhkjOPQMBBwNCAAQGABZ/
zezTMQBwmmw3aifU0OkDQ4ZzxGG7dR93svJPgYnP7TpBVtPrxy0WgVZbbCHv0Srl
PlpO9rFkKf3D4E6Qo28wbTAOBgNVHQ8BAf8EBAMCBaAwDAYDVR0TAQH/BAIwADAf
BgNVHSMEGDAWgBQXRlWLK295lmDy+931a4Ha8XVNNjAsBggrBgEFBQcBAQQgMB4w
HAYIKwYBBQUHMAGGEG9jc3AuZXhhbXBsZS5jb20wCgYIKoZIzj0EAwIDSAAwRQIh
AJMF7RkU0BtNZlHf//PPgpPfDJybnYMIoX1Ek4I8JZ+QAiBpxjzeFE9jwqcJnx5X
KnOJMbgfvJliZZgVSuXBbulzAA==
-----END CERTIFICATE-----

View File

@ -0,0 +1,5 @@
-----BEGIN EC PRIVATE KEY-----
MHcCAQEEIO4UluA82wXVkaVH0m6oFGWyC8mzVcc7H9MI0ltXgkNuoAoGCCqGSM49
AwEHoUQDQgAEBgAWf83s0zEAcJpsN2on1NDpA0OGc8Rhu3Ufd7LyT4GJz+06QVbT
68ctFoFWW2wh79Eq5T5aTvaxZCn9w+BOkA==
-----END EC PRIVATE KEY-----

View File

@ -0,0 +1,100 @@
package main
import (
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/x509"
"crypto/x509/pkix"
"encoding/pem"
"math/big"
"os"
"time"
)
func main() {
// generate CA
caKey, caCert := generateCA("Test CA")
saveKeyAndCert("integration/fixtures/ocsp/ca.key", "integration/fixtures/ocsp/ca.crt", caKey, caCert)
// server certificate
serverKey, serverCert := generateCert("server.local", caKey, caCert)
saveKeyAndCert("integration/fixtures/ocsp/server.key", "integration/fixtures/ocsp/server.crt", serverKey, serverCert)
// default certificate
defaultKey, defaultCert := generateCert("default.local", caKey, caCert)
saveKeyAndCert("integration/fixtures/ocsp/default.key", "integration/fixtures/ocsp/default.crt", defaultKey, defaultCert)
}
func generateCA(commonName string) (*ecdsa.PrivateKey, *x509.Certificate) {
// generate a private key for the CA
caKey, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
// create a self-signed CA certificate
caTemplate := &x509.Certificate{
SerialNumber: big.NewInt(1),
Subject: pkix.Name{
CommonName: commonName,
},
NotBefore: time.Now(),
NotAfter: time.Now().Add(10 * 365 * 24 * time.Hour), // 10 ans
KeyUsage: x509.KeyUsageCertSign | x509.KeyUsageCRLSign,
BasicConstraintsValid: true,
IsCA: true,
MaxPathLen: 1,
OCSPServer: []string{"ocsp.example.com"},
}
caCertDER, _ := x509.CreateCertificate(rand.Reader, caTemplate, caTemplate, &caKey.PublicKey, caKey)
caCert, _ := x509.ParseCertificate(caCertDER)
return caKey, caCert
}
func generateCert(commonName string, caKey *ecdsa.PrivateKey, caCert *x509.Certificate) (*ecdsa.PrivateKey, *x509.Certificate) {
// create a private key for the certificate
certKey, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
// create a certificate signed by the CA
certTemplate := &x509.Certificate{
SerialNumber: big.NewInt(time.Now().UnixNano()),
Subject: pkix.Name{
CommonName: commonName,
},
NotBefore: time.Now(),
NotAfter: time.Now().Add(1 * 365 * 24 * time.Hour), // 1 an
KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment,
BasicConstraintsValid: true,
OCSPServer: []string{"ocsp.example.com"},
}
certDER, _ := x509.CreateCertificate(rand.Reader, certTemplate, caCert, &certKey.PublicKey, caKey)
cert, _ := x509.ParseCertificate(certDER)
return certKey, cert
}
func saveKeyAndCert(keyFile, certFile string, key *ecdsa.PrivateKey, cert *x509.Certificate) {
// save the private key
keyOut, _ := os.Create(keyFile)
defer keyOut.Close()
// Marshal the private key to ASN.1 DER format
privateKey, err := x509.MarshalECPrivateKey(key)
if err != nil {
panic(err)
}
err = pem.Encode(keyOut, &pem.Block{Type: "EC PRIVATE KEY", Bytes: privateKey})
if err != nil {
panic(err)
}
// save the certificate
certOut, _ := os.Create(certFile)
defer certOut.Close()
err = pem.Encode(certOut, &pem.Block{Type: "CERTIFICATE", Bytes: cert.Raw})
if err != nil {
panic(err)
}
}

View File

@ -0,0 +1,11 @@
-----BEGIN CERTIFICATE-----
MIIBjjCCATSgAwIBAgIIGDlFgswgB3AwCgYIKoZIzj0EAwIwEjEQMA4GA1UEAxMH
VGVzdCBDQTAeFw0yNTA0MjQxMzUyMzhaFw0yNjA0MjQxMzUyMzhaMBcxFTATBgNV
BAMTDHNlcnZlci5sb2NhbDBZMBMGByqGSM49AgEGCCqGSM49AwEHA0IABHpjZoVk
Qh15gTa26KMJfvzfVgGHGicUDg1UYppKAMY83rxSXqRHcVFAFRqWDTgCQRy6hPq+
6p5OwBziC2X/SOejbzBtMA4GA1UdDwEB/wQEAwIFoDAMBgNVHRMBAf8EAjAAMB8G
A1UdIwQYMBaAFBdGVYsrb3mWYPL73fVrgdrxdU02MCwGCCsGAQUFBwEBBCAwHjAc
BggrBgEFBQcwAYYQb2NzcC5leGFtcGxlLmNvbTAKBggqhkjOPQQDAgNIADBFAiEA
mp5LQixMUFh5h8yF1EtFsi4MKrO+dzD68TqIhq1rKjUCIEbB++M8qO4gtqjv8d06
AzSLTEfgNCmM574JI46YAKVx
-----END CERTIFICATE-----

View File

@ -0,0 +1,5 @@
-----BEGIN EC PRIVATE KEY-----
MHcCAQEEIFpVKKKxvw6cZe7hwRLHgXIsWiJYUQ66PKzO6iXINUH0oAoGCCqGSM49
AwEHoUQDQgAEemNmhWRCHXmBNrboowl+/N9WAYcaJxQODVRimkoAxjzevFJepEdx
UUAVGpYNOAJBHLqE+r7qnk7AHOILZf9I5w==
-----END EC PRIVATE KEY-----

View File

@ -0,0 +1,27 @@
[global]
checkNewVersion = false
sendAnonymousUsage = false
[entryPoints]
[entryPoints.web]
address = ":8000"
[providers.file]
filename = "{{ .SelfFilename }}"
[ocsp.responderOverrides]
ocsp.example.com = "{{ .ResponderURL }}"
[log]
level="debug"
## dynamic configuration ##
[[tls.certificates]]
certFile = "fixtures/ocsp/server.crt"
keyFile = "fixtures/ocsp/server.key"
[tls.stores]
[tls.stores.default.defaultCertificate]
certFile = "fixtures/ocsp/default.crt"
keyFile = "fixtures/ocsp/default.key"

View File

@ -3,7 +3,9 @@ package integration
import ( import (
"bufio" "bufio"
"bytes" "bytes"
"crypto"
"crypto/rand" "crypto/rand"
"crypto/tls"
"encoding/json" "encoding/json"
"fmt" "fmt"
"io" "io"
@ -24,6 +26,7 @@ import (
"github.com/stretchr/testify/suite" "github.com/stretchr/testify/suite"
"github.com/traefik/traefik/v3/integration/try" "github.com/traefik/traefik/v3/integration/try"
"github.com/traefik/traefik/v3/pkg/config/dynamic" "github.com/traefik/traefik/v3/pkg/config/dynamic"
"golang.org/x/crypto/ocsp"
) )
// SimpleSuite tests suite. // SimpleSuite tests suite.
@ -1598,6 +1601,132 @@ func (s *SimpleSuite) TestMaxHeaderBytes() {
} }
} }
func (s *SimpleSuite) TestSimpleOCSP() {
defaultCert, err := tls.LoadX509KeyPair("fixtures/ocsp/default.crt", "fixtures/ocsp/default.key")
require.NoError(s.T(), err)
serverCert, err := tls.LoadX509KeyPair("fixtures/ocsp/server.crt", "fixtures/ocsp/server.key")
require.NoError(s.T(), err)
defaultOCSPResponseTmpl := ocsp.Response{
SerialNumber: defaultCert.Leaf.SerialNumber,
Status: ocsp.Good,
ThisUpdate: defaultCert.Leaf.NotBefore,
NextUpdate: defaultCert.Leaf.NotAfter,
}
defaultOCSPResponse, err := ocsp.CreateResponse(defaultCert.Leaf, defaultCert.Leaf, defaultOCSPResponseTmpl, defaultCert.PrivateKey.(crypto.Signer))
require.NoError(s.T(), err)
serverOCSPResponseTmpl := ocsp.Response{
SerialNumber: serverCert.Leaf.SerialNumber,
Status: ocsp.Good,
ThisUpdate: serverCert.Leaf.NotBefore,
NextUpdate: serverCert.Leaf.NotAfter,
}
serverOCSPResponse, err := ocsp.CreateResponse(serverCert.Leaf, serverCert.Leaf, serverOCSPResponseTmpl, serverCert.PrivateKey.(crypto.Signer))
require.NoError(s.T(), err)
responderCalled := make(chan struct{})
responder := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) {
ct := req.Header.Get("Content-Type")
assert.Equal(s.T(), "application/ocsp-request", ct)
reqBytes, err := io.ReadAll(req.Body)
require.NoError(s.T(), err)
ocspReq, err := ocsp.ParseRequest(reqBytes)
require.NoError(s.T(), err)
var ocspResponse []byte
switch ocspReq.SerialNumber.String() {
case defaultCert.Leaf.SerialNumber.String():
ocspResponse = defaultOCSPResponse
case serverCert.Leaf.SerialNumber.String():
ocspResponse = serverOCSPResponse
default:
s.T().Fatalf("Unexpected OCSP request for serial number: %s", ocspReq.SerialNumber)
}
rw.Header().Set("Content-Type", "application/ocsp-response")
_, err = rw.Write(ocspResponse)
require.NoError(s.T(), err)
responderCalled <- struct{}{}
}))
s.T().Cleanup(responder.Close)
file := s.adaptFile("fixtures/ocsp/simple.toml", struct {
ResponderURL string
}{responder.URL})
s.traefikCmd(withConfigFile(file))
select {
case <-responderCalled:
case <-time.After(5 * time.Second):
s.T().Fatal("OCSP responder was not called")
}
select {
case <-responderCalled:
case <-time.After(5 * time.Second):
s.T().Fatal("OCSP responder was not called")
}
// Check that the response is stapled.
// Create a TLS client configuration that checks for OCSP stapling for the default cert.
var verifyCallCount int
clientConfig := &tls.Config{
InsecureSkipVerify: true,
ServerName: "unknown",
VerifyConnection: func(state tls.ConnectionState) error {
s.T().Helper()
verifyCallCount++
assert.Equal(s.T(), "default.local", state.PeerCertificates[0].Subject.CommonName)
assert.Equal(s.T(), defaultOCSPResponse, state.OCSPResponse)
return nil
},
}
// Connect to the server and verify OCSP stapling.
conn, err := tls.DialWithDialer(&net.Dialer{Timeout: 5 * time.Second}, "tcp", "127.0.0.1:8000", clientConfig)
require.NoError(s.T(), err)
s.T().Cleanup(func() {
_ = conn.Close()
})
assert.Equal(s.T(), 1, verifyCallCount)
// Create a TLS client configuration that checks for OCSP stapling for a cert in the store.
verifyCallCount = 0
clientConfig = &tls.Config{
InsecureSkipVerify: true,
ServerName: "server.local",
VerifyConnection: func(state tls.ConnectionState) error {
s.T().Helper()
verifyCallCount++
assert.Equal(s.T(), "server.local", state.PeerCertificates[0].Subject.CommonName)
assert.Equal(s.T(), serverOCSPResponse, state.OCSPResponse)
return nil
},
}
// Connect to the server and verify OCSP stapling.
conn, err = tls.DialWithDialer(&net.Dialer{Timeout: 5 * time.Second}, "tcp", "127.0.0.1:8000", clientConfig)
require.NoError(s.T(), err)
s.T().Cleanup(func() {
_ = conn.Close()
})
assert.Equal(s.T(), 1, verifyCallCount)
}
func (s *SimpleSuite) TestSanitizePath() { func (s *SimpleSuite) TestSanitizePath() {
s.createComposeProject("base") s.createComposeProject("base")

View File

@ -28,6 +28,7 @@ import (
"github.com/traefik/traefik/v3/pkg/provider/kv/zk" "github.com/traefik/traefik/v3/pkg/provider/kv/zk"
"github.com/traefik/traefik/v3/pkg/provider/nomad" "github.com/traefik/traefik/v3/pkg/provider/nomad"
"github.com/traefik/traefik/v3/pkg/provider/rest" "github.com/traefik/traefik/v3/pkg/provider/rest"
"github.com/traefik/traefik/v3/pkg/tls"
"github.com/traefik/traefik/v3/pkg/types" "github.com/traefik/traefik/v3/pkg/types"
) )
@ -80,6 +81,8 @@ type Configuration struct {
Core *Core `description:"Core controls." json:"core,omitempty" toml:"core,omitempty" yaml:"core,omitempty" export:"true"` Core *Core `description:"Core controls." json:"core,omitempty" toml:"core,omitempty" yaml:"core,omitempty" export:"true"`
Spiffe *SpiffeClientConfig `description:"SPIFFE integration configuration." json:"spiffe,omitempty" toml:"spiffe,omitempty" yaml:"spiffe,omitempty" export:"true"` Spiffe *SpiffeClientConfig `description:"SPIFFE integration configuration." json:"spiffe,omitempty" toml:"spiffe,omitempty" yaml:"spiffe,omitempty" export:"true"`
OCSP *tls.OCSPConfig `description:"OCSP configuration." json:"ocsp,omitempty" toml:"ocsp,omitempty" yaml:"ocsp,omitempty" label:"allowEmpty" file:"allowEmpty" export:"true"`
} }
// Core configures Traefik core behavior. // Core configures Traefik core behavior.
@ -424,6 +427,14 @@ func (c *Configuration) ValidateConfiguration() error {
return errors.New("API basePath must be a valid absolute path") return errors.New("API basePath must be a valid absolute path")
} }
if c.OCSP != nil {
for responderURL, url := range c.OCSP.ResponderOverrides {
if url == "" {
return fmt.Errorf("OCSP responder override value for %s cannot be empty", responderURL)
}
}
}
return nil return nil
} }

View File

@ -325,7 +325,7 @@ func TestRouterManager_Get(t *testing.T) {
serviceManager := service.NewManager(rtConf.Services, nil, nil, transportManager, proxyBuilderMock{}) serviceManager := service.NewManager(rtConf.Services, nil, nil, transportManager, proxyBuilderMock{})
middlewaresBuilder := middleware.NewBuilder(rtConf.Middlewares, serviceManager, nil) middlewaresBuilder := middleware.NewBuilder(rtConf.Middlewares, serviceManager, nil)
tlsManager := traefiktls.NewManager() tlsManager := traefiktls.NewManager(nil)
parser, err := httpmuxer.NewSyntaxParser() parser, err := httpmuxer.NewSyntaxParser()
require.NoError(t, err) require.NoError(t, err)
@ -712,7 +712,7 @@ func TestRuntimeConfiguration(t *testing.T) {
serviceManager := service.NewManager(rtConf.Services, nil, nil, transportManager, proxyBuilderMock{}) serviceManager := service.NewManager(rtConf.Services, nil, nil, transportManager, proxyBuilderMock{})
middlewaresBuilder := middleware.NewBuilder(rtConf.Middlewares, serviceManager, nil) middlewaresBuilder := middleware.NewBuilder(rtConf.Middlewares, serviceManager, nil)
tlsManager := traefiktls.NewManager() tlsManager := traefiktls.NewManager(nil)
tlsManager.UpdateConfigs(t.Context(), nil, test.tlsOptions, nil) tlsManager.UpdateConfigs(t.Context(), nil, test.tlsOptions, nil)
parser, err := httpmuxer.NewSyntaxParser() parser, err := httpmuxer.NewSyntaxParser()
@ -794,7 +794,7 @@ func TestProviderOnMiddlewares(t *testing.T) {
serviceManager := service.NewManager(rtConf.Services, nil, nil, transportManager, nil) serviceManager := service.NewManager(rtConf.Services, nil, nil, transportManager, nil)
middlewaresBuilder := middleware.NewBuilder(rtConf.Middlewares, serviceManager, nil) middlewaresBuilder := middleware.NewBuilder(rtConf.Middlewares, serviceManager, nil)
tlsManager := traefiktls.NewManager() tlsManager := traefiktls.NewManager(nil)
parser, err := httpmuxer.NewSyntaxParser() parser, err := httpmuxer.NewSyntaxParser()
require.NoError(t, err) require.NoError(t, err)
@ -873,7 +873,7 @@ func BenchmarkRouterServe(b *testing.B) {
serviceManager := service.NewManager(rtConf.Services, nil, nil, staticTransportManager{res}, nil) serviceManager := service.NewManager(rtConf.Services, nil, nil, staticTransportManager{res}, nil)
middlewaresBuilder := middleware.NewBuilder(rtConf.Middlewares, serviceManager, nil) middlewaresBuilder := middleware.NewBuilder(rtConf.Middlewares, serviceManager, nil)
tlsManager := traefiktls.NewManager() tlsManager := traefiktls.NewManager(nil)
parser, err := httpmuxer.NewSyntaxParser() parser, err := httpmuxer.NewSyntaxParser()
require.NoError(b, err) require.NoError(b, err)

View File

@ -347,7 +347,7 @@ func TestRuntimeConfiguration(t *testing.T) {
dialerManager := tcp2.NewDialerManager(nil) dialerManager := tcp2.NewDialerManager(nil)
dialerManager.Update(map[string]*dynamic.TCPServersTransport{"default@internal": {}}) dialerManager.Update(map[string]*dynamic.TCPServersTransport{"default@internal": {}})
serviceManager := tcp.NewManager(conf, dialerManager) serviceManager := tcp.NewManager(conf, dialerManager)
tlsManager := traefiktls.NewManager() tlsManager := traefiktls.NewManager(nil)
tlsManager.UpdateConfigs( tlsManager.UpdateConfigs(
t.Context(), t.Context(),
map[string]traefiktls.Store{}, map[string]traefiktls.Store{},
@ -659,7 +659,7 @@ func TestDomainFronting(t *testing.T) {
serviceManager := tcp.NewManager(conf, tcp2.NewDialerManager(nil)) serviceManager := tcp.NewManager(conf, tcp2.NewDialerManager(nil))
tlsManager := traefiktls.NewManager() tlsManager := traefiktls.NewManager(nil)
tlsManager.UpdateConfigs(t.Context(), map[string]traefiktls.Store{}, test.tlsOptions, []*traefiktls.CertAndStores{}) tlsManager.UpdateConfigs(t.Context(), map[string]traefiktls.Store{}, test.tlsOptions, []*traefiktls.CertAndStores{})
httpsHandler := map[string]http.Handler{ httpsHandler := map[string]http.Handler{

View File

@ -172,7 +172,7 @@ func Test_Routing(t *testing.T) {
require.NoError(t, err) require.NoError(t, err)
// Creates the tlsManager and defines the TLS 1.0 and 1.2 TLSOptions. // Creates the tlsManager and defines the TLS 1.0 and 1.2 TLSOptions.
tlsManager := traefiktls.NewManager() tlsManager := traefiktls.NewManager(nil)
tlsManager.UpdateConfigs( tlsManager.UpdateConfigs(
t.Context(), t.Context(),
map[string]traefiktls.Store{ map[string]traefiktls.Store{

View File

@ -55,7 +55,7 @@ func TestReuseService(t *testing.T) {
transportManager.Update(map[string]*dynamic.ServersTransport{"default@internal": {}}) transportManager.Update(map[string]*dynamic.ServersTransport{"default@internal": {}})
managerFactory := service.NewManagerFactory(staticConfig, nil, nil, transportManager, proxyBuilderMock{}, nil) managerFactory := service.NewManagerFactory(staticConfig, nil, nil, transportManager, proxyBuilderMock{}, nil)
tlsManager := tls.NewManager() tlsManager := tls.NewManager(nil)
dialerManager := tcp.NewDialerManager(nil) dialerManager := tcp.NewDialerManager(nil)
dialerManager.Update(map[string]*dynamic.TCPServersTransport{"default@internal": {}}) dialerManager.Update(map[string]*dynamic.TCPServersTransport{"default@internal": {}})
@ -193,7 +193,7 @@ func TestServerResponseEmptyBackend(t *testing.T) {
transportManager.Update(map[string]*dynamic.ServersTransport{"default@internal": {}}) transportManager.Update(map[string]*dynamic.ServersTransport{"default@internal": {}})
managerFactory := service.NewManagerFactory(staticConfig, nil, nil, transportManager, proxyBuilderMock{}, nil) managerFactory := service.NewManagerFactory(staticConfig, nil, nil, transportManager, proxyBuilderMock{}, nil)
tlsManager := tls.NewManager() tlsManager := tls.NewManager(nil)
dialerManager := tcp.NewDialerManager(nil) dialerManager := tcp.NewDialerManager(nil)
dialerManager.Update(map[string]*dynamic.TCPServersTransport{"default@internal": {}}) dialerManager.Update(map[string]*dynamic.TCPServersTransport{"default@internal": {}})
@ -239,7 +239,7 @@ func TestInternalServices(t *testing.T) {
transportManager.Update(map[string]*dynamic.ServersTransport{"default@internal": {}}) transportManager.Update(map[string]*dynamic.ServersTransport{"default@internal": {}})
managerFactory := service.NewManagerFactory(staticConfig, nil, nil, transportManager, nil, nil) managerFactory := service.NewManagerFactory(staticConfig, nil, nil, transportManager, nil, nil)
tlsManager := tls.NewManager() tlsManager := tls.NewManager(nil)
dialerManager := tcp.NewDialerManager(nil) dialerManager := tcp.NewDialerManager(nil)
dialerManager.Update(map[string]*dynamic.TCPServersTransport{"default@internal": {}}) dialerManager.Update(map[string]*dynamic.TCPServersTransport{"default@internal": {}})

View File

@ -7,7 +7,6 @@ import (
"fmt" "fmt"
"net/url" "net/url"
"os" "os"
"sort"
"strings" "strings"
"github.com/rs/zerolog/log" "github.com/rs/zerolog/log"
@ -76,68 +75,6 @@ type Certificate struct {
KeyFile types.FileOrContent `json:"keyFile,omitempty" toml:"keyFile,omitempty" yaml:"keyFile,omitempty" loggable:"false"` KeyFile types.FileOrContent `json:"keyFile,omitempty" toml:"keyFile,omitempty" yaml:"keyFile,omitempty" loggable:"false"`
} }
// AppendCertificate appends a Certificate to a certificates map keyed by store name.
func (c *Certificate) AppendCertificate(certs map[string]map[string]*tls.Certificate, storeName string) error {
certContent, err := c.CertFile.Read()
if err != nil {
return fmt.Errorf("unable to read CertFile : %w", err)
}
keyContent, err := c.KeyFile.Read()
if err != nil {
return fmt.Errorf("unable to read KeyFile : %w", err)
}
tlsCert, err := tls.X509KeyPair(certContent, keyContent)
if err != nil {
return fmt.Errorf("unable to generate TLS certificate : %w", err)
}
parsedCert, _ := x509.ParseCertificate(tlsCert.Certificate[0])
var SANs []string
if parsedCert.Subject.CommonName != "" {
SANs = append(SANs, strings.ToLower(parsedCert.Subject.CommonName))
}
if parsedCert.DNSNames != nil {
for _, dnsName := range parsedCert.DNSNames {
if dnsName != parsedCert.Subject.CommonName {
SANs = append(SANs, strings.ToLower(dnsName))
}
}
}
if parsedCert.IPAddresses != nil {
for _, ip := range parsedCert.IPAddresses {
if ip.String() != parsedCert.Subject.CommonName {
SANs = append(SANs, strings.ToLower(ip.String()))
}
}
}
// Guarantees the order to produce a unique cert key.
sort.Strings(SANs)
certKey := strings.Join(SANs, ",")
certExists := false
if certs[storeName] == nil {
certs[storeName] = make(map[string]*tls.Certificate)
} else {
for domains := range certs[storeName] {
if domains == certKey {
certExists = true
break
}
}
}
if certExists {
log.Debug().Msgf("Skipping addition of certificate for domain(s) %q, to TLS Store %s, as it already exists for this store.", certKey, storeName)
} else {
log.Debug().Msgf("Adding certificate for domain(s) %s", certKey)
certs[storeName][certKey] = &tlsCert
}
return err
}
// GetCertificate returns a tls.Certificate matching the configured CertFile and KeyFile. // GetCertificate returns a tls.Certificate matching the configured CertFile and KeyFile.
func (c *Certificate) GetCertificate() (tls.Certificate, error) { func (c *Certificate) GetCertificate() (tls.Certificate, error) {
certContent, err := c.CertFile.Read() certContent, err := c.CertFile.Read()
@ -169,24 +106,6 @@ func (c *Certificate) GetCertificateFromBytes() (tls.Certificate, error) {
return cert, nil return cert, nil
} }
// Set is the method to set the flag value, part of the flag.Value interface.
// Set's argument is a string to be parsed to set the flag.
// It's a comma-separated list, so we split it.
func (c *Certificates) Set(value string) error {
certificates := strings.Split(value, ";")
for _, certificate := range certificates {
files := strings.Split(certificate, ",")
if len(files) != 2 {
return fmt.Errorf("bad certificates format: %s", value)
}
*c = append(*c, Certificate{
CertFile: types.FileOrContent(files[0]),
KeyFile: types.FileOrContent(files[1]),
})
}
return nil
}
// GetTruncatedCertificateName truncates the certificate name. // GetTruncatedCertificateName truncates the certificate name.
func (c *Certificate) GetTruncatedCertificateName() string { func (c *Certificate) GetTruncatedCertificateName() string {
certName := c.CertFile.String() certName := c.CertFile.String()

View File

@ -2,7 +2,7 @@ package tls
import ( import (
"crypto/tls" "crypto/tls"
"crypto/x509" "fmt"
"net" "net"
"sort" "sort"
"strings" "strings"
@ -13,57 +13,40 @@ import (
"github.com/traefik/traefik/v3/pkg/safe" "github.com/traefik/traefik/v3/pkg/safe"
) )
// CertificateData holds runtime data for runtime TLS certificate handling.
type CertificateData struct {
Hash string
Certificate *tls.Certificate
}
// CertificateStore store for dynamic certificates. // CertificateStore store for dynamic certificates.
type CertificateStore struct { type CertificateStore struct {
DynamicCerts *safe.Safe DynamicCerts *safe.Safe
DefaultCertificate *tls.Certificate DefaultCertificate *CertificateData
CertCache *cache.Cache CertCache *cache.Cache
ocspStapler *ocspStapler
} }
// NewCertificateStore create a store for dynamic certificates. // NewCertificateStore create a store for dynamic certificates.
func NewCertificateStore() *CertificateStore { func NewCertificateStore(ocspStapler *ocspStapler) *CertificateStore {
s := &safe.Safe{} var dynamicCerts safe.Safe
s.Set(make(map[string]*tls.Certificate)) dynamicCerts.Set(make(map[string]*CertificateData))
return &CertificateStore{ return &CertificateStore{
DynamicCerts: s, DynamicCerts: &dynamicCerts,
CertCache: cache.New(1*time.Hour, 10*time.Minute), CertCache: cache.New(1*time.Hour, 10*time.Minute),
ocspStapler: ocspStapler,
} }
} }
func (c *CertificateStore) getDefaultCertificateDomains() []string {
var allCerts []string
if c.DefaultCertificate == nil {
return allCerts
}
x509Cert, err := x509.ParseCertificate(c.DefaultCertificate.Certificate[0])
if err != nil {
log.Error().Err(err).Msg("Could not parse default certificate")
return allCerts
}
if len(x509Cert.Subject.CommonName) > 0 {
allCerts = append(allCerts, x509Cert.Subject.CommonName)
}
allCerts = append(allCerts, x509Cert.DNSNames...)
for _, ipSan := range x509Cert.IPAddresses {
allCerts = append(allCerts, ipSan.String())
}
return allCerts
}
// GetAllDomains return a slice with all the certificate domain. // GetAllDomains return a slice with all the certificate domain.
func (c *CertificateStore) GetAllDomains() []string { func (c *CertificateStore) GetAllDomains() []string {
allDomains := c.getDefaultCertificateDomains() allDomains := c.getDefaultCertificateDomains()
// Get dynamic certificates // Get dynamic certificates
if c.DynamicCerts != nil && c.DynamicCerts.Get() != nil { if c.DynamicCerts != nil && c.DynamicCerts.Get() != nil {
for domain := range c.DynamicCerts.Get().(map[string]*tls.Certificate) { for domain := range c.DynamicCerts.Get().(map[string]*CertificateData) {
allDomains = append(allDomains, domain) allDomains = append(allDomains, domain)
} }
} }
@ -71,6 +54,23 @@ func (c *CertificateStore) GetAllDomains() []string {
return allDomains return allDomains
} }
// GetDefaultCertificate returns the default certificate.
func (c *CertificateStore) GetDefaultCertificate() *tls.Certificate {
if c == nil {
return nil
}
if c.ocspStapler != nil && c.DefaultCertificate.Hash != "" {
if staple, ok := c.ocspStapler.GetStaple(c.DefaultCertificate.Hash); ok {
// We are updating the OCSPStaple of the certificate without any synchronization
// as this should not cause any issue.
c.DefaultCertificate.Certificate.OCSPStaple = staple
}
}
return c.DefaultCertificate.Certificate
}
// GetBestCertificate returns the best match certificate, and caches the response. // GetBestCertificate returns the best match certificate, and caches the response.
func (c *CertificateStore) GetBestCertificate(clientHello *tls.ClientHelloInfo) *tls.Certificate { func (c *CertificateStore) GetBestCertificate(clientHello *tls.ClientHelloInfo) *tls.Certificate {
if c == nil { if c == nil {
@ -87,12 +87,21 @@ func (c *CertificateStore) GetBestCertificate(clientHello *tls.ClientHelloInfo)
} }
if cert, ok := c.CertCache.Get(serverName); ok { if cert, ok := c.CertCache.Get(serverName); ok {
return cert.(*tls.Certificate) certificateData := cert.(*CertificateData)
if c.ocspStapler != nil && certificateData.Hash != "" {
if staple, ok := c.ocspStapler.GetStaple(certificateData.Hash); ok {
// We are updating the OCSPStaple of the certificate without any synchronization
// as this should not cause any issue.
certificateData.Certificate.OCSPStaple = staple
}
}
return certificateData.Certificate
} }
matchedCerts := map[string]*tls.Certificate{} matchedCerts := map[string]*CertificateData{}
if c.DynamicCerts != nil && c.DynamicCerts.Get() != nil { if c.DynamicCerts != nil && c.DynamicCerts.Get() != nil {
for domains, cert := range c.DynamicCerts.Get().(map[string]*tls.Certificate) { for domains, cert := range c.DynamicCerts.Get().(map[string]*CertificateData) {
for _, certDomain := range strings.Split(domains, ",") { for _, certDomain := range strings.Split(domains, ",") {
if matchDomain(serverName, certDomain) { if matchDomain(serverName, certDomain) {
matchedCerts[certDomain] = cert matchedCerts[certDomain] = cert
@ -110,15 +119,25 @@ func (c *CertificateStore) GetBestCertificate(clientHello *tls.ClientHelloInfo)
sort.Strings(keys) sort.Strings(keys)
// cache best match // cache best match
c.CertCache.SetDefault(serverName, matchedCerts[keys[len(keys)-1]]) certificateData := matchedCerts[keys[len(keys)-1]]
return matchedCerts[keys[len(keys)-1]] c.CertCache.SetDefault(serverName, certificateData)
if c.ocspStapler != nil && certificateData.Hash != "" {
if staple, ok := c.ocspStapler.GetStaple(certificateData.Hash); ok {
// We are updating the OCSPStaple of the certificate without any synchronization
// as this should not cause any issue.
certificateData.Certificate.OCSPStaple = staple
}
}
return certificateData.Certificate
} }
return nil return nil
} }
// GetCertificate returns the first certificate matching all the given domains. // GetCertificate returns the first certificate matching all the given domains.
func (c *CertificateStore) GetCertificate(domains []string) *tls.Certificate { func (c *CertificateStore) GetCertificate(domains []string) *CertificateData {
if c == nil { if c == nil {
return nil return nil
} }
@ -127,11 +146,11 @@ func (c *CertificateStore) GetCertificate(domains []string) *tls.Certificate {
domainsKey := strings.Join(domains, ",") domainsKey := strings.Join(domains, ",")
if cert, ok := c.CertCache.Get(domainsKey); ok { if cert, ok := c.CertCache.Get(domainsKey); ok {
return cert.(*tls.Certificate) return cert.(*CertificateData)
} }
if c.DynamicCerts != nil && c.DynamicCerts.Get() != nil { if c.DynamicCerts != nil && c.DynamicCerts.Get() != nil {
for certDomains, cert := range c.DynamicCerts.Get().(map[string]*tls.Certificate) { for certDomains, cert := range c.DynamicCerts.Get().(map[string]*CertificateData) {
if domainsKey == certDomains { if domainsKey == certDomains {
c.CertCache.SetDefault(domainsKey, cert) c.CertCache.SetDefault(domainsKey, cert)
return cert return cert
@ -163,6 +182,91 @@ func (c *CertificateStore) ResetCache() {
} }
} }
func (c *CertificateStore) getDefaultCertificateDomains() []string {
if c.DefaultCertificate == nil {
return nil
}
defaultCert := c.DefaultCertificate.Certificate.Leaf
var allCerts []string
if len(defaultCert.Subject.CommonName) > 0 {
allCerts = append(allCerts, defaultCert.Subject.CommonName)
}
allCerts = append(allCerts, defaultCert.DNSNames...)
for _, ipSan := range defaultCert.IPAddresses {
allCerts = append(allCerts, ipSan.String())
}
return allCerts
}
// appendCertificate appends a Certificate to a certificates map keyed by store name.
func appendCertificate(certs map[string]map[string]*CertificateData, subjectAltNames []string, storeName string, cert *CertificateData) {
// Guarantees the order to produce a unique cert key.
sort.Strings(subjectAltNames)
certKey := strings.Join(subjectAltNames, ",")
certExists := false
if certs[storeName] == nil {
certs[storeName] = make(map[string]*CertificateData)
} else {
for domains := range certs[storeName] {
if domains == certKey {
certExists = true
break
}
}
}
if certExists {
log.Debug().Msgf("Skipping addition of certificate for domain(s) %q, to TLS Store %s, as it already exists for this store.", certKey, storeName)
} else {
log.Debug().Msgf("Adding certificate for domain(s) %s", certKey)
certs[storeName][certKey] = cert
}
}
func parseCertificate(cert *Certificate) (tls.Certificate, []string, error) {
certContent, err := cert.CertFile.Read()
if err != nil {
return tls.Certificate{}, nil, fmt.Errorf("unable to read CertFile: %w", err)
}
keyContent, err := cert.KeyFile.Read()
if err != nil {
return tls.Certificate{}, nil, fmt.Errorf("unable to read KeyFile: %w", err)
}
tlsCert, err := tls.X509KeyPair(certContent, keyContent)
if err != nil {
return tls.Certificate{}, nil, fmt.Errorf("unable to generate TLS certificate: %w", err)
}
var SANs []string
if tlsCert.Leaf.Subject.CommonName != "" {
SANs = append(SANs, strings.ToLower(tlsCert.Leaf.Subject.CommonName))
}
if tlsCert.Leaf.DNSNames != nil {
for _, dnsName := range tlsCert.Leaf.DNSNames {
if dnsName != tlsCert.Leaf.Subject.CommonName {
SANs = append(SANs, strings.ToLower(dnsName))
}
}
}
if tlsCert.Leaf.IPAddresses != nil {
for _, ip := range tlsCert.Leaf.IPAddresses {
if ip.String() != tlsCert.Leaf.Subject.CommonName {
SANs = append(SANs, strings.ToLower(ip.String()))
}
}
}
return tlsCert, SANs, err
}
// matchDomain returns whether the server name matches the cert domain. // matchDomain returns whether the server name matches the cert domain.
// The server name, from TLS SNI, must not have trailing dots (https://datatracker.ietf.org/doc/html/rfc6066#section-3). // The server name, from TLS SNI, must not have trailing dots (https://datatracker.ietf.org/doc/html/rfc6066#section-3).
// This is enforced by https://github.com/golang/go/blob/d3d7998756c33f69706488cade1cd2b9b10a4c7f/src/crypto/tls/handshake_messages.go#L423-L427. // This is enforced by https://github.com/golang/go/blob/d3d7998756c33f69706488cade1cd2b9b10a4c7f/src/crypto/tls/handshake_messages.go#L423-L427.

View File

@ -58,12 +58,12 @@ func TestGetBestCertificate(t *testing.T) {
for _, test := range testCases { for _, test := range testCases {
t.Run(test.desc, func(t *testing.T) { t.Run(test.desc, func(t *testing.T) {
t.Parallel() t.Parallel()
dynamicMap := map[string]*tls.Certificate{} dynamicMap := map[string]*CertificateData{}
if test.dynamicCert != "" { if test.dynamicCert != "" {
cert, err := loadTestCert(test.dynamicCert, test.uppercase) cert, err := loadTestCert(test.dynamicCert, test.uppercase)
require.NoError(t, err) require.NoError(t, err)
dynamicMap[strings.ToLower(test.dynamicCert)] = cert dynamicMap[strings.ToLower(test.dynamicCert)] = &CertificateData{Certificate: cert}
} }
store := &CertificateStore{ store := &CertificateStore{

206
pkg/tls/ocsp.go Normal file
View File

@ -0,0 +1,206 @@
package tls
import (
"bytes"
"context"
"crypto/x509"
"errors"
"fmt"
"io"
"net/http"
"time"
"github.com/patrickmn/go-cache"
"github.com/rs/zerolog/log"
"golang.org/x/crypto/ocsp"
)
const defaultCacheDuration = 24 * time.Hour
type ocspEntry struct {
leaf *x509.Certificate
issuer *x509.Certificate
responders []string
nextUpdate time.Time
staple []byte
}
// ocspStapler retrieves staples from OCSP responders and store them in an in-memory cache.
// It also updates the staples on a regular basis and before they expire.
type ocspStapler struct {
client *http.Client
cache cache.Cache
forceStapleUpdates chan struct{}
responderOverrides map[string]string
}
// newOCSPStapler creates a new ocspStapler cache.
func newOCSPStapler(responderOverrides map[string]string) *ocspStapler {
return &ocspStapler{
client: &http.Client{Timeout: 10 * time.Second},
cache: *cache.New(defaultCacheDuration, 5*time.Minute),
forceStapleUpdates: make(chan struct{}, 1),
responderOverrides: responderOverrides,
}
}
// Run updates the OCSP staples every hours.
func (o *ocspStapler) Run(ctx context.Context) {
ticker := time.NewTicker(time.Hour)
defer ticker.Stop()
select {
case <-ctx.Done():
return
case <-o.forceStapleUpdates:
o.updateStaples(ctx)
case <-ticker.C:
o.updateStaples(ctx)
}
}
// ForceStapleUpdates triggers staple updates in the background instead of waiting for the Run routine to update them.
func (o *ocspStapler) ForceStapleUpdates() {
select {
case o.forceStapleUpdates <- struct{}{}:
default:
}
}
// GetStaple retrieves the OCSP staple for the corresponding to the given key (public certificate hash).
func (o *ocspStapler) GetStaple(key string) ([]byte, bool) {
if item, ok := o.cache.Get(key); ok && item != nil {
if entry, ok := item.(*ocspEntry); ok {
return entry.staple, true
}
}
return nil, false
}
// Upsert creates a new entry for the given certificate.
// The ocspStapler will then be responsible from retrieving and updating the corresponding OCSP obtainStaple.
func (o *ocspStapler) Upsert(key string, leaf, issuer *x509.Certificate) error {
if len(leaf.OCSPServer) == 0 {
return errors.New("leaf certificate does not contain an OCSP server")
}
if item, ok := o.cache.Get(key); ok {
o.cache.Set(key, item, cache.NoExpiration)
return nil
}
var responders []string
for _, url := range leaf.OCSPServer {
if len(o.responderOverrides) > 0 {
if newURL, ok := o.responderOverrides[url]; ok {
url = newURL
}
}
responders = append(responders, url)
}
o.cache.Set(key, &ocspEntry{
leaf: leaf,
issuer: issuer,
responders: responders,
}, cache.NoExpiration)
return nil
}
// ResetTTL resets the expiration time for all items having no expiration.
// This allows setting a TTL for certificates that do not exist anymore in the dynamic configuration.
// For certificates that are still provided by the dynamic configuration,
// their expiration time will be unset when calling the Upsert method.
func (o *ocspStapler) ResetTTL() {
for key, item := range o.cache.Items() {
if item.Expiration > 0 {
continue
}
o.cache.Set(key, item.Object, defaultCacheDuration)
}
}
func (o *ocspStapler) updateStaples(ctx context.Context) {
for _, item := range o.cache.Items() {
select {
case <-ctx.Done():
return
default:
}
entry := item.Object.(*ocspEntry)
if entry.staple != nil && time.Now().Before(entry.nextUpdate) {
continue
}
if err := o.updateStaple(ctx, entry); err != nil {
log.Error().Err(err).Msgf("Unable to retieve OCSP staple for: %s", entry.leaf.Subject.CommonName)
continue
}
}
}
// obtainStaple obtains the OCSP stable for the given leaf certificate.
func (o *ocspStapler) updateStaple(ctx context.Context, entry *ocspEntry) error {
ocspReq, err := ocsp.CreateRequest(entry.leaf, entry.issuer, nil)
if err != nil {
return fmt.Errorf("creating OCSP request: %w", err)
}
for _, responder := range entry.responders {
logger := log.With().Str("responder", responder).Logger()
req, err := http.NewRequestWithContext(ctx, http.MethodPost, responder, bytes.NewReader(ocspReq))
if err != nil {
return fmt.Errorf("creating OCSP request: %w", err)
}
req.Header.Set("Content-Type", "application/ocsp-request")
res, err := o.client.Do(req)
if err != nil && ctx.Err() != nil {
return ctx.Err()
}
if err != nil {
logger.Debug().Err(err).Msg("Unable to obtain OCSP response")
continue
}
defer res.Body.Close()
if res.StatusCode/100 != 2 {
logger.Debug().Msgf("Unable to obtain OCSP response due to status code: %d", res.StatusCode)
continue
}
ocspResBytes, err := io.ReadAll(res.Body)
if err != nil {
logger.Debug().Err(err).Msg("Unable to read OCSP response bytes")
continue
}
ocspRes, err := ocsp.ParseResponseForCert(ocspResBytes, entry.leaf, entry.issuer)
if err != nil {
logger.Debug().Err(err).Msg("Unable to parse OCSP response")
continue
}
entry.staple = ocspResBytes
// As per RFC 6960, the nextUpdate field is optional.
if ocspRes.NextUpdate.IsZero() {
// NextUpdate is not set, the staple should be updated on the next update.
entry.nextUpdate = time.Now()
} else {
entry.nextUpdate = ocspRes.ThisUpdate.Add(ocspRes.NextUpdate.Sub(ocspRes.ThisUpdate) / 2)
}
return nil
}
return errors.New("no OCSP staple obtained from any responders")
}

485
pkg/tls/ocsp_test.go Normal file
View File

@ -0,0 +1,485 @@
package tls
import (
"crypto"
"crypto/tls"
"io"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/patrickmn/go-cache"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"golang.org/x/crypto/ocsp"
)
const certWithOCSPServer = `-----BEGIN CERTIFICATE-----
MIIBgjCCASegAwIBAgICIAAwCgYIKoZIzj0EAwIwEjEQMA4GA1UEAxMHVGVzdCBD
QTAeFw0yMzAxMDExMjAwMDBaFw0yMzAyMDExMjAwMDBaMCAxHjAcBgNVBAMTFU9D
U1AgVGVzdCBDZXJ0aWZpY2F0ZTBZMBMGByqGSM49AgEGCCqGSM49AwEHA0IABIoe
I/bjo34qony8LdRJD+Jhuk8/S8YHXRHl6rH9t5VFCFtX8lIPN/Ll1zCrQ2KB3Wlb
fxSgiQyLrCpZyrdhVPSjXzBdMAwGA1UdEwEB/wQCMAAwHwYDVR0jBBgwFoAU+Eo3
5sST4LRrwS4dueIdGBZ5d7IwLAYIKwYBBQUHAQEEIDAeMBwGCCsGAQUFBzABhhBv
Y3NwLmV4YW1wbGUuY29tMAoGCCqGSM49BAMCA0kAMEYCIQDg94xY/+/VepESdvTT
ykCwiWOS2aCpjyryrKpwMKkR0AIhAPc/+ZEz4W10OENxC1t+NUTvS8JbEGOwulkZ
z9yfaLuD
-----END CERTIFICATE-----`
const certWithoutOCSPServer = `-----BEGIN CERTIFICATE-----
MIIBUzCB+aADAgECAgIgADAKBggqhkjOPQQDAjASMRAwDgYDVQQDEwdUZXN0IENB
MB4XDTIzMDEwMTEyMDAwMFoXDTIzMDIwMTEyMDAwMFowIDEeMBwGA1UEAxMVT0NT
UCBUZXN0IENlcnRpZmljYXRlMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEih4j
9uOjfiqifLwt1EkP4mG6Tz9LxgddEeXqsf23lUUIW1fyUg838uXXMKtDYoHdaVt/
FKCJDIusKlnKt2FU9KMxMC8wDAYDVR0TAQH/BAIwADAfBgNVHSMEGDAWgBT4Sjfm
xJPgtGvBLh254h0YFnl3sjAKBggqhkjOPQQDAgNJADBGAiEA3rWetLGblfSuNZKf
5CpZxhj3A0BjEocEh+2P+nAgIdUCIQDIgptabR1qTLQaF2u0hJsEX2IKuIUvYWH3
6Lb92+zIHg==
-----END CERTIFICATE-----`
// certKey is the private key for both certWithOCSPServer and certWithoutOCSPServer.
const certKey = `-----BEGIN EC PRIVATE KEY-----
MHcCAQEEINnVcgrSNh4HlThWlZpegq14M8G/p9NVDtdVjZrseUGLoAoGCCqGSM49
AwEHoUQDQgAEih4j9uOjfiqifLwt1EkP4mG6Tz9LxgddEeXqsf23lUUIW1fyUg83
8uXXMKtDYoHdaVt/FKCJDIusKlnKt2FU9A==
-----END EC PRIVATE KEY-----`
// caCert is the issuing certificate for certWithOCSPServer and certWithoutOCSPServer.
const caCert = `-----BEGIN CERTIFICATE-----
MIIBazCCARGgAwIBAgICEAAwCgYIKoZIzj0EAwIwEjEQMA4GA1UEAxMHVGVzdCBD
QTAeFw0yMzAxMDExMjAwMDBaFw0yMzAyMDExMjAwMDBaMBIxEDAOBgNVBAMTB1Rl
c3QgQ0EwWTATBgcqhkjOPQIBBggqhkjOPQMBBwNCAASdKexSor/aeazDM57UHhAX
rCkJxUeF2BWf0lZYCRxc3f0GdrEsVvjJW8+/E06eAzDCGSdM/08Nvun1nb6AmAlt
o1cwVTAOBgNVHQ8BAf8EBAMCAQYwEwYDVR0lBAwwCgYIKwYBBQUHAwkwDwYDVR0T
AQH/BAUwAwEB/zAdBgNVHQ4EFgQU+Eo35sST4LRrwS4dueIdGBZ5d7IwCgYIKoZI
zj0EAwIDSAAwRQIgGbA39+kETTB/YMLBFoC2fpZe1cDWfFB7TUdfINUqdH4CIQCR
ByUFC8A+hRNkK5YNH78bgjnKk/88zUQF5ONy4oPGdQ==
-----END CERTIFICATE-----`
const caKey = `-----BEGIN EC PRIVATE KEY-----
MHcCAQEEIDJ59ptjq3MzILH4zn5IKoH1sYn+zrUeq2kD8+DD2x+OoAoGCCqGSM49
AwEHoUQDQgAEnSnsUqK/2nmswzOe1B4QF6wpCcVHhdgVn9JWWAkcXN39BnaxLFb4
yVvPvxNOngMwwhknTP9PDb7p9Z2+gJgJbQ==
-----END EC PRIVATE KEY-----`
func TestOCSPStapler_Upsert(t *testing.T) {
ocspStapler := newOCSPStapler(nil)
issuerCert, err := tls.X509KeyPair([]byte(caCert), []byte(caKey))
require.NoError(t, err)
leafCert, err := tls.X509KeyPair([]byte(certWithOCSPServer), []byte(certKey))
require.NoError(t, err)
// Upsert a certificate without an OCSP server should raise an error.
leafCertWithoutOCSPServer, err := tls.X509KeyPair([]byte(certWithoutOCSPServer), []byte(certKey))
require.NoError(t, err)
err = ocspStapler.Upsert("foo", leafCertWithoutOCSPServer.Leaf, issuerCert.Leaf)
require.Error(t, err)
// Upsert a certificate with an OCSP server.
err = ocspStapler.Upsert("foo", leafCert.Leaf, issuerCert.Leaf)
require.NoError(t, err)
i, ok := ocspStapler.cache.Get("foo")
require.True(t, ok)
e, ok := i.(*ocspEntry)
require.True(t, ok)
assert.Equal(t, leafCert.Leaf, e.leaf)
assert.Equal(t, issuerCert.Leaf, e.issuer)
assert.Nil(t, e.staple)
assert.Equal(t, []string{"ocsp.example.com"}, e.responders)
assert.Equal(t, int64(0), ocspStapler.cache.Items()["foo"].Expiration)
// Upsert an existing entry to make sure that the existing staple is preserved.
e.staple = []byte("foo")
e.nextUpdate = time.Now()
e.responders = []string{"foo.com"}
err = ocspStapler.Upsert("foo", leafCert.Leaf, issuerCert.Leaf)
require.NoError(t, err)
i, ok = ocspStapler.cache.Get("foo")
require.True(t, ok)
e, ok = i.(*ocspEntry)
require.True(t, ok)
assert.Equal(t, leafCert.Leaf, e.leaf)
assert.Equal(t, issuerCert.Leaf, e.issuer)
assert.Equal(t, []byte("foo"), e.staple)
assert.NotZero(t, e.nextUpdate)
assert.Equal(t, []string{"foo.com"}, e.responders)
assert.Equal(t, int64(0), ocspStapler.cache.Items()["foo"].Expiration)
}
func TestOCSPStapler_Upsert_withResponderOverrides(t *testing.T) {
ocspStapler := newOCSPStapler(map[string]string{
"ocsp.example.com": "foo.com",
})
issuerCert, err := tls.X509KeyPair([]byte(caCert), []byte(caKey))
require.NoError(t, err)
leafCert, err := tls.X509KeyPair([]byte(certWithOCSPServer), []byte(certKey))
require.NoError(t, err)
err = ocspStapler.Upsert("foo", leafCert.Leaf, issuerCert.Leaf)
require.NoError(t, err)
i, ok := ocspStapler.cache.Get("foo")
require.True(t, ok)
e, ok := i.(*ocspEntry)
require.True(t, ok)
assert.Equal(t, leafCert.Leaf, e.leaf)
assert.Equal(t, issuerCert.Leaf, e.issuer)
assert.Nil(t, e.staple)
assert.Equal(t, []string{"foo.com"}, e.responders)
}
func TestOCSPStapler_ResetTTL(t *testing.T) {
ocspStapler := newOCSPStapler(nil)
issuerCert, err := tls.X509KeyPair([]byte(caCert), []byte(caKey))
require.NoError(t, err)
leafCert, err := tls.X509KeyPair([]byte(certWithOCSPServer), []byte(certKey))
require.NoError(t, err)
ocspStapler.cache.Set("foo", &ocspEntry{
leaf: leafCert.Leaf,
issuer: issuerCert.Leaf,
responders: []string{"foo.com"},
nextUpdate: time.Now(),
staple: []byte("foo"),
}, cache.NoExpiration)
ocspStapler.cache.Set("bar", &ocspEntry{
leaf: leafCert.Leaf,
issuer: issuerCert.Leaf,
responders: []string{"bar.com"},
nextUpdate: time.Now(),
staple: []byte("bar"),
}, time.Hour)
wantBarExpiration := ocspStapler.cache.Items()["bar"].Expiration
ocspStapler.ResetTTL()
item, ok := ocspStapler.cache.Items()["foo"]
require.True(t, ok)
e, ok := item.Object.(*ocspEntry)
require.True(t, ok)
assert.Positive(t, item.Expiration)
assert.Equal(t, leafCert.Leaf, e.leaf)
assert.Equal(t, issuerCert.Leaf, e.issuer)
assert.Equal(t, []byte("foo"), e.staple)
assert.NotZero(t, e.nextUpdate)
assert.Equal(t, []string{"foo.com"}, e.responders)
item, ok = ocspStapler.cache.Items()["bar"]
require.True(t, ok)
e, ok = item.Object.(*ocspEntry)
require.True(t, ok)
assert.Equal(t, wantBarExpiration, item.Expiration)
assert.Equal(t, leafCert.Leaf, e.leaf)
assert.Equal(t, issuerCert.Leaf, e.issuer)
assert.Equal(t, []byte("bar"), e.staple)
assert.NotZero(t, e.nextUpdate)
assert.Equal(t, []string{"bar.com"}, e.responders)
}
func TestOCSPStapler_GetStaple(t *testing.T) {
ocspStapler := newOCSPStapler(nil)
// Get an un-existing staple.
staple, exists := ocspStapler.GetStaple("foo")
assert.False(t, exists)
assert.Nil(t, staple)
// Get an existing staple.
ocspStapler.cache.Set("foo", &ocspEntry{staple: []byte("foo")}, cache.NoExpiration)
staple, exists = ocspStapler.GetStaple("foo")
assert.True(t, exists)
assert.Equal(t, []byte("foo"), staple)
}
func TestOCSPStapler_updateStaple(t *testing.T) {
leafCert, err := tls.X509KeyPair([]byte(certWithOCSPServer), []byte(certKey))
require.NoError(t, err)
issuerCert, err := tls.X509KeyPair([]byte(caCert), []byte(caKey))
require.NoError(t, err)
thisUpdate, err := time.Parse("2006-01-02", "2025-01-01")
require.NoError(t, err)
nextUpdate, err := time.Parse("2006-01-02", "2025-01-02")
require.NoError(t, err)
stapleUpdate := thisUpdate.Add(nextUpdate.Sub(thisUpdate) / 2)
ocspResponseTmpl := ocsp.Response{
SerialNumber: leafCert.Leaf.SerialNumber,
TBSResponseData: []byte("foo"),
ThisUpdate: thisUpdate,
NextUpdate: nextUpdate,
}
ocspResponse, err := ocsp.CreateResponse(leafCert.Leaf, leafCert.Leaf, ocspResponseTmpl, issuerCert.PrivateKey.(crypto.Signer))
require.NoError(t, err)
handler := func(rw http.ResponseWriter, req *http.Request) {
ct := req.Header.Get("Content-Type")
assert.Equal(t, "application/ocsp-request", ct)
reqBytes, err := io.ReadAll(req.Body)
require.NoError(t, err)
_, err = ocsp.ParseRequest(reqBytes)
require.NoError(t, err)
rw.Header().Set("Content-Type", "application/ocsp-response")
_, err = rw.Write(ocspResponse)
require.NoError(t, err)
}
responder := httptest.NewServer(http.HandlerFunc(handler))
t.Cleanup(responder.Close)
responderStatusNotOK := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNotFound)
}))
t.Cleanup(responderStatusNotOK.Close)
testCases := []struct {
desc string
entry *ocspEntry
expectError bool
}{
{
desc: "no responder",
entry: &ocspEntry{
leaf: leafCert.Leaf,
issuer: issuerCert.Leaf,
},
expectError: true,
},
{
desc: "wrong responder",
entry: &ocspEntry{
leaf: leafCert.Leaf,
issuer: issuerCert.Leaf,
responders: []string{"http://foo.bar"},
},
expectError: true,
},
{
desc: "not ok status responder",
entry: &ocspEntry{
leaf: leafCert.Leaf,
issuer: issuerCert.Leaf,
responders: []string{responderStatusNotOK.URL},
},
expectError: true,
},
{
desc: "one wrong responder, one ok",
entry: &ocspEntry{
leaf: leafCert.Leaf,
issuer: issuerCert.Leaf,
responders: []string{"http://foo.bar", responder.URL},
},
},
{
desc: "ok responder",
entry: &ocspEntry{
leaf: leafCert.Leaf,
issuer: issuerCert.Leaf,
responders: []string{responder.URL},
},
},
}
for _, test := range testCases {
t.Run(test.desc, func(t *testing.T) {
t.Parallel()
ocspStapler := newOCSPStapler(nil)
ocspStapler.client = &http.Client{Timeout: time.Second}
err = ocspStapler.updateStaple(t.Context(), test.entry)
if test.expectError {
require.Error(t, err)
return
}
require.NoError(t, err)
assert.Equal(t, ocspResponse, test.entry.staple)
assert.Equal(t, stapleUpdate.UTC(), test.entry.nextUpdate)
})
}
}
func TestOCSPStapler_updateStaple_withoutNextUpdate(t *testing.T) {
leafCert, err := tls.X509KeyPair([]byte(certWithOCSPServer), []byte(certKey))
require.NoError(t, err)
issuerCert, err := tls.X509KeyPair([]byte(caCert), []byte(caKey))
require.NoError(t, err)
thisUpdate, err := time.Parse("2006-01-02", "2025-01-01")
require.NoError(t, err)
ocspResponseTmpl := ocsp.Response{
SerialNumber: leafCert.Leaf.SerialNumber,
TBSResponseData: []byte("foo"),
ThisUpdate: thisUpdate,
}
ocspResponse, err := ocsp.CreateResponse(leafCert.Leaf, leafCert.Leaf, ocspResponseTmpl, issuerCert.PrivateKey.(crypto.Signer))
require.NoError(t, err)
handler := func(rw http.ResponseWriter, req *http.Request) {
ct := req.Header.Get("Content-Type")
assert.Equal(t, "application/ocsp-request", ct)
reqBytes, err := io.ReadAll(req.Body)
require.NoError(t, err)
_, err = ocsp.ParseRequest(reqBytes)
require.NoError(t, err)
rw.Header().Set("Content-Type", "application/ocsp-response")
_, err = rw.Write(ocspResponse)
require.NoError(t, err)
}
responder := httptest.NewServer(http.HandlerFunc(handler))
t.Cleanup(responder.Close)
responderStatusNotOK := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNotFound)
}))
t.Cleanup(responderStatusNotOK.Close)
ocspStapler := newOCSPStapler(nil)
ocspStapler.client = &http.Client{Timeout: time.Second}
entry := &ocspEntry{
leaf: leafCert.Leaf,
issuer: issuerCert.Leaf,
responders: []string{responder.URL},
}
err = ocspStapler.updateStaple(t.Context(), entry)
require.NoError(t, err)
assert.Equal(t, ocspResponse, entry.staple)
assert.NotZero(t, entry.nextUpdate)
assert.Greater(t, time.Now(), entry.nextUpdate)
}
func TestOCSPStapler_updateStaples(t *testing.T) {
leafCert, err := tls.X509KeyPair([]byte(certWithOCSPServer), []byte(certKey))
require.NoError(t, err)
issuerCert, err := tls.X509KeyPair([]byte(caCert), []byte(caKey))
require.NoError(t, err)
thisUpdate, err := time.Parse("2006-01-02", "2025-01-01")
require.NoError(t, err)
nextUpdate, err := time.Parse("2006-01-02", "2025-01-02")
require.NoError(t, err)
stapleUpdate := thisUpdate.Add(nextUpdate.Sub(thisUpdate) / 2)
ocspResponseTmpl := ocsp.Response{
SerialNumber: leafCert.Leaf.SerialNumber,
TBSResponseData: []byte("foo"),
ThisUpdate: thisUpdate,
NextUpdate: nextUpdate,
}
ocspResponse, err := ocsp.CreateResponse(leafCert.Leaf, leafCert.Leaf, ocspResponseTmpl, issuerCert.PrivateKey.(crypto.Signer))
require.NoError(t, err)
handler := func(rw http.ResponseWriter, req *http.Request) {
ct := req.Header.Get("Content-Type")
assert.Equal(t, "application/ocsp-request", ct)
reqBytes, err := io.ReadAll(req.Body)
require.NoError(t, err)
_, err = ocsp.ParseRequest(reqBytes)
require.NoError(t, err)
rw.Header().Set("Content-Type", "application/ocsp-response")
_, err = rw.Write(ocspResponse)
require.NoError(t, err)
}
responder := httptest.NewServer(http.HandlerFunc(handler))
t.Cleanup(responder.Close)
ocspStapler := newOCSPStapler(nil)
ocspStapler.client = &http.Client{Timeout: time.Second}
// nil staple entry
ocspStapler.cache.Set("nilStaple", &ocspEntry{
leaf: leafCert.Leaf,
issuer: issuerCert.Leaf,
responders: []string{responder.URL},
nextUpdate: time.Now().Add(-time.Hour),
}, cache.NoExpiration)
// staple entry with nextUpdate in the past
ocspStapler.cache.Set("toUpdate", &ocspEntry{
leaf: leafCert.Leaf,
issuer: issuerCert.Leaf,
responders: []string{responder.URL},
staple: []byte("foo"),
nextUpdate: time.Now().Add(-time.Hour),
}, cache.NoExpiration)
// staple entry with nextUpdate in the future
inOneHour := time.Now().Add(time.Hour)
ocspStapler.cache.Set("noUpdate", &ocspEntry{
leaf: leafCert.Leaf,
issuer: issuerCert.Leaf,
responders: []string{responder.URL},
staple: []byte("foo"),
nextUpdate: inOneHour,
}, cache.NoExpiration)
ocspStapler.updateStaples(t.Context())
nilStaple, ok := ocspStapler.cache.Get("nilStaple")
require.True(t, ok)
assert.Equal(t, ocspResponse, nilStaple.(*ocspEntry).staple)
assert.Equal(t, stapleUpdate.UTC(), nilStaple.(*ocspEntry).nextUpdate)
toUpdate, ok := ocspStapler.cache.Get("toUpdate")
require.True(t, ok)
assert.Equal(t, ocspResponse, toUpdate.(*ocspEntry).staple)
assert.Equal(t, stapleUpdate.UTC(), nilStaple.(*ocspEntry).nextUpdate)
noUpdate, ok := ocspStapler.cache.Get("noUpdate")
require.True(t, ok)
assert.Equal(t, []byte("foo"), noUpdate.(*ocspEntry).staple)
assert.Equal(t, inOneHour, noUpdate.(*ocspEntry).nextUpdate)
}

View File

@ -6,7 +6,9 @@ import (
"crypto/x509" "crypto/x509"
"errors" "errors"
"fmt" "fmt"
"hash/fnv"
"slices" "slices"
"strconv"
"strings" "strings"
"sync" "sync"
@ -43,6 +45,11 @@ func getCipherSuites() []string {
return ciphers return ciphers
} }
// OCSPConfig contains the OCSP configuration.
type OCSPConfig struct {
ResponderOverrides map[string]string `description:"Defines a map of OCSP responders to replace for querying OCSP servers." json:"responderOverrides,omitempty" toml:"responderOverrides,omitempty" yaml:"responderOverrides,omitempty"`
}
// Manager is the TLS option/store/configuration factory. // Manager is the TLS option/store/configuration factory.
type Manager struct { type Manager struct {
lock sync.RWMutex lock sync.RWMutex
@ -50,16 +57,33 @@ type Manager struct {
stores map[string]*CertificateStore stores map[string]*CertificateStore
configs map[string]Options configs map[string]Options
certs []*CertAndStores certs []*CertAndStores
// As of today, the TLS manager contains and is responsible for creating/starting the OCSP ocspStapler.
// It would likely have been a Configuration listener but this implies that certs are re-parsed.
// But this would probably have impact on resource consumption.
ocspStapler *ocspStapler
} }
// NewManager creates a new Manager. // NewManager creates a new Manager.
func NewManager() *Manager { func NewManager(ocspConfig *OCSPConfig) *Manager {
return &Manager{ manager := &Manager{
stores: map[string]*CertificateStore{}, stores: map[string]*CertificateStore{},
configs: map[string]Options{ configs: map[string]Options{
"default": DefaultTLSOptions, "default": DefaultTLSOptions,
}, },
} }
if ocspConfig != nil {
manager.ocspStapler = newOCSPStapler(ocspConfig.ResponderOverrides)
}
return manager
}
func (m *Manager) Run(ctx context.Context) {
if m.ocspStapler != nil {
m.ocspStapler.Run(ctx)
}
} }
// UpdateConfigs updates the TLS* configuration options. // UpdateConfigs updates the TLS* configuration options.
@ -91,7 +115,14 @@ func (m *Manager) UpdateConfigs(ctx context.Context, stores map[string]Store, co
m.storesConfig[tlsalpn01.ACMETLS1Protocol] = Store{} m.storesConfig[tlsalpn01.ACMETLS1Protocol] = Store{}
} }
storesCertificates := make(map[string]map[string]*tls.Certificate) storesCertificates := make(map[string]map[string]*CertificateData)
// Define the TTL for all the cache entries with no TTL.
// This will discard entries that are not used anymore.
if m.ocspStapler != nil {
m.ocspStapler.ResetTTL()
}
for _, conf := range certs { for _, conf := range certs {
if len(conf.Stores) == 0 { if len(conf.Stores) == 0 {
log.Ctx(ctx).Debug().MsgFunc(func() string { log.Ctx(ctx).Debug().MsgFunc(func() string {
@ -101,24 +132,49 @@ func (m *Manager) UpdateConfigs(ctx context.Context, stores map[string]Store, co
conf.Stores = []string{DefaultTLSStoreName} conf.Stores = []string{DefaultTLSStoreName}
} }
for _, store := range conf.Stores { cert, SANs, err := parseCertificate(&conf.Certificate)
logger := log.Ctx(ctx).With().Str(logs.TLSStoreName, store).Logger() if err != nil {
log.Ctx(ctx).Error().Err(err).Msgf("Unable to parse certificate %s", conf.Certificate.GetTruncatedCertificateName())
continue
}
var certHash string
if m.ocspStapler != nil && len(cert.Leaf.OCSPServer) > 0 {
certHash = hashRawCert(cert.Leaf.Raw)
issuer := cert.Leaf
if len(cert.Certificate) > 1 {
issuer, err = x509.ParseCertificate(cert.Certificate[1])
if err != nil {
log.Ctx(ctx).Error().Err(err).Msgf("Unable to parse issuer certificate %s", conf.Certificate.GetTruncatedCertificateName())
continue
}
}
if err := m.ocspStapler.Upsert(certHash, cert.Leaf, issuer); err != nil {
log.Ctx(ctx).Error().Err(err).Msgf("Unable to upsert OCSP certificate %s", conf.Certificate.GetTruncatedCertificateName())
continue
}
}
certData := &CertificateData{
Certificate: &cert,
Hash: certHash,
}
for _, store := range conf.Stores {
if _, ok := m.storesConfig[store]; !ok { if _, ok := m.storesConfig[store]; !ok {
m.storesConfig[store] = Store{} m.storesConfig[store] = Store{}
} }
err := conf.Certificate.AppendCertificate(storesCertificates, store) appendCertificate(storesCertificates, SANs, store, certData)
if err != nil {
logger.Error().Err(err).Msgf("Unable to append certificate %s to store", conf.Certificate.GetTruncatedCertificateName())
}
} }
} }
m.stores = make(map[string]*CertificateStore) m.stores = make(map[string]*CertificateStore)
for storeName, storeConfig := range m.storesConfig { for storeName, storeConfig := range m.storesConfig {
st := NewCertificateStore() st := NewCertificateStore(m.ocspStapler)
m.stores[storeName] = st m.stores[storeName] = st
if certs, ok := storesCertificates[storeName]; ok { if certs, ok := storesCertificates[storeName]; ok {
@ -133,13 +189,17 @@ func (m *Manager) UpdateConfigs(ctx context.Context, stores map[string]Store, co
logger := log.Ctx(ctx).With().Str(logs.TLSStoreName, storeName).Logger() logger := log.Ctx(ctx).With().Str(logs.TLSStoreName, storeName).Logger()
ctxStore := logger.WithContext(ctx) ctxStore := logger.WithContext(ctx)
certificate, err := getDefaultCertificate(ctxStore, storeConfig, st) certificate, err := m.getDefaultCertificate(ctxStore, storeConfig, st)
if err != nil { if err != nil {
logger.Error().Err(err).Msg("Error while creating certificate store") logger.Error().Err(err).Msg("Error while creating certificate store")
} }
st.DefaultCertificate = certificate st.DefaultCertificate = certificate
} }
if m.ocspStapler != nil {
m.ocspStapler.ForceStapleUpdates()
}
} }
// sanitizeDomains sanitizes the domain definition Main and SANS, // sanitizeDomains sanitizes the domain definition Main and SANS,
@ -226,7 +286,8 @@ func (m *Manager) Get(storeName, configName string) (*tls.Config, error) {
} }
log.Debug().Msgf("Serving default certificate for request: %q", domainToCheck) log.Debug().Msgf("Serving default certificate for request: %q", domainToCheck)
return store.DefaultCertificate, nil
return store.GetDefaultCertificate(), nil
} }
return tlsConfig, err return tlsConfig, err
@ -245,8 +306,8 @@ func (m *Manager) GetServerCertificates() []*x509.Certificate {
// We iterate over all the certificates. // We iterate over all the certificates.
if defaultStore.DynamicCerts != nil && defaultStore.DynamicCerts.Get() != nil { if defaultStore.DynamicCerts != nil && defaultStore.DynamicCerts.Get() != nil {
for _, cert := range defaultStore.DynamicCerts.Get().(map[string]*tls.Certificate) { for _, cert := range defaultStore.DynamicCerts.Get().(map[string]*CertificateData) {
x509Cert, err := x509.ParseCertificate(cert.Certificate[0]) x509Cert, err := x509.ParseCertificate(cert.Certificate.Certificate[0])
if err != nil { if err != nil {
continue continue
} }
@ -256,7 +317,7 @@ func (m *Manager) GetServerCertificates() []*x509.Certificate {
} }
if defaultStore.DefaultCertificate != nil { if defaultStore.DefaultCertificate != nil {
x509Cert, err := x509.ParseCertificate(defaultStore.DefaultCertificate.Certificate[0]) x509Cert, err := x509.ParseCertificate(defaultStore.DefaultCertificate.Certificate.Certificate[0])
if err != nil { if err != nil {
return certificates return certificates
} }
@ -289,9 +350,9 @@ func (m *Manager) GetStore(storeName string) *CertificateStore {
return m.getStore(storeName) return m.getStore(storeName)
} }
func getDefaultCertificate(ctx context.Context, tlsStore Store, st *CertificateStore) (*tls.Certificate, error) { func (m *Manager) getDefaultCertificate(ctx context.Context, tlsStore Store, st *CertificateStore) (*CertificateData, error) {
if tlsStore.DefaultCertificate != nil { if tlsStore.DefaultCertificate != nil {
cert, err := buildDefaultCertificate(tlsStore.DefaultCertificate) cert, err := m.buildDefaultCertificate(tlsStore.DefaultCertificate)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@ -304,22 +365,65 @@ func getDefaultCertificate(ctx context.Context, tlsStore Store, st *CertificateS
return nil, err return nil, err
} }
defaultCertificate := &CertificateData{
Certificate: defaultCert,
}
if tlsStore.DefaultGeneratedCert != nil && tlsStore.DefaultGeneratedCert.Domain != nil && tlsStore.DefaultGeneratedCert.Resolver != "" { if tlsStore.DefaultGeneratedCert != nil && tlsStore.DefaultGeneratedCert.Domain != nil && tlsStore.DefaultGeneratedCert.Resolver != "" {
domains, err := sanitizeDomains(*tlsStore.DefaultGeneratedCert.Domain) domains, err := sanitizeDomains(*tlsStore.DefaultGeneratedCert.Domain)
if err != nil { if err != nil {
return defaultCert, fmt.Errorf("falling back to the internal generated certificate because invalid domains: %w", err) return defaultCertificate, fmt.Errorf("falling back to the internal generated certificate because invalid domains: %w", err)
} }
defaultACMECert := st.GetCertificate(domains) defaultACMECert := st.GetCertificate(domains)
if defaultACMECert == nil { if defaultACMECert == nil {
return defaultCert, fmt.Errorf("unable to find certificate for domains %q: falling back to the internal generated certificate", strings.Join(domains, ",")) return defaultCertificate, fmt.Errorf("unable to find certificate for domains %q: falling back to the internal generated certificate", strings.Join(domains, ","))
} }
return defaultACMECert, nil return defaultACMECert, nil
} }
log.Ctx(ctx).Debug().Msg("No default certificate, fallback to the internal generated certificate") log.Ctx(ctx).Debug().Msg("No default certificate, fallback to the internal generated certificate")
return defaultCert, nil return defaultCertificate, nil
}
func (m *Manager) buildDefaultCertificate(defaultCertificate *Certificate) (*CertificateData, error) {
certFile, err := defaultCertificate.CertFile.Read()
if err != nil {
return nil, fmt.Errorf("failed to get cert file content: %w", err)
}
keyFile, err := defaultCertificate.KeyFile.Read()
if err != nil {
return nil, fmt.Errorf("failed to get key file content: %w", err)
}
cert, err := tls.X509KeyPair(certFile, keyFile)
if err != nil {
return nil, fmt.Errorf("failed to load X509 key pair: %w", err)
}
var certHash string
if m.ocspStapler != nil && len(cert.Leaf.OCSPServer) > 0 {
certHash = hashRawCert(cert.Leaf.Raw)
issuer := cert.Leaf
if len(cert.Certificate) > 1 {
issuer, err = x509.ParseCertificate(cert.Certificate[1])
if err != nil {
return nil, fmt.Errorf("parsing issuer certificate %s: %w", defaultCertificate.GetTruncatedCertificateName(), err)
}
}
if err := m.ocspStapler.Upsert(certHash, cert.Leaf, issuer); err != nil {
return nil, fmt.Errorf("upserting OCSP certificate %s: %w", defaultCertificate.GetTruncatedCertificateName(), err)
}
}
return &CertificateData{
Certificate: &cert,
Hash: certHash,
}, nil
} }
// creates a TLS config that allows terminating HTTPS for multiple domains using SNI. // creates a TLS config that allows terminating HTTPS for multiple domains using SNI.
@ -412,20 +516,10 @@ func buildTLSConfig(tlsOption Options) (*tls.Config, error) {
return conf, nil return conf, nil
} }
func buildDefaultCertificate(defaultCertificate *Certificate) (*tls.Certificate, error) { func hashRawCert(rawCert []byte) string {
certFile, err := defaultCertificate.CertFile.Read() hasher := fnv.New64()
if err != nil {
return nil, fmt.Errorf("failed to get cert file content: %w", err)
}
keyFile, err := defaultCertificate.KeyFile.Read() // purposely ignoring the error, as no error can be returned from the implementation.
if err != nil { _, _ = hasher.Write(rawCert)
return nil, fmt.Errorf("failed to get key file content: %w", err) return strconv.FormatUint(hasher.Sum64(), 16)
}
cert, err := tls.X509KeyPair(certFile, keyFile)
if err != nil {
return nil, fmt.Errorf("failed to load X509 key pair: %w", err)
}
return &cert, nil
} }

View File

@ -1,14 +1,22 @@
package tls package tls
import ( import (
"context"
"crypto"
"crypto/tls" "crypto/tls"
"crypto/x509" "crypto/x509"
"encoding/pem" "encoding/pem"
"io"
"net/http"
"net/http/httptest"
"testing" "testing"
"time"
"github.com/patrickmn/go-cache"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
"github.com/traefik/traefik/v3/pkg/types" "github.com/traefik/traefik/v3/pkg/types"
"golang.org/x/crypto/ocsp"
) )
// LocalhostCert is a PEM-encoded TLS cert with SAN IPs // LocalhostCert is a PEM-encoded TLS cert with SAN IPs
@ -76,10 +84,10 @@ func TestTLSInStore(t *testing.T) {
}, },
}} }}
tlsManager := NewManager() tlsManager := NewManager(nil)
tlsManager.UpdateConfigs(t.Context(), nil, nil, dynamicConfigs) tlsManager.UpdateConfigs(t.Context(), nil, nil, dynamicConfigs)
certs := tlsManager.GetStore("default").DynamicCerts.Get().(map[string]*tls.Certificate) certs := tlsManager.GetStore("default").DynamicCerts.Get().(map[string]*CertificateData)
if len(certs) == 0 { if len(certs) == 0 {
t.Fatal("got error: default store must have TLS certificates.") t.Fatal("got error: default store must have TLS certificates.")
} }
@ -93,7 +101,7 @@ func TestTLSInvalidStore(t *testing.T) {
}, },
}} }}
tlsManager := NewManager() tlsManager := NewManager(nil)
tlsManager.UpdateConfigs(t.Context(), tlsManager.UpdateConfigs(t.Context(),
map[string]Store{ map[string]Store{
"default": { "default": {
@ -104,7 +112,7 @@ func TestTLSInvalidStore(t *testing.T) {
}, },
}, nil, dynamicConfigs) }, nil, dynamicConfigs)
certs := tlsManager.GetStore("default").DynamicCerts.Get().(map[string]*tls.Certificate) certs := tlsManager.GetStore("default").DynamicCerts.Get().(map[string]*CertificateData)
if len(certs) == 0 { if len(certs) == 0 {
t.Fatal("got error: default store must have TLS certificates.") t.Fatal("got error: default store must have TLS certificates.")
} }
@ -157,7 +165,7 @@ func TestManager_Get(t *testing.T) {
}, },
} }
tlsManager := NewManager() tlsManager := NewManager(nil)
tlsManager.UpdateConfigs(t.Context(), nil, tlsConfigs, dynamicConfigs) tlsManager.UpdateConfigs(t.Context(), nil, tlsConfigs, dynamicConfigs)
for _, test := range testCases { for _, test := range testCases {
@ -296,7 +304,7 @@ func TestClientAuth(t *testing.T) {
}, },
} }
tlsManager := NewManager() tlsManager := NewManager(nil)
tlsManager.UpdateConfigs(t.Context(), nil, tlsConfigs, nil) tlsManager.UpdateConfigs(t.Context(), nil, tlsConfigs, nil)
for _, test := range testCases { for _, test := range testCases {
@ -323,8 +331,108 @@ func TestClientAuth(t *testing.T) {
} }
} }
func TestManager_UpdateConfigs_OCSPConfig(t *testing.T) {
leafCert, err := tls.X509KeyPair([]byte(certWithOCSPServer), []byte(certKey))
require.NoError(t, err)
issuerCert, err := tls.X509KeyPair([]byte(caCert), []byte(caKey))
require.NoError(t, err)
thisUpdate, err := time.Parse("2006-01-02", "2025-01-01")
require.NoError(t, err)
nextUpdate, err := time.Parse("2006-01-02", "2025-01-02")
require.NoError(t, err)
ocspResponseTmpl := ocsp.Response{
SerialNumber: leafCert.Leaf.SerialNumber,
TBSResponseData: []byte("foo"),
ThisUpdate: thisUpdate,
NextUpdate: nextUpdate,
}
ocspResponse, err := ocsp.CreateResponse(leafCert.Leaf, leafCert.Leaf, ocspResponseTmpl, issuerCert.PrivateKey.(crypto.Signer))
require.NoError(t, err)
responderCall := make(chan struct{})
handler := func(rw http.ResponseWriter, req *http.Request) {
ct := req.Header.Get("Content-Type")
assert.Equal(t, "application/ocsp-request", ct)
reqBytes, err := io.ReadAll(req.Body)
require.NoError(t, err)
_, err = ocsp.ParseRequest(reqBytes)
require.NoError(t, err)
rw.Header().Set("Content-Type", "application/ocsp-response")
_, err = rw.Write(ocspResponse)
require.NoError(t, err)
responderCall <- struct{}{}
}
responder := httptest.NewServer(http.HandlerFunc(handler))
t.Cleanup(responder.Close)
testContext, cancel := context.WithCancel(t.Context())
t.Cleanup(cancel)
tlsManager := NewManager(&OCSPConfig{
ResponderOverrides: map[string]string{
"ocsp.example.com": responder.URL,
},
})
go tlsManager.Run(testContext)
tlsManager.ocspStapler.cache.Set("existing", &ocspEntry{
leaf: leafCert.Leaf,
issuer: issuerCert.Leaf,
staple: []byte("foo"),
nextUpdate: time.Now().Add(time.Hour),
}, cache.NoExpiration)
tlsManager.ocspStapler.cache.Set("existingWithTTL", &ocspEntry{
leaf: leafCert.Leaf,
issuer: issuerCert.Leaf,
staple: []byte("foo"),
nextUpdate: time.Now().Add(time.Hour),
}, 2*defaultCacheDuration)
tlsManager.UpdateConfigs(testContext, nil, nil, []*CertAndStores{
{
Certificate: Certificate{
CertFile: certWithOCSPServer,
KeyFile: certKey,
},
},
})
// Asserting that UpdateConfigs resets the expiration for existing entries.
_, expiration, ok := tlsManager.ocspStapler.cache.GetWithExpiration("existing")
require.True(t, ok)
assert.Greater(t, expiration, time.Now())
// But not for entries with TTL already set.
_, expiration, ok = tlsManager.ocspStapler.cache.GetWithExpiration("existingWithTTL")
require.True(t, ok)
assert.Greater(t, expiration, time.Now().Add(defaultCacheDuration))
select {
case <-responderCall:
case <-time.After(3 * time.Second):
t.Fatal("Timeout waiting for OCSP responder call")
}
assert.Len(t, tlsManager.ocspStapler.cache.Items(), 3)
certHash := hashRawCert(leafCert.Leaf.Raw)
_, ok = tlsManager.ocspStapler.cache.Get(certHash)
require.True(t, ok)
}
func TestManager_Get_DefaultValues(t *testing.T) { func TestManager_Get_DefaultValues(t *testing.T) {
tlsManager := NewManager() tlsManager := NewManager(nil)
// Ensures we won't break things for Traefik users when updating Go // Ensures we won't break things for Traefik users when updating Go
config, _ := tlsManager.Get("default", "default") config, _ := tlsManager.Get("default", "default")