diff --git a/README.md b/README.md index a41c58b..82091e4 100644 --- a/README.md +++ b/README.md @@ -89,6 +89,20 @@ applications. To enable this, add the `--tls` flag when deploying an instance: kamal-proxy deploy service1 --target web-1:3000 --host app1.example.com --tls +### On-demand TLS + +In addition of the automatic TLS functionality, Kamal Proxy can also dynamically obtain a TLS certificate +from any host allowed by an external API endpoint of your choice. +This avoids hard-coding hosts in the configuration, especially when you don't know the hosts at the startup. + + kamal-proxy deploy service1 --target web-1:3000 --host "" --tls --tls-on-demand-url="http://localhost:4567/check" + +The On-demand URL endpoint will have to answer a 200 HTTP status code. +Kamal Proxy will call the on-demand URL with a query string of `?host=` containing the host received by Kamal Proxy. + +It also must respond as fast as possible, a couple of milliseconds top. + + ### Custom TLS certificate When you obtained your TLS certificate manually, manage your own certificate authority, diff --git a/internal/cmd/deploy.go b/internal/cmd/deploy.go index 82ae5c4..0563937 100644 --- a/internal/cmd/deploy.go +++ b/internal/cmd/deploy.go @@ -30,6 +30,7 @@ func newDeployCommand() *deployCommand { deployCommand.cmd.Flags().StringSliceVar(&deployCommand.args.Hosts, "host", []string{}, "Host(s) to serve this target on (empty for wildcard)") deployCommand.cmd.Flags().BoolVar(&deployCommand.args.ServiceOptions.TLSEnabled, "tls", false, "Configure TLS for this target (requires a non-empty host)") + deployCommand.cmd.Flags().StringVar(&deployCommand.args.ServiceOptions.TLSOnDemandUrl, "tls-on-demand-url", "", "Will make an HTTP request to the given URL, asking whether a host is allowed to have a certificate issued.") deployCommand.cmd.Flags().BoolVar(&deployCommand.tlsStaging, "tls-staging", false, "Use Let's Encrypt staging environment for certificate provisioning") deployCommand.cmd.Flags().StringVar(&deployCommand.args.ServiceOptions.TLSCertificatePath, "tls-certificate-path", "", "Configure custom TLS certificate path (PEM format)") deployCommand.cmd.Flags().StringVar(&deployCommand.args.ServiceOptions.TLSPrivateKeyPath, "tls-private-key-path", "", "Configure custom TLS private key path (PEM format)") diff --git a/internal/server/service.go b/internal/server/service.go index 5636ae8..089abaa 100644 --- a/internal/server/service.go +++ b/internal/server/service.go @@ -1,13 +1,16 @@ package server import ( + "context" "crypto/sha256" "encoding/hex" "encoding/json" "errors" + "fmt" "log/slog" "net" "net/http" + "net/url" "os" "path" "strings" @@ -66,6 +69,7 @@ type HealthCheckConfig struct { type ServiceOptions struct { TLSEnabled bool `json:"tls_enabled"` + TLSOnDemandUrl string `json:"tls_on_demand_url"` TLSCertificatePath string `json:"tls_certificate_path"` TLSPrivateKeyPath string `json:"tls_private_key_path"` ACMEDirectory string `json:"acme_directory"` @@ -318,14 +322,49 @@ func (s *Service) createCertManager(hosts []string, options ServiceOptions) (Cer } } + hostPolicy, err := s.createAutoCertHostPolicy(hosts, options) + if err != nil { + return nil, err + } + return &autocert.Manager{ Prompt: autocert.AcceptTOS, Cache: autocert.DirCache(options.ScopedCachePath()), - HostPolicy: autocert.HostWhitelist(hosts...), + HostPolicy: hostPolicy, Client: &acme.Client{DirectoryURL: options.ACMEDirectory}, }, nil } +func (s *Service) createAutoCertHostPolicy(hosts []string, options ServiceOptions) (autocert.HostPolicy, error) { + if options.TLSOnDemandUrl == "" { + return autocert.HostWhitelist(hosts...), nil + } + + _, err := url.ParseRequestURI(options.TLSOnDemandUrl) + if err != nil { + slog.Error("Unable to parse the tls_on_demand_url URL") + return nil, err + } + + slog.Info("Will use the tls_on_demand_url URL", "url", options.TLSOnDemandUrl) + + return func(ctx context.Context, host string) error { + slog.Info("Get a certificate", "host", host) + + resp, err := http.Get(fmt.Sprintf("%s?host=%s", options.TLSOnDemandUrl, url.QueryEscape(host))) + if err != nil { + slog.Error("Unable to reach the TLS on demand URL", host, err) + return err + } + + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("%s is not allowed to get a certificate", host) + } + + return nil + }, nil +} + func (s *Service) createMiddleware(options ServiceOptions, certManager CertManager) (http.Handler, error) { var err error var handler http.Handler = http.HandlerFunc(s.serviceRequestWithTarget)