From b82694e9a4eb28f2b983002f4f018b9348a23869 Mon Sep 17 00:00:00 2001 From: mhenrixon Date: Sat, 27 Dec 2025 10:44:21 +0100 Subject: [PATCH 01/12] Add SAN certificate batching to prevent Let's Encrypt rate limits Batch up to 100 domains into a single SAN certificate, regardless of their root domain. This dramatically reduces certificate requests: - 1000 domains = 10 certificates instead of 1000 - Avoids the 300 orders/3 hours rate limit Features: - SANCertManager with HTTP-01 challenge support - Automatic domain batching (up to 100 per cert) - Certificate persistence and state management - CLI flags: --acme-email, --acme-directory --- README.md | 54 ++ go.mod | 13 +- go.sum | 26 +- internal/cmd/run.go | 4 + internal/cmd/util.go | 8 + internal/server/config.go | 8 + internal/server/domain_group.go | 137 +++++ internal/server/domain_group_test.go | 215 +++++++ internal/server/san_cert_manager.go | 678 +++++++++++++++++++++++ internal/server/san_cert_manager_test.go | 274 +++++++++ 10 files changed, 1408 insertions(+), 9 deletions(-) create mode 100644 internal/server/domain_group.go create mode 100644 internal/server/domain_group_test.go create mode 100644 internal/server/san_cert_manager.go create mode 100644 internal/server/san_cert_manager_test.go diff --git a/README.md b/README.md index 03d34de3..8df2d2d1 100644 --- a/README.md +++ b/README.md @@ -143,6 +143,60 @@ your certificate file and the corresponding private key: kamal-proxy deploy service1 --target web-1:3000 --host app1.example.com --tls --tls-certificate-path cert.pem --tls-private-key-path key.pem +### SAN Certificate Batching + +Kamal Proxy automatically batches multiple domains into a single SAN (Subject +Alternative Name) certificate. This dramatically reduces the number of certificates +needed and helps avoid Let's Encrypt rate limits. + +**How it works:** + +1. When services with TLS enabled are deployed, domains are queued for certificate provisioning +2. All pending domains (up to 100) are batched into a single certificate request +3. The resulting SAN certificate covers all domains, regardless of their root domain + +**Example:** + +```bash +kamal-proxy deploy app1 --target web-1:3000 --host app.example.com --tls +kamal-proxy deploy app2 --target web-2:3000 --host api.other.org --tls +kamal-proxy deploy app3 --target web-3:3000 --host mysite.net --tls +# → All three services share a single certificate with SANs: +# app.example.com, api.other.org, mysite.net +``` + +**Rate limit impact:** + +| Domains | Without batching | With SAN batching | +|---------|------------------|-------------------| +| 10 | 10 certificates | 1 certificate | +| 100 | 100 certificates | 1 certificate | +| 1000 | 1000 certificates| 10 certificates | + +**Benefits:** + +- **Dramatic reduction**: Up to 100 domains per certificate +- **Rate limit friendly**: 1000 domains = 10 certs instead of 1000 +- **Any domains**: Works across different root domains +- **Zero configuration**: Just deploy with `--tls` as usual + +**Configuration options:** + +| Flag | Environment Variable | Default | Description | +|------|---------------------|---------|-------------| +| `--acme-email` | `ACME_EMAIL` | (required for TLS) | Contact email for Let's Encrypt | +| `--acme-directory` | `ACME_DIRECTORY` | Let's Encrypt production | ACME directory URL | + +**Using Let's Encrypt staging environment:** + +For testing, use the staging environment to avoid rate limits: + +```bash +kamal-proxy run --acme-email admin@example.com \ + --acme-directory https://acme-staging-v02.api.letsencrypt.org/directory +``` + + ## Specifying `run` options with environment variables In some environments, like when running a Docker container, it can be convenient diff --git a/go.mod b/go.mod index 63fbb053..6963d8dc 100644 --- a/go.mod +++ b/go.mod @@ -4,31 +4,38 @@ go 1.25.5 require ( github.com/coder/websocket v1.8.12 + github.com/go-acme/lego/v4 v4.30.1 github.com/google/uuid v1.6.0 github.com/prometheus/client_golang v1.23.2 github.com/quic-go/quic-go v0.57.1 github.com/spf13/cobra v1.10.2 github.com/stretchr/testify v1.11.1 golang.org/x/crypto v0.46.0 + golang.org/x/net v0.48.0 ) require ( github.com/beorn7/perks v1.0.1 // indirect + github.com/cenkalti/backoff/v5 v5.0.3 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect - github.com/davecgh/go-spew v1.1.1 // indirect + github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect + github.com/go-jose/go-jose/v4 v4.1.3 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/kr/text v0.2.0 // indirect + github.com/miekg/dns v1.1.69 // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect - github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/prometheus/client_model v0.6.2 // indirect github.com/prometheus/common v0.67.4 // indirect github.com/prometheus/procfs v0.19.2 // indirect github.com/quic-go/qpack v0.6.0 // indirect github.com/spf13/pflag v1.0.10 // indirect go.yaml.in/yaml/v2 v2.4.3 // indirect - golang.org/x/net v0.48.0 // indirect + golang.org/x/mod v0.30.0 // indirect + golang.org/x/sync v0.19.0 // indirect golang.org/x/sys v0.39.0 // indirect golang.org/x/text v0.32.0 // indirect + golang.org/x/tools v0.39.0 // indirect google.golang.org/protobuf v1.36.11 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index dbb6cdce..c7d45b85 100644 --- a/go.sum +++ b/go.sum @@ -1,13 +1,19 @@ github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= +github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM= +github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/coder/websocket v1.8.12 h1:5bUXkEPPIbewrnkU8LTCLVaxi4N4J8ahufH2vlo4NAo= github.com/coder/websocket v1.8.12/go.mod h1:LNVeNrXQZfe5qhS9ALED3uA+l5pPqvwXg3CKoDBB2gs= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= -github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= -github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/go-acme/lego/v4 v4.30.1 h1:tmb6U0lvy8Mc3lQbqKwTat7oAhE8FUYNJ3D0gSg6pJU= +github.com/go-acme/lego/v4 v4.30.1/go.mod h1:V7m/Ip+EeFkjOe028+zeH+SwWtESxw1LHelwMIfAjm4= +github.com/go-jose/go-jose/v4 v4.1.3 h1:CVLmWDhDVRa6Mi/IgCgaopNosCaHz7zrMeF9MlZRkrs= +github.com/go-jose/go-jose/v4 v4.1.3/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= @@ -22,10 +28,12 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= +github.com/miekg/dns v1.1.69 h1:Kb7Y/1Jo+SG+a2GtfoFUfDkG//csdRPwRLkCsxDG9Sc= +github.com/miekg/dns v1.1.69/go.mod h1:7OyjD9nEba5OkqQ/hB4fy3PIoxafSZJtducccIelz3g= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= -github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= -github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o= github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg= github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk= @@ -57,14 +65,20 @@ go.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= golang.org/x/crypto v0.46.0 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU= golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0= +golang.org/x/mod v0.30.0 h1:fDEXFVZ/fmCKProc/yAXXUijritrDzahmwwefnjoPFk= +golang.org/x/mod v0.30.0/go.mod h1:lAsf5O2EvJeSFMiBxXDki7sCgAxEUcZHXoXMKT4GJKc= golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU= golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY= +golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= +golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk= golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU= golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY= -golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE= -golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg= +golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI= +golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4= +golang.org/x/tools v0.39.0 h1:ik4ho21kwuQln40uelmciQPp9SipgNDdrafrYA4TmQQ= +golang.org/x/tools v0.39.0/go.mod h1:JnefbkDPyD8UU2kI5fuf8ZX4/yUeh9W877ZeBONxUqQ= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/internal/cmd/run.go b/internal/cmd/run.go index 0a70655e..4dbbcd1e 100644 --- a/internal/cmd/run.go +++ b/internal/cmd/run.go @@ -30,6 +30,10 @@ func newRunCommand() *runCommand { runCommand.cmd.Flags().IntVar(&globalConfig.MetricsPort, "metrics-port", getEnvInt("METRICS_PORT", 0), "Publish metrics on the specified port (default zero to disable)") runCommand.cmd.Flags().BoolVar(&globalConfig.HTTP3Enabled, "http3", false, "Enable HTTP/3") + // ACME/TLS configuration + runCommand.cmd.Flags().StringVar(&globalConfig.ACMEEmail, "acme-email", getEnvString("ACME_EMAIL", ""), "Email address for ACME account registration (required for automatic TLS)") + runCommand.cmd.Flags().StringVar(&globalConfig.ACMEDirectory, "acme-directory", getEnvString("ACME_DIRECTORY", server.LetsEncryptProduction), "ACME directory URL") + return runCommand } diff --git a/internal/cmd/util.go b/internal/cmd/util.go index d28618c6..1b440f8f 100644 --- a/internal/cmd/util.go +++ b/internal/cmd/util.go @@ -60,3 +60,11 @@ func getEnvBool(key string, defaultValue bool) bool { return boolValue } + +func getEnvString(key string, defaultValue string) string { + value, ok := findEnv(key) + if !ok { + return defaultValue + } + return value +} diff --git a/internal/server/config.go b/internal/server/config.go index 03148253..ebd05997 100644 --- a/internal/server/config.go +++ b/internal/server/config.go @@ -20,6 +20,10 @@ type Config struct { HTTP3Enabled bool AlternateConfigDir string + + // ACME configuration for automatic TLS + ACMEEmail string + ACMEDirectory string } func (c Config) SocketPath() string { @@ -34,6 +38,10 @@ func (c Config) CertificatePath() string { return path.Join(c.dataDirectory(), "certs") } +func (c Config) ACMEStatePath() string { + return path.Join(c.dataDirectory(), "acme.state") +} + // Private func (c Config) runtimeDirectory() string { diff --git a/internal/server/domain_group.go b/internal/server/domain_group.go new file mode 100644 index 00000000..ac691e2a --- /dev/null +++ b/internal/server/domain_group.go @@ -0,0 +1,137 @@ +package server + +import ( + "sort" + "strings" + + "golang.org/x/net/publicsuffix" +) + +// DomainGroup represents a group of domains that share the same root domain +// and can be included in a single SAN certificate +type DomainGroup struct { + // RootDomain is the registrable domain (e.g., "example.com") + RootDomain string + + // Domains contains all full domain names in this group + Domains []string + + // IncludesApex is true if the apex domain itself is in the group + IncludesApex bool +} + +// DomainGrouper groups domains by their root domain for efficient certificate management +type DomainGrouper struct { + // MinDomainsForBatching is the minimum number of domains needed to batch into a SAN cert + // Default is 2 (batch when we have 2+ domains for the same root) + MinDomainsForBatching int +} + +// NewDomainGrouper creates a new DomainGrouper with default settings +func NewDomainGrouper() *DomainGrouper { + return &DomainGrouper{ + MinDomainsForBatching: 2, + } +} + +// GroupDomains analyzes a list of domains and groups them by root domain +func (g *DomainGrouper) GroupDomains(domains []string) []*DomainGroup { + if len(domains) == 0 { + return nil + } + + // Group domains by their root domain + groups := make(map[string]*DomainGroup) + + for _, domain := range domains { + domain = strings.ToLower(strings.TrimSpace(domain)) + if domain == "" { + continue + } + + rootDomain, err := publicsuffix.EffectiveTLDPlusOne(domain) + if err != nil { + // If we can't determine the root, treat domain as its own root + rootDomain = domain + } + + group, exists := groups[rootDomain] + if !exists { + group = &DomainGroup{ + RootDomain: rootDomain, + Domains: []string{}, + } + groups[rootDomain] = group + } + + // Check if this is the apex domain + if domain == rootDomain { + group.IncludesApex = true + } + + // Add domain if not already present + if !contains(group.Domains, domain) { + group.Domains = append(group.Domains, domain) + } + } + + // Convert map to sorted slice + result := make([]*DomainGroup, 0, len(groups)) + for _, group := range groups { + // Sort domains within each group for consistent ordering + sort.Strings(group.Domains) + result = append(result, group) + } + + // Sort groups by root domain for consistent ordering + sort.Slice(result, func(i, j int) bool { + return result[i].RootDomain < result[j].RootDomain + }) + + return result +} + +// ShouldBatch returns true if the group has enough domains to warrant batching +func (g *DomainGrouper) ShouldBatch(group *DomainGroup) bool { + return len(group.Domains) >= g.MinDomainsForBatching +} + +// GetDomainsForCert returns the domains that should be included in the certificate +// For SAN certificates, this is simply all domains in the group +func (group *DomainGroup) GetDomainsForCert() []string { + return group.Domains +} + +// CertificateIdentifier returns a unique identifier for this group's certificate +func (group *DomainGroup) CertificateIdentifier() string { + if len(group.Domains) == 1 { + return "single:" + group.Domains[0] + } + return "san:" + group.RootDomain +} + +// MatchesDomain checks if a domain belongs to this group +func (group *DomainGroup) MatchesDomain(domain string) bool { + domain = strings.ToLower(domain) + for _, d := range group.Domains { + if d == domain { + return true + } + } + return false +} + +// GetRootDomain extracts the registrable domain from a full domain name +// e.g., "app.example.com" -> "example.com", "www.example.co.uk" -> "example.co.uk" +func GetRootDomain(domain string) (string, error) { + return publicsuffix.EffectiveTLDPlusOne(strings.ToLower(domain)) +} + +func contains(slice []string, item string) bool { + for _, s := range slice { + if s == item { + return true + } + } + return false +} diff --git a/internal/server/domain_group_test.go b/internal/server/domain_group_test.go new file mode 100644 index 00000000..240ffe89 --- /dev/null +++ b/internal/server/domain_group_test.go @@ -0,0 +1,215 @@ +package server + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestDomainGrouper_GroupDomains_Empty(t *testing.T) { + grouper := NewDomainGrouper() + groups := grouper.GroupDomains(nil) + assert.Nil(t, groups) + + groups = grouper.GroupDomains([]string{}) + assert.Nil(t, groups) +} + +func TestDomainGrouper_GroupDomains_SingleDomain(t *testing.T) { + grouper := NewDomainGrouper() + groups := grouper.GroupDomains([]string{"app.example.com"}) + + require.Len(t, groups, 1) + assert.Equal(t, "example.com", groups[0].RootDomain) + assert.Equal(t, []string{"app.example.com"}, groups[0].Domains) + assert.False(t, groups[0].IncludesApex) +} + +func TestDomainGrouper_GroupDomains_ApexDomain(t *testing.T) { + grouper := NewDomainGrouper() + groups := grouper.GroupDomains([]string{"example.com"}) + + require.Len(t, groups, 1) + assert.Equal(t, "example.com", groups[0].RootDomain) + assert.Equal(t, []string{"example.com"}, groups[0].Domains) + assert.True(t, groups[0].IncludesApex) +} + +func TestDomainGrouper_GroupDomains_MultipleSubdomains(t *testing.T) { + grouper := NewDomainGrouper() + groups := grouper.GroupDomains([]string{ + "app.example.com", + "api.example.com", + "www.example.com", + }) + + require.Len(t, groups, 1) + assert.Equal(t, "example.com", groups[0].RootDomain) + assert.Len(t, groups[0].Domains, 3) + assert.Contains(t, groups[0].Domains, "app.example.com") + assert.Contains(t, groups[0].Domains, "api.example.com") + assert.Contains(t, groups[0].Domains, "www.example.com") + assert.False(t, groups[0].IncludesApex) +} + +func TestDomainGrouper_GroupDomains_ApexWithSubdomains(t *testing.T) { + grouper := NewDomainGrouper() + groups := grouper.GroupDomains([]string{ + "example.com", + "www.example.com", + "api.example.com", + }) + + require.Len(t, groups, 1) + assert.Equal(t, "example.com", groups[0].RootDomain) + assert.Len(t, groups[0].Domains, 3) + assert.True(t, groups[0].IncludesApex) +} + +func TestDomainGrouper_GroupDomains_MultipleDomains(t *testing.T) { + grouper := NewDomainGrouper() + groups := grouper.GroupDomains([]string{ + "app.example.com", + "api.example.com", + "app.other.com", + "www.other.com", + }) + + require.Len(t, groups, 2) + + // Groups are sorted by root domain + assert.Equal(t, "example.com", groups[0].RootDomain) + assert.Len(t, groups[0].Domains, 2) + + assert.Equal(t, "other.com", groups[1].RootDomain) + assert.Len(t, groups[1].Domains, 2) +} + +func TestDomainGrouper_GroupDomains_MultiLevelSubdomain(t *testing.T) { + grouper := NewDomainGrouper() + groups := grouper.GroupDomains([]string{ + "deep.nested.example.com", + "app.example.com", + }) + + require.Len(t, groups, 1) + assert.Equal(t, "example.com", groups[0].RootDomain) + assert.Len(t, groups[0].Domains, 2) +} + +func TestDomainGrouper_GroupDomains_PublicSuffix(t *testing.T) { + grouper := NewDomainGrouper() + groups := grouper.GroupDomains([]string{ + "app.example.co.uk", + "www.example.co.uk", + }) + + require.Len(t, groups, 1) + assert.Equal(t, "example.co.uk", groups[0].RootDomain) + assert.Len(t, groups[0].Domains, 2) +} + +func TestDomainGrouper_GroupDomains_Deduplication(t *testing.T) { + grouper := NewDomainGrouper() + groups := grouper.GroupDomains([]string{ + "app.example.com", + "APP.EXAMPLE.COM", // Duplicate with different case + "app.example.com", // Exact duplicate + }) + + require.Len(t, groups, 1) + assert.Len(t, groups[0].Domains, 1) + assert.Equal(t, "app.example.com", groups[0].Domains[0]) +} + +func TestDomainGrouper_ShouldBatch(t *testing.T) { + grouper := NewDomainGrouper() + + // Single domain - no batch + group1 := &DomainGroup{Domains: []string{"app.example.com"}} + assert.False(t, grouper.ShouldBatch(group1)) + + // Two domains - should batch + group2 := &DomainGroup{Domains: []string{"app.example.com", "api.example.com"}} + assert.True(t, grouper.ShouldBatch(group2)) +} + +func TestDomainGroup_GetDomainsForCert(t *testing.T) { + group := &DomainGroup{ + RootDomain: "example.com", + Domains: []string{"app.example.com", "api.example.com"}, + } + + domains := group.GetDomainsForCert() + assert.Equal(t, []string{"app.example.com", "api.example.com"}, domains) +} + +func TestDomainGroup_CertificateIdentifier(t *testing.T) { + tests := []struct { + name string + domains []string + expected string + }{ + { + name: "single domain", + domains: []string{"app.example.com"}, + expected: "single:app.example.com", + }, + { + name: "multiple domains", + domains: []string{"app.example.com", "api.example.com"}, + expected: "san:example.com", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + group := &DomainGroup{ + RootDomain: "example.com", + Domains: tt.domains, + } + assert.Equal(t, tt.expected, group.CertificateIdentifier()) + }) + } +} + +func TestDomainGroup_MatchesDomain(t *testing.T) { + group := &DomainGroup{ + RootDomain: "example.com", + Domains: []string{"app.example.com", "api.example.com"}, + } + + assert.True(t, group.MatchesDomain("app.example.com")) + assert.True(t, group.MatchesDomain("API.EXAMPLE.COM")) + assert.False(t, group.MatchesDomain("www.example.com")) + assert.False(t, group.MatchesDomain("app.other.com")) +} + +func TestGetRootDomain(t *testing.T) { + tests := []struct { + domain string + expected string + hasErr bool + }{ + {"app.example.com", "example.com", false}, + {"api.example.com", "example.com", false}, + {"deep.nested.example.com", "example.com", false}, + {"example.com", "example.com", false}, + {"app.example.co.uk", "example.co.uk", false}, + {"www.example.co.uk", "example.co.uk", false}, + {"APP.EXAMPLE.COM", "example.com", false}, + } + + for _, tt := range tests { + t.Run(tt.domain, func(t *testing.T) { + result, err := GetRootDomain(tt.domain) + if tt.hasErr { + require.Error(t, err) + } else { + require.NoError(t, err) + assert.Equal(t, tt.expected, result) + } + }) + } +} diff --git a/internal/server/san_cert_manager.go b/internal/server/san_cert_manager.go new file mode 100644 index 00000000..058380bb --- /dev/null +++ b/internal/server/san_cert_manager.go @@ -0,0 +1,678 @@ +package server + +import ( + "context" + "crypto" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/tls" + "encoding/json" + "errors" + "fmt" + "log/slog" + "net/http" + "os" + "path/filepath" + "sync" + "time" + + "github.com/go-acme/lego/v4/certcrypto" + "github.com/go-acme/lego/v4/certificate" + "github.com/go-acme/lego/v4/lego" + "github.com/go-acme/lego/v4/registration" +) + +const ( + // LetsEncryptProduction is the production ACME directory + LetsEncryptProduction = "https://acme-v02.api.letsencrypt.org/directory" + // LetsEncryptStaging is the staging ACME directory for testing + LetsEncryptStaging = "https://acme-staging-v02.api.letsencrypt.org/directory" + + // MaxSANsPerCertificate is the maximum number of SANs allowed per certificate + // Let's Encrypt limit is 100 + MaxSANsPerCertificate = 100 +) + +var ( + ErrNoDomains = errors.New("no domains to provision") + ErrCertNotFound = errors.New("certificate not found") + ErrManagerNotReady = errors.New("certificate manager not initialized") + ErrProvisioningFailed = errors.New("certificate provisioning failed") +) + +// SANCertManagerConfig holds configuration for the SAN certificate manager +type SANCertManagerConfig struct { + // Email is the ACME account email (required) + Email string + + // Directory is the ACME directory URL (defaults to Let's Encrypt production) + Directory string + + // CachePath is where certificates are stored + CachePath string + + // StatePath is where manager state is persisted + StatePath string + + // BatchDelay is how long to wait for more domains before provisioning + // This allows batching multiple service deployments together + BatchDelay time.Duration +} + +// SANCertManager manages SAN certificates with domain batching +// It groups domains by their root domain and provisions a single certificate +// for all subdomains, reducing the number of certificates needed +type SANCertManager struct { + mu sync.RWMutex + config SANCertManagerConfig + + // Domain grouper + grouper *DomainGrouper + + // ACME client + client *lego.Client + user *acmeUser + + // Certificate storage: certID -> certificate + certificates map[string]*ManagedCert + + // Domain to certificate mapping + domainToCert map[string]string + + // Pending domains waiting to be batched: domain -> service name + pendingDomains map[string]string + + // Currently provisioning: rootDomain -> done channel + provisioning map[string]chan struct{} + + // HTTP-01 challenge tokens: token -> key authorization + challengeTokens map[string]string + + // State + ready bool +} + +// ManagedCert represents a certificate managed by the manager +type ManagedCert struct { + Identifier string `json:"identifier"` + Domains []string `json:"domains"` + NotAfter time.Time `json:"not_after"` + Certificate *tls.Certificate `json:"-"` // Not persisted, loaded from files +} + +// acmeUser implements registration.User for lego +type acmeUser struct { + Email string `json:"email"` + Registration *registration.Resource `json:"registration"` + Key *ecdsa.PrivateKey `json:"-"` + KeyPEM []byte `json:"key_pem"` +} + +func (u *acmeUser) GetEmail() string { return u.Email } +func (u *acmeUser) GetRegistration() *registration.Resource { return u.Registration } +func (u *acmeUser) GetPrivateKey() crypto.PrivateKey { return u.Key } + +// NewSANCertManager creates a new SAN certificate manager +func NewSANCertManager(config SANCertManagerConfig) (*SANCertManager, error) { + if config.Email == "" { + return nil, errors.New("email is required for ACME registration") + } + + if config.Directory == "" { + config.Directory = LetsEncryptProduction + } + + if config.BatchDelay == 0 { + config.BatchDelay = 5 * time.Second + } + + manager := &SANCertManager{ + config: config, + grouper: NewDomainGrouper(), + certificates: make(map[string]*ManagedCert), + domainToCert: make(map[string]string), + pendingDomains: make(map[string]string), + provisioning: make(map[string]chan struct{}), + challengeTokens: make(map[string]string), + } + + // Ensure cache directory exists + if config.CachePath != "" { + if err := os.MkdirAll(config.CachePath, 0700); err != nil { + return nil, fmt.Errorf("failed to create cache directory: %w", err) + } + } + + return manager, nil +} + +// Initialize sets up the ACME client and loads persisted state +func (m *SANCertManager) Initialize(ctx context.Context) error { + m.mu.Lock() + defer m.mu.Unlock() + + // Load or create ACME user + user, err := m.loadOrCreateUser() + if err != nil { + return fmt.Errorf("failed to setup ACME user: %w", err) + } + m.user = user + + // Create lego config + legoConfig := lego.NewConfig(user) + legoConfig.CADirURL = m.config.Directory + legoConfig.Certificate.KeyType = certcrypto.EC256 + + // Create ACME client + client, err := lego.NewClient(legoConfig) + if err != nil { + return fmt.Errorf("failed to create ACME client: %w", err) + } + + // Setup HTTP-01 challenge provider using our custom provider + httpProvider := &memoryHTTP01Provider{manager: m} + if err := client.Challenge.SetHTTP01Provider(httpProvider); err != nil { + return fmt.Errorf("failed to set HTTP-01 provider: %w", err) + } + + m.client = client + + // Register with ACME if not already registered + if user.Registration == nil { + reg, err := client.Registration.Register(registration.RegisterOptions{ + TermsOfServiceAgreed: true, + }) + if err != nil { + return fmt.Errorf("failed to register with ACME: %w", err) + } + user.Registration = reg + + // Save user with registration + if err := m.saveUser(); err != nil { + slog.Warn("Failed to save ACME user", "error", err) + } + } + + // Load persisted state + if err := m.loadState(); err != nil { + slog.Warn("Failed to load certificate state", "error", err) + } + + m.ready = true + slog.Info("SAN certificate manager initialized", + "email", m.config.Email, + "directory", m.config.Directory, + ) + + return nil +} + +// memoryHTTP01Provider is a custom HTTP-01 challenge provider that stores tokens in memory +type memoryHTTP01Provider struct { + manager *SANCertManager +} + +func (p *memoryHTTP01Provider) Present(domain, token, keyAuth string) error { + p.manager.mu.Lock() + p.manager.challengeTokens[token] = keyAuth + p.manager.mu.Unlock() + slog.Debug("HTTP-01 challenge presented", "domain", domain, "token", token) + return nil +} + +func (p *memoryHTTP01Provider) CleanUp(domain, token, keyAuth string) error { + p.manager.mu.Lock() + delete(p.manager.challengeTokens, token) + p.manager.mu.Unlock() + slog.Debug("HTTP-01 challenge cleaned up", "domain", domain, "token", token) + return nil +} + +// RegisterDomain registers a domain for certificate management +func (m *SANCertManager) RegisterDomain(domain string, service string) error { + m.mu.Lock() + defer m.mu.Unlock() + + if !m.ready { + return ErrManagerNotReady + } + + // Check if domain already has a certificate + if certID, ok := m.domainToCert[domain]; ok { + cert := m.certificates[certID] + if cert != nil && time.Until(cert.NotAfter) > 24*time.Hour { + slog.Debug("Domain already has valid certificate", + "domain", domain, + "certificate", certID, + ) + return nil + } + } + + // Check if domain is covered by an existing SAN certificate + for _, cert := range m.certificates { + for _, d := range cert.Domains { + if d == domain { + m.domainToCert[domain] = cert.Identifier + slog.Debug("Domain covered by existing SAN certificate", + "domain", domain, + "certificate", cert.Identifier, + ) + return nil + } + } + } + + // Add to pending domains for batched provisioning + m.pendingDomains[domain] = service + slog.Debug("Domain added to pending batch", + "domain", domain, + "service", service, + "pending_count", len(m.pendingDomains), + ) + + return nil +} + +// UnregisterDomain removes a domain from management +func (m *SANCertManager) UnregisterDomain(domain string, service string) error { + m.mu.Lock() + defer m.mu.Unlock() + + delete(m.pendingDomains, domain) + return nil +} + +// GetCertificate returns a certificate for the TLS handshake +func (m *SANCertManager) GetCertificate(hello *tls.ClientHelloInfo) (*tls.Certificate, error) { + domain := hello.ServerName + if domain == "" { + return nil, errors.New("no server name provided") + } + + m.mu.RLock() + ready := m.ready + certID, hasCert := m.domainToCert[domain] + var cert *ManagedCert + if hasCert { + cert = m.certificates[certID] + } + m.mu.RUnlock() + + if !ready { + return nil, ErrManagerNotReady + } + + // Return existing valid certificate + if cert != nil && cert.Certificate != nil { + if time.Until(cert.NotAfter) > 24*time.Hour { + return cert.Certificate, nil + } + slog.Info("Certificate expiring soon, will reprovision", + "domain", domain, + "expiresAt", cert.NotAfter, + ) + } + + // Need to provision certificate + return m.provisionCertificate(hello.Context(), domain) +} + +// provisionCertificate provisions a certificate for a domain +// It batches together ALL pending domains (up to MaxSANsPerCertificate) into a single cert +// This minimizes the number of certificates and avoids rate limits +func (m *SANCertManager) provisionCertificate(ctx context.Context, domain string) (*tls.Certificate, error) { + // Use a single provisioning lock - we batch everything together + const provisioningKey = "_batch_" + + m.mu.Lock() + if done, ok := m.provisioning[provisioningKey]; ok { + m.mu.Unlock() + // Wait for existing provisioning to complete + select { + case <-done: + return m.getCertForDomain(domain) + case <-ctx.Done(): + return nil, ctx.Err() + } + } + + // Collect ALL pending domains (up to MaxSANsPerCertificate) + domainsToProvision := []string{domain} + for pendingDomain := range m.pendingDomains { + if pendingDomain != domain { + domainsToProvision = append(domainsToProvision, pendingDomain) + } + if len(domainsToProvision) >= MaxSANsPerCertificate { + break + } + } + + // Start provisioning + done := make(chan struct{}) + m.provisioning[provisioningKey] = done + + // Remove domains from pending + for _, d := range domainsToProvision { + delete(m.pendingDomains, d) + } + m.mu.Unlock() + + defer func() { + m.mu.Lock() + delete(m.provisioning, provisioningKey) + close(done) + m.mu.Unlock() + }() + + // Sort domains for consistent certificate identifiers + sortedDomains := make([]string, len(domainsToProvision)) + copy(sortedDomains, domainsToProvision) + sortStrings(sortedDomains) + + slog.Info("Provisioning SAN certificate", + "domains", sortedDomains, + "batch_size", len(sortedDomains), + ) + + // Request certificate for all domains + request := certificate.ObtainRequest{ + Domains: sortedDomains, + Bundle: true, + } + + resource, err := m.client.Certificate.Obtain(request) + if err != nil { + return nil, fmt.Errorf("failed to obtain certificate: %w", err) + } + + // Parse the certificate + tlsCert, err := tls.X509KeyPair(resource.Certificate, resource.PrivateKey) + if err != nil { + return nil, fmt.Errorf("failed to parse certificate: %w", err) + } + + // Determine expiry from leaf certificate + var notAfter time.Time + if tlsCert.Leaf != nil { + notAfter = tlsCert.Leaf.NotAfter + } else { + notAfter = time.Now().Add(90 * 24 * time.Hour) // Approximate + } + + // Generate certificate ID from first domain (sorted) + certID := fmt.Sprintf("san:%d:%s", len(sortedDomains), sortedDomains[0]) + managed := &ManagedCert{ + Identifier: certID, + Domains: sortedDomains, + NotAfter: notAfter, + Certificate: &tlsCert, + } + + m.mu.Lock() + m.certificates[certID] = managed + for _, d := range sortedDomains { + m.domainToCert[d] = certID + } + m.mu.Unlock() + + // Save certificate to disk + if err := m.saveCertificate(certID, resource); err != nil { + slog.Warn("Failed to save certificate", "error", err) + } + + // Persist state + if err := m.saveState(); err != nil { + slog.Warn("Failed to save state", "error", err) + } + + slog.Info("Certificate provisioned successfully", + "identifier", certID, + "domains", sortedDomains, + "batch_size", len(sortedDomains), + "expires", notAfter, + ) + + return &tlsCert, nil +} + +// sortStrings sorts a slice of strings in place +func sortStrings(s []string) { + for i := 0; i < len(s)-1; i++ { + for j := i + 1; j < len(s); j++ { + if s[i] > s[j] { + s[i], s[j] = s[j], s[i] + } + } + } +} + +// getCertForDomain retrieves a certificate for a domain +func (m *SANCertManager) getCertForDomain(domain string) (*tls.Certificate, error) { + m.mu.RLock() + defer m.mu.RUnlock() + + certID, ok := m.domainToCert[domain] + if !ok { + return nil, ErrCertNotFound + } + + cert := m.certificates[certID] + if cert == nil || cert.Certificate == nil { + return nil, ErrCertNotFound + } + + return cert.Certificate, nil +} + +// HTTPChallengeHandler returns the HTTP handler for ACME challenges +func (m *SANCertManager) HTTPChallengeHandler(fallback http.Handler) http.Handler { + const challengePrefix = "/.well-known/acme-challenge/" + + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Check if this is an ACME challenge request + if len(r.URL.Path) > len(challengePrefix) && r.URL.Path[:len(challengePrefix)] == challengePrefix { + token := r.URL.Path[len(challengePrefix):] + + m.mu.RLock() + keyAuth, ok := m.challengeTokens[token] + m.mu.RUnlock() + + if ok { + slog.Debug("Serving HTTP-01 challenge", "token", token) + w.Header().Set("Content-Type", "text/plain") + w.Write([]byte(keyAuth)) + return + } + + slog.Debug("HTTP-01 challenge token not found", "token", token) + } + + fallback.ServeHTTP(w, r) + }) +} + +// GetStats returns statistics about the manager +func (m *SANCertManager) GetStats() map[string]interface{} { + m.mu.RLock() + defer m.mu.RUnlock() + + expiringCount := 0 + for _, cert := range m.certificates { + if time.Until(cert.NotAfter) < 30*24*time.Hour { + expiringCount++ + } + } + + return map[string]interface{}{ + "ready": m.ready, + "total_certificates": len(m.certificates), + "domains_mapped": len(m.domainToCert), + "pending_domains": len(m.pendingDomains), + "expiring_soon": expiringCount, + "provisioning_active": len(m.provisioning), + } +} + +// Persistence methods + +func (m *SANCertManager) loadOrCreateUser() (*acmeUser, error) { + userPath := filepath.Join(m.config.CachePath, "acme_user.json") + + data, err := os.ReadFile(userPath) + if err == nil { + var user acmeUser + if err := json.Unmarshal(data, &user); err == nil { + // Decode the private key + key, err := certcrypto.ParsePEMPrivateKey(user.KeyPEM) + if err == nil { + if ecKey, ok := key.(*ecdsa.PrivateKey); ok { + user.Key = ecKey + return &user, nil + } + } + } + } + + // Create new user + privateKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + return nil, fmt.Errorf("failed to generate private key: %w", err) + } + + user := &acmeUser{ + Email: m.config.Email, + Key: privateKey, + } + + return user, nil +} + +func (m *SANCertManager) saveUser() error { + if m.config.CachePath == "" { + return nil + } + + keyPEM := certcrypto.PEMEncode(m.user.Key) + m.user.KeyPEM = keyPEM + + data, err := json.MarshalIndent(m.user, "", " ") + if err != nil { + return err + } + + userPath := filepath.Join(m.config.CachePath, "acme_user.json") + return os.WriteFile(userPath, data, 0600) +} + +func (m *SANCertManager) saveCertificate(certID string, resource *certificate.Resource) error { + if m.config.CachePath == "" { + return nil + } + + certDir := filepath.Join(m.config.CachePath, "certs", sanitizeFilename(certID)) + if err := os.MkdirAll(certDir, 0700); err != nil { + return err + } + + // Save certificate + if err := os.WriteFile(filepath.Join(certDir, "cert.pem"), resource.Certificate, 0600); err != nil { + return err + } + + // Save private key + if err := os.WriteFile(filepath.Join(certDir, "key.pem"), resource.PrivateKey, 0600); err != nil { + return err + } + + return nil +} + +type managerState struct { + Certificates map[string]*ManagedCert `json:"certificates"` + DomainMap map[string]string `json:"domain_map"` + SavedAt time.Time `json:"saved_at"` +} + +func (m *SANCertManager) loadState() error { + if m.config.StatePath == "" { + return nil + } + + data, err := os.ReadFile(m.config.StatePath) + if err != nil { + if os.IsNotExist(err) { + return nil + } + return err + } + + var state managerState + if err := json.Unmarshal(data, &state); err != nil { + return err + } + + // Load certificates from disk + for id, cert := range state.Certificates { + if m.config.CachePath != "" { + certDir := filepath.Join(m.config.CachePath, "certs", sanitizeFilename(id)) + certPath := filepath.Join(certDir, "cert.pem") + keyPath := filepath.Join(certDir, "key.pem") + + tlsCert, err := tls.LoadX509KeyPair(certPath, keyPath) + if err == nil { + cert.Certificate = &tlsCert + } + } + m.certificates[id] = cert + } + + m.domainToCert = state.DomainMap + + slog.Info("Loaded certificate manager state", + "certificates", len(m.certificates), + "domains", len(m.domainToCert), + ) + + return nil +} + +func (m *SANCertManager) saveState() error { + if m.config.StatePath == "" { + return nil + } + + m.mu.RLock() + state := managerState{ + Certificates: m.certificates, + DomainMap: m.domainToCert, + SavedAt: time.Now(), + } + m.mu.RUnlock() + + data, err := json.MarshalIndent(state, "", " ") + if err != nil { + return err + } + + tmpPath := m.config.StatePath + ".tmp" + if err := os.WriteFile(tmpPath, data, 0600); err != nil { + return err + } + + return os.Rename(tmpPath, m.config.StatePath) +} + +func sanitizeFilename(s string) string { + result := make([]byte, 0, len(s)) + for i := 0; i < len(s); i++ { + c := s[i] + if (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == '-' || c == '_' || c == '.' { + result = append(result, c) + } else { + result = append(result, '_') + } + } + return string(result) +} diff --git a/internal/server/san_cert_manager_test.go b/internal/server/san_cert_manager_test.go new file mode 100644 index 00000000..00168704 --- /dev/null +++ b/internal/server/san_cert_manager_test.go @@ -0,0 +1,274 @@ +package server + +import ( + "net/http" + "net/http/httptest" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestNewSANCertManager(t *testing.T) { + tmpDir := t.TempDir() + + config := SANCertManagerConfig{ + Email: "test@example.com", + Directory: LetsEncryptStaging, + CachePath: filepath.Join(tmpDir, "certs"), + StatePath: filepath.Join(tmpDir, "state.json"), + } + + manager, err := NewSANCertManager(config) + require.NoError(t, err) + assert.NotNil(t, manager) + assert.NotNil(t, manager.grouper) +} + +func TestNewSANCertManager_RequiresEmail(t *testing.T) { + config := SANCertManagerConfig{} + + _, err := NewSANCertManager(config) + require.Error(t, err) + assert.Contains(t, err.Error(), "email is required") +} + +func TestNewSANCertManager_DefaultDirectory(t *testing.T) { + tmpDir := t.TempDir() + + config := SANCertManagerConfig{ + Email: "test@example.com", + CachePath: filepath.Join(tmpDir, "certs"), + } + + manager, err := NewSANCertManager(config) + require.NoError(t, err) + assert.Equal(t, LetsEncryptProduction, manager.config.Directory) +} + +func TestSANCertManager_RegisterDomain_NotReady(t *testing.T) { + tmpDir := t.TempDir() + + config := SANCertManagerConfig{ + Email: "test@example.com", + CachePath: filepath.Join(tmpDir, "certs"), + } + + manager, err := NewSANCertManager(config) + require.NoError(t, err) + + // Don't initialize - should fail + err = manager.RegisterDomain("app.example.com", "service1") + require.ErrorIs(t, err, ErrManagerNotReady) +} + +func TestSANCertManager_RegisterDomain_AddsToPending(t *testing.T) { + tmpDir := t.TempDir() + + config := SANCertManagerConfig{ + Email: "test@example.com", + CachePath: filepath.Join(tmpDir, "certs"), + } + + manager, err := NewSANCertManager(config) + require.NoError(t, err) + + // Manually mark as ready for testing + manager.ready = true + + err = manager.RegisterDomain("app.example.com", "service1") + require.NoError(t, err) + + assert.Contains(t, manager.pendingDomains, "app.example.com") + assert.Equal(t, "service1", manager.pendingDomains["app.example.com"]) +} + +func TestSANCertManager_RegisterMultipleDomains(t *testing.T) { + tmpDir := t.TempDir() + + config := SANCertManagerConfig{ + Email: "test@example.com", + CachePath: filepath.Join(tmpDir, "certs"), + } + + manager, err := NewSANCertManager(config) + require.NoError(t, err) + manager.ready = true + + // Register multiple domains + require.NoError(t, manager.RegisterDomain("app.example.com", "service1")) + require.NoError(t, manager.RegisterDomain("api.example.com", "service2")) + require.NoError(t, manager.RegisterDomain("www.example.com", "service3")) + + assert.Len(t, manager.pendingDomains, 3) +} + +func TestSANCertManager_RegisterDifferentRootDomains(t *testing.T) { + tmpDir := t.TempDir() + + config := SANCertManagerConfig{ + Email: "test@example.com", + CachePath: filepath.Join(tmpDir, "certs"), + } + + manager, err := NewSANCertManager(config) + require.NoError(t, err) + manager.ready = true + + // Register domains from completely different root domains + // All should be batched together (up to 100) + require.NoError(t, manager.RegisterDomain("app.example.com", "service1")) + require.NoError(t, manager.RegisterDomain("api.other.org", "service2")) + require.NoError(t, manager.RegisterDomain("www.mysite.net", "service3")) + require.NoError(t, manager.RegisterDomain("admin.different.io", "service4")) + + // All 4 should be pending for a single SAN certificate + assert.Len(t, manager.pendingDomains, 4) + assert.Contains(t, manager.pendingDomains, "app.example.com") + assert.Contains(t, manager.pendingDomains, "api.other.org") + assert.Contains(t, manager.pendingDomains, "www.mysite.net") + assert.Contains(t, manager.pendingDomains, "admin.different.io") +} + +func TestMaxSANsPerCertificate(t *testing.T) { + // Verify the constant is set correctly + assert.Equal(t, 100, MaxSANsPerCertificate) +} + +func TestSortStrings(t *testing.T) { + tests := []struct { + input []string + expected []string + }{ + {[]string{"c", "a", "b"}, []string{"a", "b", "c"}}, + {[]string{"z.com", "a.com", "m.com"}, []string{"a.com", "m.com", "z.com"}}, + {[]string{"single"}, []string{"single"}}, + {[]string{}, []string{}}, + } + + for _, tt := range tests { + t.Run("", func(t *testing.T) { + input := make([]string, len(tt.input)) + copy(input, tt.input) + sortStrings(input) + assert.Equal(t, tt.expected, input) + }) + } +} + +func TestSANCertManager_RegisterDomain_ExistingCert(t *testing.T) { + tmpDir := t.TempDir() + + config := SANCertManagerConfig{ + Email: "test@example.com", + CachePath: filepath.Join(tmpDir, "certs"), + } + + manager, err := NewSANCertManager(config) + require.NoError(t, err) + manager.ready = true + + // Add an existing certificate + manager.certificates["san:example.com"] = &ManagedCert{ + Identifier: "san:example.com", + Domains: []string{"app.example.com", "api.example.com"}, + } + manager.domainToCert["app.example.com"] = "san:example.com" + manager.domainToCert["api.example.com"] = "san:example.com" + + // Register a domain that's already covered + err = manager.RegisterDomain("app.example.com", "service1") + require.NoError(t, err) + + // Should not be in pending since it's already covered + assert.NotContains(t, manager.pendingDomains, "app.example.com") +} + +func TestSANCertManager_HTTPChallengeHandler(t *testing.T) { + tmpDir := t.TempDir() + + config := SANCertManagerConfig{ + Email: "test@example.com", + CachePath: filepath.Join(tmpDir, "certs"), + } + + manager, err := NewSANCertManager(config) + require.NoError(t, err) + + // Add a challenge token + manager.challengeTokens["test-token"] = "test-key-auth" + + fallback := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + w.Write([]byte("fallback")) + }) + + handler := manager.HTTPChallengeHandler(fallback) + + // Test challenge request + req := httptest.NewRequest("GET", "/.well-known/acme-challenge/test-token", nil) + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + + assert.Equal(t, http.StatusOK, rec.Code) + assert.Equal(t, "test-key-auth", rec.Body.String()) + + // Test unknown token falls through + req = httptest.NewRequest("GET", "/.well-known/acme-challenge/unknown-token", nil) + rec = httptest.NewRecorder() + handler.ServeHTTP(rec, req) + + assert.Equal(t, http.StatusOK, rec.Code) + assert.Equal(t, "fallback", rec.Body.String()) + + // Test non-challenge request falls through + req = httptest.NewRequest("GET", "/some/other/path", nil) + rec = httptest.NewRecorder() + handler.ServeHTTP(rec, req) + + assert.Equal(t, http.StatusOK, rec.Code) + assert.Equal(t, "fallback", rec.Body.String()) +} + +func TestSANCertManager_GetStats(t *testing.T) { + tmpDir := t.TempDir() + + config := SANCertManagerConfig{ + Email: "test@example.com", + CachePath: filepath.Join(tmpDir, "certs"), + } + + manager, err := NewSANCertManager(config) + require.NoError(t, err) + + stats := manager.GetStats() + + assert.Equal(t, false, stats["ready"]) + assert.Equal(t, 0, stats["total_certificates"]) + assert.Equal(t, 0, stats["domains_mapped"]) + assert.Equal(t, 0, stats["pending_domains"]) +} + +func TestSanitizeFilename(t *testing.T) { + tests := []struct { + input string + expected string + }{ + {"san:example.com", "san_example.com"}, + {"simple", "simple"}, + {"with-dash", "with-dash"}, + {"with_underscore", "with_underscore"}, + {"with.dot", "with.dot"}, + {"with/slash", "with_slash"}, + {"with:colon", "with_colon"}, + {"MixedCase123", "MixedCase123"}, + } + + for _, tt := range tests { + t.Run(tt.input, func(t *testing.T) { + result := sanitizeFilename(tt.input) + assert.Equal(t, tt.expected, result) + }) + } +} From 67df7869426e6017f29196c111f3c9eb67aa54b9 Mon Sep 17 00:00:00 2001 From: mhenrixon Date: Thu, 26 Mar 2026 05:44:11 +0100 Subject: [PATCH 02/12] refactor: remove unused DomainGrouper code MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DomainGrouper was never called — the SAN cert manager batches all pending domains regardless of root domain. Remove the dead code and its tests. --- go.mod | 2 +- internal/server/domain_group.go | 137 ----------------- internal/server/domain_group_test.go | 215 --------------------------- 3 files changed, 1 insertion(+), 353 deletions(-) delete mode 100644 internal/server/domain_group.go delete mode 100644 internal/server/domain_group_test.go diff --git a/go.mod b/go.mod index f31c859e..707e8524 100644 --- a/go.mod +++ b/go.mod @@ -11,7 +11,6 @@ require ( github.com/spf13/cobra v1.10.2 github.com/stretchr/testify v1.11.1 golang.org/x/crypto v0.46.0 - golang.org/x/net v0.48.0 ) require ( @@ -32,6 +31,7 @@ require ( github.com/spf13/pflag v1.0.10 // indirect go.yaml.in/yaml/v2 v2.4.3 // indirect golang.org/x/mod v0.30.0 // indirect + golang.org/x/net v0.48.0 // indirect golang.org/x/sync v0.19.0 // indirect golang.org/x/sys v0.39.0 // indirect golang.org/x/text v0.32.0 // indirect diff --git a/internal/server/domain_group.go b/internal/server/domain_group.go deleted file mode 100644 index ac691e2a..00000000 --- a/internal/server/domain_group.go +++ /dev/null @@ -1,137 +0,0 @@ -package server - -import ( - "sort" - "strings" - - "golang.org/x/net/publicsuffix" -) - -// DomainGroup represents a group of domains that share the same root domain -// and can be included in a single SAN certificate -type DomainGroup struct { - // RootDomain is the registrable domain (e.g., "example.com") - RootDomain string - - // Domains contains all full domain names in this group - Domains []string - - // IncludesApex is true if the apex domain itself is in the group - IncludesApex bool -} - -// DomainGrouper groups domains by their root domain for efficient certificate management -type DomainGrouper struct { - // MinDomainsForBatching is the minimum number of domains needed to batch into a SAN cert - // Default is 2 (batch when we have 2+ domains for the same root) - MinDomainsForBatching int -} - -// NewDomainGrouper creates a new DomainGrouper with default settings -func NewDomainGrouper() *DomainGrouper { - return &DomainGrouper{ - MinDomainsForBatching: 2, - } -} - -// GroupDomains analyzes a list of domains and groups them by root domain -func (g *DomainGrouper) GroupDomains(domains []string) []*DomainGroup { - if len(domains) == 0 { - return nil - } - - // Group domains by their root domain - groups := make(map[string]*DomainGroup) - - for _, domain := range domains { - domain = strings.ToLower(strings.TrimSpace(domain)) - if domain == "" { - continue - } - - rootDomain, err := publicsuffix.EffectiveTLDPlusOne(domain) - if err != nil { - // If we can't determine the root, treat domain as its own root - rootDomain = domain - } - - group, exists := groups[rootDomain] - if !exists { - group = &DomainGroup{ - RootDomain: rootDomain, - Domains: []string{}, - } - groups[rootDomain] = group - } - - // Check if this is the apex domain - if domain == rootDomain { - group.IncludesApex = true - } - - // Add domain if not already present - if !contains(group.Domains, domain) { - group.Domains = append(group.Domains, domain) - } - } - - // Convert map to sorted slice - result := make([]*DomainGroup, 0, len(groups)) - for _, group := range groups { - // Sort domains within each group for consistent ordering - sort.Strings(group.Domains) - result = append(result, group) - } - - // Sort groups by root domain for consistent ordering - sort.Slice(result, func(i, j int) bool { - return result[i].RootDomain < result[j].RootDomain - }) - - return result -} - -// ShouldBatch returns true if the group has enough domains to warrant batching -func (g *DomainGrouper) ShouldBatch(group *DomainGroup) bool { - return len(group.Domains) >= g.MinDomainsForBatching -} - -// GetDomainsForCert returns the domains that should be included in the certificate -// For SAN certificates, this is simply all domains in the group -func (group *DomainGroup) GetDomainsForCert() []string { - return group.Domains -} - -// CertificateIdentifier returns a unique identifier for this group's certificate -func (group *DomainGroup) CertificateIdentifier() string { - if len(group.Domains) == 1 { - return "single:" + group.Domains[0] - } - return "san:" + group.RootDomain -} - -// MatchesDomain checks if a domain belongs to this group -func (group *DomainGroup) MatchesDomain(domain string) bool { - domain = strings.ToLower(domain) - for _, d := range group.Domains { - if d == domain { - return true - } - } - return false -} - -// GetRootDomain extracts the registrable domain from a full domain name -// e.g., "app.example.com" -> "example.com", "www.example.co.uk" -> "example.co.uk" -func GetRootDomain(domain string) (string, error) { - return publicsuffix.EffectiveTLDPlusOne(strings.ToLower(domain)) -} - -func contains(slice []string, item string) bool { - for _, s := range slice { - if s == item { - return true - } - } - return false -} diff --git a/internal/server/domain_group_test.go b/internal/server/domain_group_test.go deleted file mode 100644 index 240ffe89..00000000 --- a/internal/server/domain_group_test.go +++ /dev/null @@ -1,215 +0,0 @@ -package server - -import ( - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -func TestDomainGrouper_GroupDomains_Empty(t *testing.T) { - grouper := NewDomainGrouper() - groups := grouper.GroupDomains(nil) - assert.Nil(t, groups) - - groups = grouper.GroupDomains([]string{}) - assert.Nil(t, groups) -} - -func TestDomainGrouper_GroupDomains_SingleDomain(t *testing.T) { - grouper := NewDomainGrouper() - groups := grouper.GroupDomains([]string{"app.example.com"}) - - require.Len(t, groups, 1) - assert.Equal(t, "example.com", groups[0].RootDomain) - assert.Equal(t, []string{"app.example.com"}, groups[0].Domains) - assert.False(t, groups[0].IncludesApex) -} - -func TestDomainGrouper_GroupDomains_ApexDomain(t *testing.T) { - grouper := NewDomainGrouper() - groups := grouper.GroupDomains([]string{"example.com"}) - - require.Len(t, groups, 1) - assert.Equal(t, "example.com", groups[0].RootDomain) - assert.Equal(t, []string{"example.com"}, groups[0].Domains) - assert.True(t, groups[0].IncludesApex) -} - -func TestDomainGrouper_GroupDomains_MultipleSubdomains(t *testing.T) { - grouper := NewDomainGrouper() - groups := grouper.GroupDomains([]string{ - "app.example.com", - "api.example.com", - "www.example.com", - }) - - require.Len(t, groups, 1) - assert.Equal(t, "example.com", groups[0].RootDomain) - assert.Len(t, groups[0].Domains, 3) - assert.Contains(t, groups[0].Domains, "app.example.com") - assert.Contains(t, groups[0].Domains, "api.example.com") - assert.Contains(t, groups[0].Domains, "www.example.com") - assert.False(t, groups[0].IncludesApex) -} - -func TestDomainGrouper_GroupDomains_ApexWithSubdomains(t *testing.T) { - grouper := NewDomainGrouper() - groups := grouper.GroupDomains([]string{ - "example.com", - "www.example.com", - "api.example.com", - }) - - require.Len(t, groups, 1) - assert.Equal(t, "example.com", groups[0].RootDomain) - assert.Len(t, groups[0].Domains, 3) - assert.True(t, groups[0].IncludesApex) -} - -func TestDomainGrouper_GroupDomains_MultipleDomains(t *testing.T) { - grouper := NewDomainGrouper() - groups := grouper.GroupDomains([]string{ - "app.example.com", - "api.example.com", - "app.other.com", - "www.other.com", - }) - - require.Len(t, groups, 2) - - // Groups are sorted by root domain - assert.Equal(t, "example.com", groups[0].RootDomain) - assert.Len(t, groups[0].Domains, 2) - - assert.Equal(t, "other.com", groups[1].RootDomain) - assert.Len(t, groups[1].Domains, 2) -} - -func TestDomainGrouper_GroupDomains_MultiLevelSubdomain(t *testing.T) { - grouper := NewDomainGrouper() - groups := grouper.GroupDomains([]string{ - "deep.nested.example.com", - "app.example.com", - }) - - require.Len(t, groups, 1) - assert.Equal(t, "example.com", groups[0].RootDomain) - assert.Len(t, groups[0].Domains, 2) -} - -func TestDomainGrouper_GroupDomains_PublicSuffix(t *testing.T) { - grouper := NewDomainGrouper() - groups := grouper.GroupDomains([]string{ - "app.example.co.uk", - "www.example.co.uk", - }) - - require.Len(t, groups, 1) - assert.Equal(t, "example.co.uk", groups[0].RootDomain) - assert.Len(t, groups[0].Domains, 2) -} - -func TestDomainGrouper_GroupDomains_Deduplication(t *testing.T) { - grouper := NewDomainGrouper() - groups := grouper.GroupDomains([]string{ - "app.example.com", - "APP.EXAMPLE.COM", // Duplicate with different case - "app.example.com", // Exact duplicate - }) - - require.Len(t, groups, 1) - assert.Len(t, groups[0].Domains, 1) - assert.Equal(t, "app.example.com", groups[0].Domains[0]) -} - -func TestDomainGrouper_ShouldBatch(t *testing.T) { - grouper := NewDomainGrouper() - - // Single domain - no batch - group1 := &DomainGroup{Domains: []string{"app.example.com"}} - assert.False(t, grouper.ShouldBatch(group1)) - - // Two domains - should batch - group2 := &DomainGroup{Domains: []string{"app.example.com", "api.example.com"}} - assert.True(t, grouper.ShouldBatch(group2)) -} - -func TestDomainGroup_GetDomainsForCert(t *testing.T) { - group := &DomainGroup{ - RootDomain: "example.com", - Domains: []string{"app.example.com", "api.example.com"}, - } - - domains := group.GetDomainsForCert() - assert.Equal(t, []string{"app.example.com", "api.example.com"}, domains) -} - -func TestDomainGroup_CertificateIdentifier(t *testing.T) { - tests := []struct { - name string - domains []string - expected string - }{ - { - name: "single domain", - domains: []string{"app.example.com"}, - expected: "single:app.example.com", - }, - { - name: "multiple domains", - domains: []string{"app.example.com", "api.example.com"}, - expected: "san:example.com", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - group := &DomainGroup{ - RootDomain: "example.com", - Domains: tt.domains, - } - assert.Equal(t, tt.expected, group.CertificateIdentifier()) - }) - } -} - -func TestDomainGroup_MatchesDomain(t *testing.T) { - group := &DomainGroup{ - RootDomain: "example.com", - Domains: []string{"app.example.com", "api.example.com"}, - } - - assert.True(t, group.MatchesDomain("app.example.com")) - assert.True(t, group.MatchesDomain("API.EXAMPLE.COM")) - assert.False(t, group.MatchesDomain("www.example.com")) - assert.False(t, group.MatchesDomain("app.other.com")) -} - -func TestGetRootDomain(t *testing.T) { - tests := []struct { - domain string - expected string - hasErr bool - }{ - {"app.example.com", "example.com", false}, - {"api.example.com", "example.com", false}, - {"deep.nested.example.com", "example.com", false}, - {"example.com", "example.com", false}, - {"app.example.co.uk", "example.co.uk", false}, - {"www.example.co.uk", "example.co.uk", false}, - {"APP.EXAMPLE.COM", "example.com", false}, - } - - for _, tt := range tests { - t.Run(tt.domain, func(t *testing.T) { - result, err := GetRootDomain(tt.domain) - if tt.hasErr { - require.Error(t, err) - } else { - require.NoError(t, err) - assert.Equal(t, tt.expected, result) - } - }) - } -} From bb563798b01bce103de94859d78da5fc84236d9c Mon Sep 17 00:00:00 2001 From: mhenrixon Date: Thu, 26 Mar 2026 05:44:22 +0100 Subject: [PATCH 03/12] fix: address multiple SANCertManager issues MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Replace O(n²) bubble sort with slices.Sort - Parse leaf certificate explicitly to get real expiry instead of approximating 90 days - Split saveState into persistState/writeState to prevent deadlock when called from contexts that already hold the lock - Skip expired certificates when checking SAN coverage in RegisterDomain - Rename HTTPChallengeHandler to HTTPHandler to satisfy CertManager interface - Remove grouper field (deleted in previous commit) --- internal/server/san_cert_manager.go | 55 ++++++++++------------ internal/server/san_cert_manager_test.go | 58 ++++++++++++++---------- 2 files changed, 58 insertions(+), 55 deletions(-) diff --git a/internal/server/san_cert_manager.go b/internal/server/san_cert_manager.go index 058380bb..3ee3122b 100644 --- a/internal/server/san_cert_manager.go +++ b/internal/server/san_cert_manager.go @@ -7,6 +7,7 @@ import ( "crypto/elliptic" "crypto/rand" "crypto/tls" + "crypto/x509" "encoding/json" "errors" "fmt" @@ -14,6 +15,7 @@ import ( "net/http" "os" "path/filepath" + "slices" "sync" "time" @@ -60,16 +62,13 @@ type SANCertManagerConfig struct { BatchDelay time.Duration } -// SANCertManager manages SAN certificates with domain batching -// It groups domains by their root domain and provisions a single certificate -// for all subdomains, reducing the number of certificates needed +// SANCertManager manages SAN certificates with domain batching. +// It batches up to 100 domains into a single certificate, +// reducing the number of certificates and avoiding rate limits. type SANCertManager struct { mu sync.RWMutex config SANCertManagerConfig - // Domain grouper - grouper *DomainGrouper - // ACME client client *lego.Client user *acmeUser @@ -129,7 +128,6 @@ func NewSANCertManager(config SANCertManagerConfig) (*SANCertManager, error) { manager := &SANCertManager{ config: config, - grouper: NewDomainGrouper(), certificates: make(map[string]*ManagedCert), domainToCert: make(map[string]string), pendingDomains: make(map[string]string), @@ -250,8 +248,11 @@ func (m *SANCertManager) RegisterDomain(domain string, service string) error { } } - // Check if domain is covered by an existing SAN certificate + // Check if domain is covered by an existing valid SAN certificate for _, cert := range m.certificates { + if time.Until(cert.NotAfter) <= 24*time.Hour { + continue + } for _, d := range cert.Domains { if d == domain { m.domainToCert[domain] = cert.Identifier @@ -369,7 +370,7 @@ func (m *SANCertManager) provisionCertificate(ctx context.Context, domain string // Sort domains for consistent certificate identifiers sortedDomains := make([]string, len(domainsToProvision)) copy(sortedDomains, domainsToProvision) - sortStrings(sortedDomains) + slices.Sort(sortedDomains) slog.Info("Provisioning SAN certificate", "domains", sortedDomains, @@ -393,13 +394,13 @@ func (m *SANCertManager) provisionCertificate(ctx context.Context, domain string return nil, fmt.Errorf("failed to parse certificate: %w", err) } - // Determine expiry from leaf certificate - var notAfter time.Time - if tlsCert.Leaf != nil { - notAfter = tlsCert.Leaf.NotAfter - } else { - notAfter = time.Now().Add(90 * 24 * time.Hour) // Approximate + // Parse the leaf certificate to get the actual expiry + leaf, err := x509.ParseCertificate(tlsCert.Certificate[0]) + if err != nil { + return nil, fmt.Errorf("failed to parse leaf certificate: %w", err) } + tlsCert.Leaf = leaf + notAfter := leaf.NotAfter // Generate certificate ID from first domain (sorted) certID := fmt.Sprintf("san:%d:%s", len(sortedDomains), sortedDomains[0]) @@ -422,8 +423,8 @@ func (m *SANCertManager) provisionCertificate(ctx context.Context, domain string slog.Warn("Failed to save certificate", "error", err) } - // Persist state - if err := m.saveState(); err != nil { + // Persist state (called without lock held) + if err := m.persistState(); err != nil { slog.Warn("Failed to save state", "error", err) } @@ -437,16 +438,6 @@ func (m *SANCertManager) provisionCertificate(ctx context.Context, domain string return &tlsCert, nil } -// sortStrings sorts a slice of strings in place -func sortStrings(s []string) { - for i := 0; i < len(s)-1; i++ { - for j := i + 1; j < len(s); j++ { - if s[i] > s[j] { - s[i], s[j] = s[j], s[i] - } - } - } -} // getCertForDomain retrieves a certificate for a domain func (m *SANCertManager) getCertForDomain(domain string) (*tls.Certificate, error) { @@ -466,8 +457,8 @@ func (m *SANCertManager) getCertForDomain(domain string) (*tls.Certificate, erro return cert.Certificate, nil } -// HTTPChallengeHandler returns the HTTP handler for ACME challenges -func (m *SANCertManager) HTTPChallengeHandler(fallback http.Handler) http.Handler { +// HTTPHandler returns the HTTP handler for ACME challenges +func (m *SANCertManager) HTTPHandler(fallback http.Handler) http.Handler { const challengePrefix = "/.well-known/acme-challenge/" return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { @@ -638,7 +629,7 @@ func (m *SANCertManager) loadState() error { return nil } -func (m *SANCertManager) saveState() error { +func (m *SANCertManager) persistState() error { if m.config.StatePath == "" { return nil } @@ -651,6 +642,10 @@ func (m *SANCertManager) saveState() error { } m.mu.RUnlock() + return m.writeState(state) +} + +func (m *SANCertManager) writeState(state managerState) error { data, err := json.MarshalIndent(state, "", " ") if err != nil { return err diff --git a/internal/server/san_cert_manager_test.go b/internal/server/san_cert_manager_test.go index 00168704..971b31d3 100644 --- a/internal/server/san_cert_manager_test.go +++ b/internal/server/san_cert_manager_test.go @@ -5,6 +5,7 @@ import ( "net/http/httptest" "path/filepath" "testing" + "time" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -23,7 +24,6 @@ func TestNewSANCertManager(t *testing.T) { manager, err := NewSANCertManager(config) require.NoError(t, err) assert.NotNil(t, manager) - assert.NotNil(t, manager.grouper) } func TestNewSANCertManager_RequiresEmail(t *testing.T) { @@ -136,27 +136,6 @@ func TestMaxSANsPerCertificate(t *testing.T) { assert.Equal(t, 100, MaxSANsPerCertificate) } -func TestSortStrings(t *testing.T) { - tests := []struct { - input []string - expected []string - }{ - {[]string{"c", "a", "b"}, []string{"a", "b", "c"}}, - {[]string{"z.com", "a.com", "m.com"}, []string{"a.com", "m.com", "z.com"}}, - {[]string{"single"}, []string{"single"}}, - {[]string{}, []string{}}, - } - - for _, tt := range tests { - t.Run("", func(t *testing.T) { - input := make([]string, len(tt.input)) - copy(input, tt.input) - sortStrings(input) - assert.Equal(t, tt.expected, input) - }) - } -} - func TestSANCertManager_RegisterDomain_ExistingCert(t *testing.T) { tmpDir := t.TempDir() @@ -169,10 +148,11 @@ func TestSANCertManager_RegisterDomain_ExistingCert(t *testing.T) { require.NoError(t, err) manager.ready = true - // Add an existing certificate + // Add an existing valid certificate manager.certificates["san:example.com"] = &ManagedCert{ Identifier: "san:example.com", Domains: []string{"app.example.com", "api.example.com"}, + NotAfter: time.Now().Add(48 * time.Hour), } manager.domainToCert["app.example.com"] = "san:example.com" manager.domainToCert["api.example.com"] = "san:example.com" @@ -185,7 +165,35 @@ func TestSANCertManager_RegisterDomain_ExistingCert(t *testing.T) { assert.NotContains(t, manager.pendingDomains, "app.example.com") } -func TestSANCertManager_HTTPChallengeHandler(t *testing.T) { +func TestSANCertManager_RegisterDomain_ExpiredCert(t *testing.T) { + tmpDir := t.TempDir() + + config := SANCertManagerConfig{ + Email: "test@example.com", + CachePath: filepath.Join(tmpDir, "certs"), + } + + manager, err := NewSANCertManager(config) + require.NoError(t, err) + manager.ready = true + + // Add an expired certificate + manager.certificates["san:example.com"] = &ManagedCert{ + Identifier: "san:example.com", + Domains: []string{"app.example.com", "api.example.com"}, + NotAfter: time.Now().Add(-1 * time.Hour), + } + manager.domainToCert["app.example.com"] = "san:example.com" + + // Register a domain covered by the expired cert + err = manager.RegisterDomain("api.example.com", "service1") + require.NoError(t, err) + + // Should be in pending since the cert is expired + assert.Contains(t, manager.pendingDomains, "api.example.com") +} + +func TestSANCertManager_HTTPHandler(t *testing.T) { tmpDir := t.TempDir() config := SANCertManagerConfig{ @@ -204,7 +212,7 @@ func TestSANCertManager_HTTPChallengeHandler(t *testing.T) { w.Write([]byte("fallback")) }) - handler := manager.HTTPChallengeHandler(fallback) + handler := manager.HTTPHandler(fallback) // Test challenge request req := httptest.NewRequest("GET", "/.well-known/acme-challenge/test-token", nil) From 47d4c109e5e6e5bb1aea420dd9e8237a15af9880 Mon Sep 17 00:00:00 2001 From: mhenrixon Date: Thu, 26 Mar 2026 05:44:32 +0100 Subject: [PATCH 04/12] feat: wire SANCertManager into service lifecycle When --acme-email is set, initialize a shared SANCertManager and inject it into the router. Services with TLS enabled (and no static cert) will use the shared manager instead of per-service autocert, batching up to 100 domains per certificate. - Router holds optional sanCertManager, passes it to new services - Service.createCertManager registers hosts with the SAN manager - Server.buildHandler wires HTTP-01 challenge handler at server level - SetSANCertManager re-initializes restored services on startup --- internal/cmd/run.go | 19 +++++++++++++++++++ internal/server/router.go | 22 ++++++++++++++++++---- internal/server/server.go | 14 ++++++++------ internal/server/service.go | 25 ++++++++++++++++++++++--- internal/server/service_test.go | 2 +- 5 files changed, 68 insertions(+), 14 deletions(-) diff --git a/internal/cmd/run.go b/internal/cmd/run.go index 4dbbcd1e..3ee3f2e1 100644 --- a/internal/cmd/run.go +++ b/internal/cmd/run.go @@ -41,8 +41,27 @@ func (c *runCommand) run(cmd *cobra.Command, args []string) error { c.setLogger() router := server.NewRouter(globalConfig.StatePath()) + router.RestoreLastSavedState() + if globalConfig.ACMEEmail != "" { + manager, err := server.NewSANCertManager(server.SANCertManagerConfig{ + Email: globalConfig.ACMEEmail, + Directory: globalConfig.ACMEDirectory, + CachePath: globalConfig.CertificatePath(), + StatePath: globalConfig.ACMEStatePath(), + }) + if err != nil { + return err + } + + if err := manager.Initialize(cmd.Context()); err != nil { + return err + } + + router.SetSANCertManager(manager) + } + s := server.NewServer(&globalConfig, router) err := s.Start() if err != nil { diff --git a/internal/server/router.go b/internal/server/router.go index 051f0be8..d324d265 100644 --- a/internal/server/router.go +++ b/internal/server/router.go @@ -36,9 +36,10 @@ func RoutingContext(r *http.Request) *routingContext { } type Router struct { - statePath string - services *ServiceMap - serviceLock sync.RWMutex + statePath string + services *ServiceMap + serviceLock sync.RWMutex + sanCertManager *SANCertManager } type ServiceDescription struct { @@ -58,6 +59,19 @@ func NewRouter(statePath string) *Router { } } +func (r *Router) SetSANCertManager(manager *SANCertManager) { + r.sanCertManager = manager + + // Update any already-restored services to use the SAN cert manager + for _, service := range r.services.services { + service.SetSANCertManager(manager) + } +} + +func (r *Router) SANCertManager() *SANCertManager { + return r.sanCertManager +} + func (r *Router) RestoreLastSavedState() error { f, err := os.Open(r.statePath) if err != nil { @@ -290,7 +304,7 @@ func (r *Router) GetCertificate(hello *tls.ClientHelloInfo) (*tls.Certificate, e func (r *Router) createOrUpdateService(name string, options ServiceOptions, targetOptions TargetOptions) (*Service, error) { service := r.services.Get(name) if service == nil { - return NewService(name, options, targetOptions) + return NewService(name, options, targetOptions, r.sanCertManager) } err := service.UpdateOptions(options, targetOptions) diff --git a/internal/server/server.go b/internal/server/server.go index 4b336de3..6eb2b44a 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -46,18 +46,15 @@ func NewServer(config *Config, router *Router) *Server { } func (s *Server) Start() error { - err := s.startMetricsServer() - if err != nil { + if err := s.startMetricsServer(); err != nil { return err } - err = s.startHTTPServers() - if err != nil { + if err := s.startHTTPServers(); err != nil { return err } - err = s.startCommandHandler() - if err != nil { + if err := s.startCommandHandler(); err != nil { return err } @@ -221,6 +218,11 @@ func (s *Server) buildHandler() http.Handler { handler = WithRequestIDMiddleware(handler) handler = WithRequestStartMiddleware(handler) + // Intercept ACME HTTP-01 challenges before other handlers + if sanManager := s.router.SANCertManager(); sanManager != nil { + handler = sanManager.HTTPHandler(handler) + } + return handler } diff --git a/internal/server/service.go b/internal/server/service.go index 56953137..6d8ed5aa 100644 --- a/internal/server/service.go +++ b/internal/server/service.go @@ -135,14 +135,16 @@ type Service struct { pauseController *PauseController rolloutController *RolloutController - certManager CertManager - middleware http.Handler + sanCertManager *SANCertManager + certManager CertManager + middleware http.Handler } -func NewService(name string, options ServiceOptions, targetOptions TargetOptions) (*Service, error) { +func NewService(name string, options ServiceOptions, targetOptions TargetOptions, sanCertManager *SANCertManager) (*Service, error) { service := &Service{ name: name, pauseController: NewPauseController(), + sanCertManager: sanCertManager, } if err := service.initialize(options, targetOptions); err != nil { @@ -155,6 +157,13 @@ func (s *Service) UpdateOptions(options ServiceOptions, targetOptions TargetOpti return s.initialize(options, targetOptions) } +func (s *Service) SetSANCertManager(manager *SANCertManager) { + s.sanCertManager = manager + if s.options.TLSEnabled && s.options.TLSCertificatePath == "" { + s.initialize(s.options, s.targetOptions) + } +} + func (s *Service) Dispose() { s.active.Dispose() if s.rollout != nil { @@ -392,6 +401,16 @@ func (s *Service) createCertManager(options ServiceOptions) (CertManager, error) } } + // Use the shared SAN certificate manager when available + if s.sanCertManager != nil { + for _, host := range options.Hosts { + if err := s.sanCertManager.RegisterDomain(host, s.name); err != nil { + slog.Warn("Failed to register domain with SAN cert manager", "host", host, "error", err) + } + } + return s.sanCertManager, nil + } + return &autocert.Manager{ Prompt: autocert.AcceptTOS, Cache: autocert.DirCache(options.ScopedCachePath()), diff --git a/internal/server/service_test.go b/internal/server/service_test.go index 6dd39d46..8b8730da 100644 --- a/internal/server/service_test.go +++ b/internal/server/service_test.go @@ -232,7 +232,7 @@ func testCreateServiceWithHandler(t *testing.T, options ServiceOptions, targetOp target, err := NewTarget(serverURL.Host, targetOptions) require.NoError(t, err) - service, err := NewService("test", options, targetOptions) + service, err := NewService("test", options, targetOptions, nil) require.NoError(t, err) service.UpdateLoadBalancer(NewLoadBalancer(TargetList{target}, DefaultWriterAffinityTimeout, false), TargetSlotActive) From 9124445947f768d4848394bc3c47fd8f0b945b9d Mon Sep 17 00:00:00 2001 From: mhenrixon Date: Thu, 26 Mar 2026 06:35:36 +0100 Subject: [PATCH 05/12] fix: re-add pending domains on failed certificate obtain If ACME obtain fails, re-add the domains to pendingDomains so they will be retried on the next TLS handshake instead of being silently lost. --- internal/server/san_cert_manager.go | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/internal/server/san_cert_manager.go b/internal/server/san_cert_manager.go index 3ee3122b..093e41de 100644 --- a/internal/server/san_cert_manager.go +++ b/internal/server/san_cert_manager.go @@ -385,6 +385,12 @@ func (m *SANCertManager) provisionCertificate(ctx context.Context, domain string resource, err := m.client.Certificate.Obtain(request) if err != nil { + // Re-add domains to pending so they can be retried + m.mu.Lock() + for _, d := range sortedDomains { + m.pendingDomains[d] = "" + } + m.mu.Unlock() return nil, fmt.Errorf("failed to obtain certificate: %w", err) } From 95beb02741f89ca2d05523d1be7d8d65dbf6a5d2 Mon Sep 17 00:00:00 2001 From: mhenrixon Date: Thu, 26 Mar 2026 06:36:09 +0100 Subject: [PATCH 06/12] fix: use hash-based certificate ID to prevent collisions The previous ID format (san::) could collide when different domain sets shared the same count and first domain. Use a SHA-256 hash of the full sorted domain list instead. --- internal/server/san_cert_manager.go | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/internal/server/san_cert_manager.go b/internal/server/san_cert_manager.go index 093e41de..b37996f2 100644 --- a/internal/server/san_cert_manager.go +++ b/internal/server/san_cert_manager.go @@ -7,7 +7,9 @@ import ( "crypto/elliptic" "crypto/rand" "crypto/tls" + "crypto/sha256" "crypto/x509" + "encoding/hex" "encoding/json" "errors" "fmt" @@ -408,8 +410,8 @@ func (m *SANCertManager) provisionCertificate(ctx context.Context, domain string tlsCert.Leaf = leaf notAfter := leaf.NotAfter - // Generate certificate ID from first domain (sorted) - certID := fmt.Sprintf("san:%d:%s", len(sortedDomains), sortedDomains[0]) + // Generate collision-resistant certificate ID from full domain list + certID := sanCertID(sortedDomains) managed := &ManagedCert{ Identifier: certID, Domains: sortedDomains, @@ -665,6 +667,15 @@ func (m *SANCertManager) writeState(state managerState) error { return os.Rename(tmpPath, m.config.StatePath) } +func sanCertID(domains []string) string { + h := sha256.New() + for _, d := range domains { + h.Write([]byte(d)) + h.Write([]byte{0}) + } + return "san:" + hex.EncodeToString(h.Sum(nil))[:16] +} + func sanitizeFilename(s string) string { result := make([]byte, 0, len(s)) for i := 0; i < len(s); i++ { From de65438b2c537909ae827d4f41e2a99ab5cdf1a2 Mon Sep 17 00:00:00 2001 From: mhenrixon Date: Thu, 26 Mar 2026 06:36:41 +0100 Subject: [PATCH 07/12] fix: deep-copy maps in persistState to prevent data race The previous code copied map references under RLock but marshaled after releasing the lock. Concurrent mutations could cause panics. Now shallow-copies both maps while holding the lock. --- internal/server/san_cert_manager.go | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/internal/server/san_cert_manager.go b/internal/server/san_cert_manager.go index b37996f2..27827c26 100644 --- a/internal/server/san_cert_manager.go +++ b/internal/server/san_cert_manager.go @@ -643,14 +643,21 @@ func (m *SANCertManager) persistState() error { } m.mu.RLock() - state := managerState{ - Certificates: m.certificates, - DomainMap: m.domainToCert, - SavedAt: time.Now(), + certs := make(map[string]*ManagedCert, len(m.certificates)) + for k, v := range m.certificates { + certs[k] = v + } + domainMap := make(map[string]string, len(m.domainToCert)) + for k, v := range m.domainToCert { + domainMap[k] = v } m.mu.RUnlock() - return m.writeState(state) + return m.writeState(managerState{ + Certificates: certs, + DomainMap: domainMap, + SavedAt: time.Now(), + }) } func (m *SANCertManager) writeState(state managerState) error { From 6bb77ad6082226e196a313d84053281977bcba9e Mon Sep 17 00:00:00 2001 From: mhenrixon Date: Thu, 26 Mar 2026 06:37:10 +0100 Subject: [PATCH 08/12] refactor: remove unused BatchDelay config field BatchDelay was documented and defaulted but never actually used in the provisioning flow. Remove it to avoid promising behavior that doesn't exist. --- internal/server/san_cert_manager.go | 8 -------- 1 file changed, 8 deletions(-) diff --git a/internal/server/san_cert_manager.go b/internal/server/san_cert_manager.go index 27827c26..827efb48 100644 --- a/internal/server/san_cert_manager.go +++ b/internal/server/san_cert_manager.go @@ -58,10 +58,6 @@ type SANCertManagerConfig struct { // StatePath is where manager state is persisted StatePath string - - // BatchDelay is how long to wait for more domains before provisioning - // This allows batching multiple service deployments together - BatchDelay time.Duration } // SANCertManager manages SAN certificates with domain batching. @@ -124,10 +120,6 @@ func NewSANCertManager(config SANCertManagerConfig) (*SANCertManager, error) { config.Directory = LetsEncryptProduction } - if config.BatchDelay == 0 { - config.BatchDelay = 5 * time.Second - } - manager := &SANCertManager{ config: config, certificates: make(map[string]*ManagedCert), From b6437be38c14060f52c596dea6ab23b5af58a05b Mon Sep 17 00:00:00 2001 From: mhenrixon Date: Thu, 26 Mar 2026 06:37:46 +0100 Subject: [PATCH 09/12] fix: handle initialization error in SetSANCertManager Log the error and roll back to the previous certManager/middleware instead of silently leaving the service in a broken state. --- internal/server/service.go | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/internal/server/service.go b/internal/server/service.go index 6d8ed5aa..806f77ea 100644 --- a/internal/server/service.go +++ b/internal/server/service.go @@ -158,9 +158,17 @@ func (s *Service) UpdateOptions(options ServiceOptions, targetOptions TargetOpti } func (s *Service) SetSANCertManager(manager *SANCertManager) { + prevCertManager := s.certManager + prevMiddleware := s.middleware + s.sanCertManager = manager if s.options.TLSEnabled && s.options.TLSCertificatePath == "" { - s.initialize(s.options, s.targetOptions) + if err := s.initialize(s.options, s.targetOptions); err != nil { + slog.Error("Failed to reinitialize service with SAN cert manager, keeping previous TLS config", + "service", s.name, "error", err) + s.certManager = prevCertManager + s.middleware = prevMiddleware + } } } From 695bf087f03f04aa22ea3790cd975766d9534317 Mon Sep 17 00:00:00 2001 From: mhenrixon Date: Thu, 26 Mar 2026 06:38:18 +0100 Subject: [PATCH 10/12] fix: hold write lock in Router.SetSANCertManager Iterating the services map without the serviceLock could race with concurrent deploy/remove operations. Use withWriteLock and the iterator from services.All(). --- internal/server/router.go | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/internal/server/router.go b/internal/server/router.go index d324d265..66ef8b07 100644 --- a/internal/server/router.go +++ b/internal/server/router.go @@ -60,12 +60,14 @@ func NewRouter(statePath string) *Router { } func (r *Router) SetSANCertManager(manager *SANCertManager) { - r.sanCertManager = manager + r.withWriteLock(func() error { + r.sanCertManager = manager - // Update any already-restored services to use the SAN cert manager - for _, service := range r.services.services { - service.SetSANCertManager(manager) - } + for _, service := range r.services.All() { + service.SetSANCertManager(manager) + } + return nil + }) } func (r *Router) SANCertManager() *SANCertManager { From 66800c669d80059471574ce1c03d14412b232c8a Mon Sep 17 00:00:00 2001 From: mhenrixon Date: Thu, 26 Mar 2026 06:38:39 +0100 Subject: [PATCH 11/12] docs: clarify that SAN batching requires --acme-email Make it clear that SAN certificate batching is only enabled when --acme-email is set. Without it, the per-service autocert flow is used. Also fix the config table description. --- README.md | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 8df2d2d1..3163f311 100644 --- a/README.md +++ b/README.md @@ -145,9 +145,13 @@ your certificate file and the corresponding private key: ### SAN Certificate Batching -Kamal Proxy automatically batches multiple domains into a single SAN (Subject -Alternative Name) certificate. This dramatically reduces the number of certificates -needed and helps avoid Let's Encrypt rate limits. +When started with `--acme-email` (or the `ACME_EMAIL` environment variable), +Kamal Proxy batches multiple domains into a single SAN (Subject Alternative Name) +certificate. This dramatically reduces the number of certificates needed and helps +avoid Let's Encrypt rate limits. + +Without `--acme-email`, Kamal Proxy falls back to issuing separate certificates +per service using the standard autocert flow. **How it works:** @@ -178,13 +182,13 @@ kamal-proxy deploy app3 --target web-3:3000 --host mysite.net --tls - **Dramatic reduction**: Up to 100 domains per certificate - **Rate limit friendly**: 1000 domains = 10 certs instead of 1000 - **Any domains**: Works across different root domains -- **Zero configuration**: Just deploy with `--tls` as usual +- **Minimal configuration**: Set `--acme-email` once, then deploy with `--tls` as usual **Configuration options:** | Flag | Environment Variable | Default | Description | |------|---------------------|---------|-------------| -| `--acme-email` | `ACME_EMAIL` | (required for TLS) | Contact email for Let's Encrypt | +| `--acme-email` | `ACME_EMAIL` | (required for SAN batching) | Contact email for Let's Encrypt | | `--acme-directory` | `ACME_DIRECTORY` | Let's Encrypt production | ACME directory URL | **Using Let's Encrypt staging environment:** From cf8b02f86dcbd89c921a5e7ed2b6910b44b1cba1 Mon Sep 17 00:00:00 2001 From: mhenrixon Date: Thu, 26 Mar 2026 06:39:09 +0100 Subject: [PATCH 12/12] fix: remove nested certs/ subdirectory in certificate storage Config.CertificatePath() already returns a "certs" directory, so appending another "certs" segment created a "certs/certs" layout. Store certificate files directly under CachePath. --- internal/server/san_cert_manager.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/internal/server/san_cert_manager.go b/internal/server/san_cert_manager.go index 827efb48..96926d8b 100644 --- a/internal/server/san_cert_manager.go +++ b/internal/server/san_cert_manager.go @@ -562,7 +562,7 @@ func (m *SANCertManager) saveCertificate(certID string, resource *certificate.Re return nil } - certDir := filepath.Join(m.config.CachePath, "certs", sanitizeFilename(certID)) + certDir := filepath.Join(m.config.CachePath, sanitizeFilename(certID)) if err := os.MkdirAll(certDir, 0700); err != nil { return err } @@ -607,7 +607,7 @@ func (m *SANCertManager) loadState() error { // Load certificates from disk for id, cert := range state.Certificates { if m.config.CachePath != "" { - certDir := filepath.Join(m.config.CachePath, "certs", sanitizeFilename(id)) + certDir := filepath.Join(m.config.CachePath, sanitizeFilename(id)) certPath := filepath.Join(certDir, "cert.pem") keyPath := filepath.Join(certDir, "key.pem")