diff --git a/README.md b/README.md index 03d34de..3163f31 100644 --- a/README.md +++ b/README.md @@ -143,6 +143,64 @@ 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 + +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:** + +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 +- **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 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:** + +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 8e1a118..605ea92 100644 --- a/go.mod +++ b/go.mod @@ -4,6 +4,7 @@ go 1.26.4 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.60.0 @@ -14,21 +15,26 @@ require ( 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.69.0 // indirect github.com/prometheus/procfs v0.20.1 // 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.4 // indirect + golang.org/x/mod v0.36.0 // indirect golang.org/x/net v0.56.0 // indirect + golang.org/x/sync v0.21.0 // indirect golang.org/x/sys v0.46.0 // indirect golang.org/x/text v0.38.0 // indirect + golang.org/x/tools v0.45.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 94f261f..29a24b0 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,26 +28,24 @@ 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= github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE= -github.com/prometheus/common v0.67.4 h1:yR3NqWO1/UyO1w2PhUvXlGQs/PtFmoveVO0KZ4+Lvsc= -github.com/prometheus/common v0.67.4/go.mod h1:gP0fq6YjjNCLssJCQp0yk4M8W6ikLURwkdd/YKtTbyI= github.com/prometheus/common v0.69.0 h1:OA85nJQS/T/MaYh/Q2CcgDKSGWqNIgrBDvDH85CuiNk= github.com/prometheus/common v0.69.0/go.mod h1:ZzL3f6u94qUxh9p+tJTrF+FvBS1XXbbRAZCQkytAL0Y= -github.com/prometheus/procfs v0.19.2 h1:zUMhqEW66Ex7OXIiDkll3tl9a1ZdilUOd/F6ZXw4Vws= -github.com/prometheus/procfs v0.19.2/go.mod h1:M0aotyiemPhBCM0z5w87kL22CxfcH05ZpYlu+b4J7mw= github.com/prometheus/procfs v0.20.1 h1:XwbrGOIplXW/AU3YhIhLODXMJYyC1isLFfYCsTEycfc= github.com/prometheus/procfs v0.20.1/go.mod h1:o9EMBZGRyvDrSPH1RqdxhojkuXstoe4UlK79eF5TGGo= +github.com/quic-go/go-ossfuzz-seeds v0.1.0 h1:APacT+iIaNF6fd8AGEiN3bT/Jtkd2jz4v4TzM7MFjy0= +github.com/quic-go/go-ossfuzz-seeds v0.1.0/go.mod h1:3IOHRbJIc+L6YKMwfDtJAM9Vj9k0YY4muhuyUYk5tbk= github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8= github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII= -github.com/quic-go/quic-go v0.57.1 h1:25KAAR9QR8KZrCZRThWMKVAwGoiHIrNbT72ULHTuI10= -github.com/quic-go/quic-go v0.57.1/go.mod h1:ly4QBAjHA2VhdnxhojRsCUOeJwKYg+taDlos92xb1+s= github.com/quic-go/quic-go v0.60.0 h1:xcQioE8OM66UQLeUMHltK1CCcOu3JbVB4JAQdDQSB+0= github.com/quic-go/quic-go v0.60.0/go.mod h1:wpKpjmPpftl30sL6pFh7REVpjbcCVy4zt2vDyK1TuJk= github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ= @@ -58,29 +62,23 @@ go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= go.uber.org/mock v0.5.2 h1:LbtPTcP8A5k9WPXj54PPPbjcI4Y6lhyOZXn+VS7wNko= go.uber.org/mock v0.5.2/go.mod h1:wLlUxC2vVTPTaE3UD51E0BGOAElKrILxhVSDYQLld5o= -go.yaml.in/yaml/v2 v2.4.3 h1:6gvOSjQoTB3vt1l+CU+tSyi/HOjfOjRLJ4YwYZGwRO0= -go.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8= go.yaml.in/yaml/v2 v2.4.4 h1:tuyd0P+2Ont/d6e2rl3be67goVK4R6deVxCUX5vyPaQ= go.yaml.in/yaml/v2 v2.4.4/go.mod h1:gMZqIpDtDqOfM0uNfy0SkpRhvUryYH0Z6wdMYcacYXQ= 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/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto= golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio= -golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU= -golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY= +golang.org/x/mod v0.36.0 h1:JJjpVx6myfUsUdAzZuOSTTmRE0PfZeNWzzvKrP7amb4= +golang.org/x/mod v0.36.0/go.mod h1:moc6ELqsWcOw5Ef3xVprK5ul/MvtVvkIXLziUOICjUQ= golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o= golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec= -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/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM= +golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw= golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= -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/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE= golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4= -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/tools v0.45.0 h1:18qN3FAooORvApf5XjCXgsuayZOEtXf6JK18I3+ONa8= +golang.org/x/tools v0.45.0/go.mod h1:LuUGqqaXcXMEFEruIVJVm5mgDD8vww/z/SR1gQ4uE/0= 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 0a70655..3ee3f2e 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 } @@ -37,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/cmd/util.go b/internal/cmd/util.go index d28618c..1b440f8 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 0314825..ebd0599 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/router.go b/internal/server/router.go index 9f42c2a..2c2a016 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,21 @@ func NewRouter(statePath string) *Router { } } +func (r *Router) SetSANCertManager(manager *SANCertManager) { + r.withWriteLock(func() error { + r.sanCertManager = manager + + for _, service := range r.services.All() { + service.SetSANCertManager(manager) + } + return nil + }) +} + +func (r *Router) SANCertManager() *SANCertManager { + return r.sanCertManager +} + func (r *Router) RestoreLastSavedState() error { f, err := os.Open(r.statePath) if err != nil { @@ -294,7 +310,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/san_cert_manager.go b/internal/server/san_cert_manager.go new file mode 100644 index 0000000..96926d8 --- /dev/null +++ b/internal/server/san_cert_manager.go @@ -0,0 +1,689 @@ +package server + +import ( + "context" + "crypto" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/tls" + "crypto/sha256" + "crypto/x509" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "log/slog" + "net/http" + "os" + "path/filepath" + "slices" + "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 +} + +// 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 + + // 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 + } + + manager := &SANCertManager{ + config: config, + 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 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 + 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) + slices.Sort(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 { + // 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) + } + + // Parse the certificate + tlsCert, err := tls.X509KeyPair(resource.Certificate, resource.PrivateKey) + if err != nil { + return nil, fmt.Errorf("failed to parse certificate: %w", err) + } + + // 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 collision-resistant certificate ID from full domain list + certID := sanCertID(sortedDomains) + 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 (called without lock held) + if err := m.persistState(); 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 +} + + +// 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 +} + +// 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) { + // 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, 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, 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) persistState() error { + if m.config.StatePath == "" { + return nil + } + + m.mu.RLock() + 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(managerState{ + Certificates: certs, + DomainMap: domainMap, + SavedAt: time.Now(), + }) +} + +func (m *SANCertManager) writeState(state managerState) error { + 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 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++ { + 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 0000000..971b31d --- /dev/null +++ b/internal/server/san_cert_manager_test.go @@ -0,0 +1,282 @@ +package server + +import ( + "net/http" + "net/http/httptest" + "path/filepath" + "testing" + "time" + + "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) +} + +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 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 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" + + // 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_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{ + 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.HTTPHandler(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) + }) + } +} diff --git a/internal/server/server.go b/internal/server/server.go index 4b336de..6eb2b44 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 7af5861..5c5603c 100644 --- a/internal/server/service.go +++ b/internal/server/service.go @@ -163,14 +163,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 { @@ -183,6 +185,21 @@ func (s *Service) UpdateOptions(options ServiceOptions, targetOptions TargetOpti return s.initialize(options, targetOptions) } +func (s *Service) SetSANCertManager(manager *SANCertManager) { + prevCertManager := s.certManager + prevMiddleware := s.middleware + + s.sanCertManager = manager + if s.options.TLSEnabled && s.options.TLSCertificatePath == "" { + 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 + } + } +} + func (s *Service) Dispose() { s.active.Dispose() if s.rollout != nil { @@ -420,6 +437,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 edf8453..441d551 100644 --- a/internal/server/service_test.go +++ b/internal/server/service_test.go @@ -269,7 +269,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)