From b807ef5c4465749a02fc0574acdce93eede3279d Mon Sep 17 00:00:00 2001 From: Did Date: Fri, 1 Nov 2024 23:05:03 +0000 Subject: [PATCH 01/25] implement the On-Demand TLS functionality --- README.md | 14 +++++++ internal/cmd/deploy.go | 1 + internal/server/service.go | 75 +++++++++++++++++++++++++++++++++++++- 3 files changed, 88 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 03d34de3..caa88de0 100644 --- a/README.md +++ b/README.md @@ -134,6 +134,20 @@ root path. Services deployed to other paths on the same host will use the same TLS settings as those specified for the root path. +## 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=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 01010c89..70185eb4 100644 --- a/internal/cmd/deploy.go +++ b/internal/cmd/deploy.go @@ -34,6 +34,7 @@ func newDeployCommand() *deployCommand { deployCommand.cmd.Flags().BoolVar(&deployCommand.args.ServiceOptions.StripPrefix, "strip-path-prefix", true, "With --path-prefix, strip prefix from request before forwarding") 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 56953137..35c122b6 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" "slices" @@ -83,6 +86,7 @@ type ServiceOptions struct { TLSEnabled bool `json:"tls_enabled"` TLSCertificatePath string `json:"tls_certificate_path"` TLSPrivateKeyPath string `json:"tls_private_key_path"` + TLSOnDemandUrl string `json:"tls_on_demand_url"` TLSRedirect bool `json:"tls_redirect"` CanonicalHost string `json:"canonical_host"` ACMEDirectory string `json:"acme_directory"` @@ -386,20 +390,87 @@ func (s *Service) createCertManager(options ServiceOptions) (CertManager, error) // Ensure we're not trying to use Let's Encrypt to fetch a wildcard domain, // as that is not supported with the challenge types that we use. - for _, host := range options.Hosts { + for _, host := range options.Hosts && options.TLSOnDemandUrl == "" { if strings.Contains(host, "*") { return nil, ErrorAutomaticTLSDoesNotSupportWildcards } } + // // TODO: + // // - create a func instead to return the host policy + // // - create the function calling the on demand url + // var hostPolicy = autocert.HostWhitelist(hosts...) + + // // https://stackoverflow.com/questions/52129908/can-i-have-a-dynamic-host-policty-with-autocert + + // // Wildcard hosts!!! we can handle them now! + // if len(hosts) == 0 && options.TLSOnDemandUrl != "" { + // fmt.Println("🚀🚀🚀 Registering a custom hostPolicy for", hosts) + // hostPolicy = func(ctx context.Context, host string) error { + // slog.Debug("Contacting", options.TLSOnDemandUrl, host) + + // resp, err := http.Get(fmt.Sprintf("%s?domain=%s", options.TLSOnDemandUrl, url.QueryEscape(host))) + + // if err != nil { + // // the TLS on demand URL is not reachable + // slog.Error("Unable to reach the TLS on demand URL", host, err) + // return err + // } + + // if resp.StatusCode != 200 && resp.StatusCode != 201 { + // return fmt.Errorf("%s is not allowed to get a certificate", host) + // } + + // return nil + // } + // } else { + // fmt.Println("😕😕😕", len(hosts), hosts, options.TLSOnDemandUrl) + // } + + 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(options.Hosts...), + HostPolicy: hostPolicy, Client: &acme.Client{DirectoryURL: options.ACMEDirectory}, }, nil } +func (s *Service) createAutoCertHostPolicy(hosts []string, options ServiceOptions) (autocert.HostPolicy, error) { + onDemandTls := len(hosts) == 0 && options.TLSOnDemandUrl != "" + + if !onDemandTls { + 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 + } + + return func(ctx context.Context, host string) error { + 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) From fbbcdd3b388d3d2fabcd6afd9ed9b0aeec3afec7 Mon Sep 17 00:00:00 2001 From: Did Date: Fri, 1 Nov 2024 23:12:42 +0000 Subject: [PATCH 02/25] remove debugging statements and old code --- internal/server/service.go | 31 ------------------------------- 1 file changed, 31 deletions(-) diff --git a/internal/server/service.go b/internal/server/service.go index 35c122b6..f6836630 100644 --- a/internal/server/service.go +++ b/internal/server/service.go @@ -396,37 +396,6 @@ func (s *Service) createCertManager(options ServiceOptions) (CertManager, error) } } - // // TODO: - // // - create a func instead to return the host policy - // // - create the function calling the on demand url - // var hostPolicy = autocert.HostWhitelist(hosts...) - - // // https://stackoverflow.com/questions/52129908/can-i-have-a-dynamic-host-policty-with-autocert - - // // Wildcard hosts!!! we can handle them now! - // if len(hosts) == 0 && options.TLSOnDemandUrl != "" { - // fmt.Println("🚀🚀🚀 Registering a custom hostPolicy for", hosts) - // hostPolicy = func(ctx context.Context, host string) error { - // slog.Debug("Contacting", options.TLSOnDemandUrl, host) - - // resp, err := http.Get(fmt.Sprintf("%s?domain=%s", options.TLSOnDemandUrl, url.QueryEscape(host))) - - // if err != nil { - // // the TLS on demand URL is not reachable - // slog.Error("Unable to reach the TLS on demand URL", host, err) - // return err - // } - - // if resp.StatusCode != 200 && resp.StatusCode != 201 { - // return fmt.Errorf("%s is not allowed to get a certificate", host) - // } - - // return nil - // } - // } else { - // fmt.Println("😕😕😕", len(hosts), hosts, options.TLSOnDemandUrl) - // } - hostPolicy, err := s.createAutoCertHostPolicy(hosts, options) if err != nil { From 92398c03734e57e99957d73dc023d17491405128 Mon Sep 17 00:00:00 2001 From: Did Date: Fri, 1 Nov 2024 23:23:20 +0000 Subject: [PATCH 03/25] the on-demand URL must contain the http scheme --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index caa88de0..710a03af 100644 --- a/README.md +++ b/README.md @@ -140,7 +140,7 @@ In addition of the automatic TLS functionality, Kamal Proxy can also dynamically 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=localhost:4567/check + 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. From d22e6a3037d1909295a861267b4ddadd0ca719d8 Mon Sep 17 00:00:00 2001 From: Didier Lafforgue Date: Sun, 3 Nov 2024 16:02:23 +0100 Subject: [PATCH 04/25] Update README.md --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 710a03af..0631fcc3 100644 --- a/README.md +++ b/README.md @@ -134,7 +134,7 @@ root path. Services deployed to other paths on the same host will use the same TLS settings as those specified for the root path. -## On-demand 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. From 24ab71c712626687f4156eca12ec73afc4d981de Mon Sep 17 00:00:00 2001 From: Didier Lafforgue Date: Sat, 30 Nov 2024 15:16:34 +0100 Subject: [PATCH 05/25] don't check for the len of hosts when evaluating the TLSOnDemandUrl --- internal/server/service.go | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/internal/server/service.go b/internal/server/service.go index f6836630..aa7a2ee3 100644 --- a/internal/server/service.go +++ b/internal/server/service.go @@ -411,9 +411,9 @@ func (s *Service) createCertManager(options ServiceOptions) (CertManager, error) } func (s *Service) createAutoCertHostPolicy(hosts []string, options ServiceOptions) (autocert.HostPolicy, error) { - onDemandTls := len(hosts) == 0 && options.TLSOnDemandUrl != "" + slog.Info("createAutoCertHostPolicy called", options.TLSOnDemandUrl, len(hosts), "🚨", "ok") - if !onDemandTls { + if options.TLSOnDemandUrl == "" { return autocert.HostWhitelist(hosts...), nil } @@ -424,7 +424,11 @@ func (s *Service) createAutoCertHostPolicy(hosts []string, options ServiceOption return nil, err } + slog.Info("Will use the tls_on_demand_url URL") + return func(ctx context.Context, host string) error { + slog.Info("Get a certificate for", host, "🤞") + resp, err := http.Get(fmt.Sprintf("%s?host=%s", options.TLSOnDemandUrl, url.QueryEscape(host))) if err != nil { From 89ad21719787ab412947add84e4dcf3cae94ec60 Mon Sep 17 00:00:00 2001 From: Didier Lafforgue Date: Tue, 3 Dec 2024 00:09:08 +0100 Subject: [PATCH 06/25] chore: lint the code --- internal/server/service.go | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/internal/server/service.go b/internal/server/service.go index aa7a2ee3..7873060d 100644 --- a/internal/server/service.go +++ b/internal/server/service.go @@ -397,7 +397,6 @@ func (s *Service) createCertManager(options ServiceOptions) (CertManager, error) } hostPolicy, err := s.createAutoCertHostPolicy(hosts, options) - if err != nil { return nil, err } @@ -411,14 +410,13 @@ func (s *Service) createCertManager(options ServiceOptions) (CertManager, error) } func (s *Service) createAutoCertHostPolicy(hosts []string, options ServiceOptions) (autocert.HostPolicy, error) { - slog.Info("createAutoCertHostPolicy called", options.TLSOnDemandUrl, len(hosts), "🚨", "ok") + slog.Info("createAutoCertHostPolicy called", "url", options.TLSOnDemandUrl) 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 @@ -427,10 +425,9 @@ func (s *Service) createAutoCertHostPolicy(hosts []string, options ServiceOption slog.Info("Will use the tls_on_demand_url URL") return func(ctx context.Context, host string) error { - slog.Info("Get a certificate for", host, "🤞") + 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 From 80cd7f9f37cd7a42d8b8dcc6858da391f761d6a9 Mon Sep 17 00:00:00 2001 From: Didier Lafforgue Date: Tue, 3 Dec 2024 00:10:55 +0100 Subject: [PATCH 07/25] chore: remove a debug message --- internal/server/service.go | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/internal/server/service.go b/internal/server/service.go index 7873060d..7688a2b5 100644 --- a/internal/server/service.go +++ b/internal/server/service.go @@ -410,8 +410,6 @@ func (s *Service) createCertManager(options ServiceOptions) (CertManager, error) } func (s *Service) createAutoCertHostPolicy(hosts []string, options ServiceOptions) (autocert.HostPolicy, error) { - slog.Info("createAutoCertHostPolicy called", "url", options.TLSOnDemandUrl) - if options.TLSOnDemandUrl == "" { return autocert.HostWhitelist(hosts...), nil } @@ -422,7 +420,7 @@ func (s *Service) createAutoCertHostPolicy(hosts []string, options ServiceOption return nil, err } - slog.Info("Will use the tls_on_demand_url URL") + 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) From 3247a5178ef7c0486a77c15736f4e3f6d9a45e3e Mon Sep 17 00:00:00 2001 From: Did Date: Wed, 9 Jul 2025 17:53:43 +0200 Subject: [PATCH 08/25] chore: better logging when contacting the on demand url failed + write tests for the service.createAutoCertHostPolicy function --- README.md | 23 +++++++--- internal/server/service.go | 22 ++++++---- internal/server/service_test.go | 74 +++++++++++++++++++++++++++++++++ 3 files changed, 107 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index 0631fcc3..436b9117 100644 --- a/README.md +++ b/README.md @@ -136,16 +136,29 @@ TLS settings as those specified for the root path. ### 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. +In addition to the automatic TLS functionality, Kamal Proxy can also dynamically obtain a TLS certificate +for 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 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. +The On-demand URL endpoint must return a 200 HTTP status code to allow certificate issuance. 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. +- The HTTP request to the on-demand URL will time out after 2 seconds. If the endpoint is unreachable or slow, certificate issuance will fail for that host. +- If the endpoint returns any status other than 200, Kamal Proxy will log the status code and up to 256 bytes of the response body for debugging. +- **Security note:** The on-demand URL acts as an authorization gate for certificate issuance. It should be protected and only allow trusted hosts. If compromised, unauthorized certificates could be issued. +- If `--tls-on-demand-url` is not set, Kamal Proxy falls back to a static whitelist of hosts. + +**Best practice:** +- Ensure your on-demand endpoint is fast, reliable, and protected (e.g., behind authentication or on a private network). +- Only allow hosts you control to prevent abuse. + +Example endpoint logic (pseudo-code): + + if host in allowed_hosts: + return 200 OK + else: + return 403 Forbidden ### Custom TLS certificate diff --git a/internal/server/service.go b/internal/server/service.go index 7688a2b5..982bf7ed 100644 --- a/internal/server/service.go +++ b/internal/server/service.go @@ -390,13 +390,13 @@ func (s *Service) createCertManager(options ServiceOptions) (CertManager, error) // Ensure we're not trying to use Let's Encrypt to fetch a wildcard domain, // as that is not supported with the challenge types that we use. - for _, host := range options.Hosts && options.TLSOnDemandUrl == "" { + for _, host := range options.Hosts { if strings.Contains(host, "*") { return nil, ErrorAutomaticTLSDoesNotSupportWildcards } } - hostPolicy, err := s.createAutoCertHostPolicy(hosts, options) + hostPolicy, err := s.createAutoCertHostPolicy(options) if err != nil { return nil, err } @@ -409,9 +409,9 @@ func (s *Service) createCertManager(options ServiceOptions) (CertManager, error) }, nil } -func (s *Service) createAutoCertHostPolicy(hosts []string, options ServiceOptions) (autocert.HostPolicy, error) { +func (s *Service) createAutoCertHostPolicy(options ServiceOptions) (autocert.HostPolicy, error) { if options.TLSOnDemandUrl == "" { - return autocert.HostWhitelist(hosts...), nil + return autocert.HostWhitelist(options.Hosts...), nil } _, err := url.ParseRequestURI(options.TLSOnDemandUrl) @@ -422,17 +422,25 @@ func (s *Service) createAutoCertHostPolicy(hosts []string, options ServiceOption slog.Info("Will use the tls_on_demand_url URL", "url", options.TLSOnDemandUrl) + client := &http.Client{Timeout: 2 * time.Second} + 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))) + url := fmt.Sprintf("%s?host=%s", options.TLSOnDemandUrl, url.QueryEscape(host)) + resp, err := client.Get(url) if err != nil { - slog.Error("Unable to reach the TLS on demand URL", host, err) + slog.Error("Unable to reach the TLS on demand URL", "host", host, "error", err) return err } + defer resp.Body.Close() if resp.StatusCode != http.StatusOK { - return fmt.Errorf("%s is not allowed to get a certificate", host) + body := make([]byte, 256) + n, _ := resp.Body.Read(body) + msg := fmt.Sprintf("%s is not allowed to get a certificate (status: %d, body: %q)", host, resp.StatusCode, string(body[:n])) + slog.Warn("TLS on demand denied host", "host", host, "status", resp.StatusCode, "body", string(body[:n])) + return fmt.Errorf(msg) } return nil diff --git a/internal/server/service_test.go b/internal/server/service_test.go index 6dd39d46..d6b92daa 100644 --- a/internal/server/service_test.go +++ b/internal/server/service_test.go @@ -2,6 +2,7 @@ package server import ( "bytes" + "context" "encoding/json" "net/http" "net/http/httptest" @@ -215,6 +216,78 @@ func TestService_UnmarshallingStateFromLegacyFormat(t *testing.T) { assert.Equal(t, []string{"/"}, service.options.PathPrefixes) assert.Equal(t, 3*time.Second, service.targetOptions.ResponseTimeout) } +func TestService_AutoCertHostPolicy_TLSEnabledFalse_Whitelist(t *testing.T) { + hosts := []string{"foo.example.com", "bar.example.com"} + options := ServiceOptions{TLSEnabled: false, TLSOnDemandUrl: "", Hosts: hosts} + service := testCreateService(t, options, defaultTargetOptions) + + hostPolicy, err := service.createAutoCertHostPolicy(options) + require.NoError(t, err) + + t.Run("allowed host", func(t *testing.T) { + err := hostPolicy(context.Background(), "foo.example.com") + assert.NoError(t, err) + }) + + t.Run("denied host", func(t *testing.T) { + err := hostPolicy(context.Background(), "baz.example.com") + assert.Error(t, err) + }) +} + +func TestService_OnDemandTLSHostPolicy_AllowsAndDenies(t *testing.T) { + // Simulate an external API that allows only "allowed.example.com" + api := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + host := r.URL.Query().Get("host") + if host == "allowed.example.com" { + w.WriteHeader(http.StatusOK) + } else { + w.WriteHeader(http.StatusForbidden) + } + })) + t.Cleanup(api.Close) + + options := ServiceOptions{ + TLSEnabled: true, + TLSOnDemandUrl: api.URL, + Hosts: []string{""}, // empty hosts for on-demand tls + } + + service := testCreateService(t, options, defaultTargetOptions) + + hostPolicy, err := service.createAutoCertHostPolicy(options) + require.NoError(t, err) + + t.Run("allowed host", func(t *testing.T) { + err := hostPolicy(context.Background(), "allowed.example.com") + assert.NoError(t, err) + }) + + t.Run("denied host", func(t *testing.T) { + err := hostPolicy(context.Background(), "denied.example.com") + assert.Error(t, err) + assert.Contains(t, err.Error(), "denied.example.com is not allowed") + }) +} + +func TestService_OnDemandTLSHostPolicy_WhitelistFallback(t *testing.T) { + hosts := []string{"foo.example.com", "bar.example.com"} + options := ServiceOptions{TLSEnabled: true, TLSOnDemandUrl: "", Hosts: hosts} + service := testCreateService(t, options, defaultTargetOptions) + + hostPolicy, err := service.createAutoCertHostPolicy(options) + require.NoError(t, err) + + t.Run("allowed host", func(t *testing.T) { + err := hostPolicy(context.Background(), "foo.example.com") + assert.NoError(t, err) + }) + + t.Run("denied host", func(t *testing.T) { + err := hostPolicy(context.Background(), "baz.example.com") + assert.Error(t, err) + }) +} func testCreateService(t *testing.T, options ServiceOptions, targetOptions TargetOptions) *Service { return testCreateServiceWithHandler(t, options, targetOptions, @@ -224,6 +297,7 @@ func testCreateService(t *testing.T, options ServiceOptions, targetOptions Targe func testCreateServiceWithHandler(t *testing.T, options ServiceOptions, targetOptions TargetOptions, handler http.Handler) *Service { server := httptest.NewServer(handler) + t.Cleanup(server.Close) serverURL, err := url.Parse(server.URL) From 25a7d09eb03f2c4ffcde03e071a3cc15c1a01e01 Mon Sep 17 00:00:00 2001 From: Did Date: Thu, 10 Jul 2025 20:02:19 +0200 Subject: [PATCH 09/25] chore: remove a warning about a wrong type --- internal/server/service.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/server/service.go b/internal/server/service.go index 982bf7ed..628c4519 100644 --- a/internal/server/service.go +++ b/internal/server/service.go @@ -440,7 +440,7 @@ func (s *Service) createAutoCertHostPolicy(options ServiceOptions) (autocert.Hos n, _ := resp.Body.Read(body) msg := fmt.Sprintf("%s is not allowed to get a certificate (status: %d, body: %q)", host, resp.StatusCode, string(body[:n])) slog.Warn("TLS on demand denied host", "host", host, "status", resp.StatusCode, "body", string(body[:n])) - return fmt.Errorf(msg) + return fmt.Errorf("%s", msg) } return nil From 08de3cdab9fbd7f5ae78b7eb3b57ae93ad45d9b2 Mon Sep 17 00:00:00 2001 From: Did Date: Thu, 10 Jul 2025 23:27:27 +0200 Subject: [PATCH 10/25] feat: no need to pass the host config attribute if tls-on-demand-url is present --- internal/cmd/deploy.go | 5 +++++ internal/cmd/deploy_test.go | 37 +++++++++++++++++++++++++++++++++++++ 2 files changed, 42 insertions(+) diff --git a/internal/cmd/deploy.go b/internal/cmd/deploy.go index 70185eb4..c7514001 100644 --- a/internal/cmd/deploy.go +++ b/internal/cmd/deploy.go @@ -102,6 +102,11 @@ func (c *deployCommand) preRun(cmd *cobra.Command, args []string) error { } if c.args.ServiceOptions.TLSEnabled { + if c.args.ServiceOptions.TLSOnDemandUrl != "" { + c.args.ServiceOptions.Hosts = []string{""} + return nil + } + if len(c.args.ServiceOptions.Hosts) == 0 { return fmt.Errorf("host must be set when using TLS") } diff --git a/internal/cmd/deploy_test.go b/internal/cmd/deploy_test.go index ded8a2a5..1e3ffc30 100644 --- a/internal/cmd/deploy_test.go +++ b/internal/cmd/deploy_test.go @@ -76,3 +76,40 @@ func TestDeployCommand_CanonicalHostValidation(t *testing.T) { }) } } + +func TestDeployCommand_preRun_TLSOnDemandUrl(t *testing.T) { + t.Run("TLS enabled with TLS on-demand URL should set hosts to empty string", func(t *testing.T) { + deployCmd := newDeployCommand() + + // Set flags for TLS with on-demand URL + deployCmd.cmd.Flags().Set("target", "http://localhost:8080") + deployCmd.cmd.Flags().Set("tls", "true") + deployCmd.cmd.Flags().Set("tls-on-demand-url", "http://example.com/validate") + deployCmd.cmd.Flags().Set("host", "example.com") + deployCmd.cmd.Flags().Set("path-prefix", "/") + + // Call preRun + err := deployCmd.preRun(deployCmd.cmd, []string{"test-service"}) + require.NoError(t, err) + + // Verify that hosts is set to empty string + assert.Equal(t, []string{""}, deployCmd.args.ServiceOptions.Hosts) + }) + + t.Run("TLS enabled without TLS on-demand URL should not modify hosts", func(t *testing.T) { + deployCmd := newDeployCommand() + + // Set flags for TLS without on-demand URL + deployCmd.cmd.Flags().Set("target", "http://localhost:8080") + deployCmd.cmd.Flags().Set("tls", "true") + deployCmd.cmd.Flags().Set("host", "example.com") + deployCmd.cmd.Flags().Set("path-prefix", "/") + + // Call preRun + err := deployCmd.preRun(deployCmd.cmd, []string{"test-service"}) + require.NoError(t, err) + + // Verify that hosts is not modified + assert.Equal(t, []string{"example.com"}, deployCmd.args.ServiceOptions.Hosts) + }) +} From 4160a10d3ae6d4d7b456887a0ad1aa91eb017e6d Mon Sep 17 00:00:00 2001 From: Did Date: Fri, 11 Jul 2025 15:35:57 +0200 Subject: [PATCH 11/25] feat: allow path and external url for the TLSOnDemandURL option --- internal/server/service.go | 43 +------ internal/server/service_test.go | 73 ----------- internal/server/tls_on_demand.go | 105 ++++++++++++++++ internal/server/tls_on_demand_test.go | 174 ++++++++++++++++++++++++++ 4 files changed, 280 insertions(+), 115 deletions(-) create mode 100644 internal/server/tls_on_demand.go create mode 100644 internal/server/tls_on_demand_test.go diff --git a/internal/server/service.go b/internal/server/service.go index 628c4519..e767c01d 100644 --- a/internal/server/service.go +++ b/internal/server/service.go @@ -1,16 +1,13 @@ package server import ( - "context" "crypto/sha256" "encoding/hex" "encoding/json" "errors" - "fmt" "log/slog" "net" "net/http" - "net/url" "os" "path" "slices" @@ -396,7 +393,7 @@ func (s *Service) createCertManager(options ServiceOptions) (CertManager, error) } } - hostPolicy, err := s.createAutoCertHostPolicy(options) + hostPolicy, err := NewTLSOnDemandChecker(s).HostPolicy() if err != nil { return nil, err } @@ -409,44 +406,6 @@ func (s *Service) createCertManager(options ServiceOptions) (CertManager, error) }, nil } -func (s *Service) createAutoCertHostPolicy(options ServiceOptions) (autocert.HostPolicy, error) { - if options.TLSOnDemandUrl == "" { - return autocert.HostWhitelist(options.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) - - client := &http.Client{Timeout: 2 * time.Second} - - return func(ctx context.Context, host string) error { - slog.Info("Get a certificate", "host", host) - - url := fmt.Sprintf("%s?host=%s", options.TLSOnDemandUrl, url.QueryEscape(host)) - resp, err := client.Get(url) - if err != nil { - slog.Error("Unable to reach the TLS on demand URL", "host", host, "error", err) - return err - } - defer resp.Body.Close() - - if resp.StatusCode != http.StatusOK { - body := make([]byte, 256) - n, _ := resp.Body.Read(body) - msg := fmt.Sprintf("%s is not allowed to get a certificate (status: %d, body: %q)", host, resp.StatusCode, string(body[:n])) - slog.Warn("TLS on demand denied host", "host", host, "status", resp.StatusCode, "body", string(body[:n])) - return fmt.Errorf("%s", msg) - } - - 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) diff --git a/internal/server/service_test.go b/internal/server/service_test.go index d6b92daa..e9cffcd5 100644 --- a/internal/server/service_test.go +++ b/internal/server/service_test.go @@ -2,7 +2,6 @@ package server import ( "bytes" - "context" "encoding/json" "net/http" "net/http/httptest" @@ -216,78 +215,6 @@ func TestService_UnmarshallingStateFromLegacyFormat(t *testing.T) { assert.Equal(t, []string{"/"}, service.options.PathPrefixes) assert.Equal(t, 3*time.Second, service.targetOptions.ResponseTimeout) } -func TestService_AutoCertHostPolicy_TLSEnabledFalse_Whitelist(t *testing.T) { - hosts := []string{"foo.example.com", "bar.example.com"} - options := ServiceOptions{TLSEnabled: false, TLSOnDemandUrl: "", Hosts: hosts} - service := testCreateService(t, options, defaultTargetOptions) - - hostPolicy, err := service.createAutoCertHostPolicy(options) - require.NoError(t, err) - - t.Run("allowed host", func(t *testing.T) { - err := hostPolicy(context.Background(), "foo.example.com") - assert.NoError(t, err) - }) - - t.Run("denied host", func(t *testing.T) { - err := hostPolicy(context.Background(), "baz.example.com") - assert.Error(t, err) - }) -} - -func TestService_OnDemandTLSHostPolicy_AllowsAndDenies(t *testing.T) { - // Simulate an external API that allows only "allowed.example.com" - api := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - host := r.URL.Query().Get("host") - if host == "allowed.example.com" { - w.WriteHeader(http.StatusOK) - } else { - w.WriteHeader(http.StatusForbidden) - } - })) - t.Cleanup(api.Close) - - options := ServiceOptions{ - TLSEnabled: true, - TLSOnDemandUrl: api.URL, - Hosts: []string{""}, // empty hosts for on-demand tls - } - - service := testCreateService(t, options, defaultTargetOptions) - - hostPolicy, err := service.createAutoCertHostPolicy(options) - require.NoError(t, err) - - t.Run("allowed host", func(t *testing.T) { - err := hostPolicy(context.Background(), "allowed.example.com") - assert.NoError(t, err) - }) - - t.Run("denied host", func(t *testing.T) { - err := hostPolicy(context.Background(), "denied.example.com") - assert.Error(t, err) - assert.Contains(t, err.Error(), "denied.example.com is not allowed") - }) -} - -func TestService_OnDemandTLSHostPolicy_WhitelistFallback(t *testing.T) { - hosts := []string{"foo.example.com", "bar.example.com"} - options := ServiceOptions{TLSEnabled: true, TLSOnDemandUrl: "", Hosts: hosts} - service := testCreateService(t, options, defaultTargetOptions) - - hostPolicy, err := service.createAutoCertHostPolicy(options) - require.NoError(t, err) - - t.Run("allowed host", func(t *testing.T) { - err := hostPolicy(context.Background(), "foo.example.com") - assert.NoError(t, err) - }) - - t.Run("denied host", func(t *testing.T) { - err := hostPolicy(context.Background(), "baz.example.com") - assert.Error(t, err) - }) -} func testCreateService(t *testing.T, options ServiceOptions, targetOptions TargetOptions) *Service { return testCreateServiceWithHandler(t, options, targetOptions, diff --git a/internal/server/tls_on_demand.go b/internal/server/tls_on_demand.go new file mode 100644 index 00000000..4a8cac14 --- /dev/null +++ b/internal/server/tls_on_demand.go @@ -0,0 +1,105 @@ +package server + +import ( + "context" + "fmt" + "net/http" + "net/http/httptest" + "net/url" + "time" + + "log/slog" + + "golang.org/x/crypto/acme/autocert" +) + +type TLSOnDemandChecker struct { + service *Service + options ServiceOptions +} + +func NewTLSOnDemandChecker(service *Service) *TLSOnDemandChecker { + return &TLSOnDemandChecker{ + service: service, + options: service.options, + } +} + +func (c *TLSOnDemandChecker) HostPolicy() (autocert.HostPolicy, error) { + if c.options.TLSOnDemandUrl == "" { + return autocert.HostWhitelist(c.options.Hosts...), nil + } + + // If the URL starts with '/', treat it as a local path + if len(c.options.TLSOnDemandUrl) > 0 && c.options.TLSOnDemandUrl[0] == '/' { + return c.LocalHostPolicy(), nil + } + + // Otherwise, treat as external URL + _, err := url.ParseRequestURI(c.options.TLSOnDemandUrl) + + if err != nil { + slog.Error("Unable to parse the tls_on_demand_url URL") + return nil, err + } + + return c.ExternalHostPolicy(), nil +} + +func (c *TLSOnDemandChecker) LocalHostPolicy() autocert.HostPolicy { + return func(ctx context.Context, host string) error { + path := c.buildURLOrPath(host) + req, err := http.NewRequestWithContext(ctx, http.MethodGet, path, nil) + if err != nil { + return err + } + + // We use httptest.NewRecorder here to route the request through the service's + // load balancer and handler, capturing the response in-memory without making + // a real network request. This ensures the request is processed as if it were + // an external client, but avoids network overhead and complexity. + recorder := httptest.NewRecorder() + c.service.ServeHTTP(recorder, req) + + if recorder.Code != http.StatusOK { + body := recorder.Body.String() + + if len(body) > 256 { + body = body[:256] + } + + return c.handleError(host, recorder.Code, body) + } + return nil + } +} + +func (c *TLSOnDemandChecker) ExternalHostPolicy() autocert.HostPolicy { + return func(ctx context.Context, host string) error { + client := &http.Client{Timeout: 2 * time.Second} + url := c.buildURLOrPath(host) + resp, err := client.Get(url) + if err != nil { + return err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + body := make([]byte, 256) + n, _ := resp.Body.Read(body) + bodyStr := string(body[:n]) + return c.handleError(host, resp.StatusCode, bodyStr) + } + return nil + } +} + +func (c *TLSOnDemandChecker) buildURLOrPath(host string) string { + return fmt.Sprintf("%s?host=%s", c.options.TLSOnDemandUrl, url.QueryEscape(host)) +} + +func (c *TLSOnDemandChecker) handleError(host string, status int, body string) error { + slog.Warn("TLS on demand denied host", "host", host, "status", status, "body", body) + + return fmt.Errorf("%s is not allowed to get a certificate (status: %d, body: \"%s\")", host, status, body) +} diff --git a/internal/server/tls_on_demand_test.go b/internal/server/tls_on_demand_test.go new file mode 100644 index 00000000..3896925a --- /dev/null +++ b/internal/server/tls_on_demand_test.go @@ -0,0 +1,174 @@ +package server + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestTLSOnDemandChecker_HostPolicy_EmptyURL(t *testing.T) { + service := &Service{options: ServiceOptions{Hosts: []string{"example.com"}}} + checker := NewTLSOnDemandChecker(service) + + policy, _ := checker.HostPolicy() + + // Should allow hosts in the whitelist + err := policy(context.Background(), "example.com") + assert.NoError(t, err) + + // Should deny hosts not in the whitelist + err = policy(context.Background(), "other.com") + assert.Error(t, err) +} + +func TestTLSOnDemandChecker_LocalHostPolicy_Success(t *testing.T) { + // Create a mock service that returns 200 for /allow-host + service := &Service{ + options: ServiceOptions{TLSOnDemandUrl: "/allow-host"}, + } + service.middleware = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/allow-host" && r.URL.Query().Get("host") == "test.example.com" { + w.WriteHeader(http.StatusOK) + } else { + w.WriteHeader(http.StatusForbidden) + } + }) + + checker := NewTLSOnDemandChecker(service) + policy := checker.LocalHostPolicy() + + err := policy(context.Background(), "test.example.com") + assert.NoError(t, err) +} + +func TestTLSOnDemandChecker_LocalHostPolicy_Denied(t *testing.T) { + // Create a mock service that returns 403 for /allow-host + service := &Service{ + options: ServiceOptions{TLSOnDemandUrl: "/allow-host"}, + } + service.middleware = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusForbidden) + w.Write([]byte("Access denied")) + }) + + checker := NewTLSOnDemandChecker(service) + policy := checker.LocalHostPolicy() + + err := policy(context.Background(), "test.example.com") + assert.Error(t, err) + assert.Contains(t, err.Error(), "not allowed to get a certificate") + assert.Contains(t, err.Error(), "status: 403") +} + +func TestTLSOnDemandChecker_LocalHostPolicy_LargeResponseBody(t *testing.T) { + // Create a mock service that returns a large response body + largeBody := string(make([]byte, 500)) // 500 bytes + + service := &Service{ + options: ServiceOptions{TLSOnDemandUrl: "/allow-host"}, + } + service.middleware = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusForbidden) + w.Write([]byte(largeBody)) + }) + + checker := NewTLSOnDemandChecker(service) + policy := checker.LocalHostPolicy() + + err := policy(context.Background(), "test.example.com") + assert.Error(t, err) + assert.Contains(t, err.Error(), "status: 403") + + // Verify the body is truncated to 256 bytes + assert.Len(t, err.Error(), 256+len("test.example.com is not allowed to get a certificate (status: 403, body: \"")+len("\")")) +} + +func TestTLSOnDemandChecker_ExternalHostPolicy_Success(t *testing.T) { + // Create a test server that returns 200 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Query().Get("host") == "test.example.com" { + w.WriteHeader(http.StatusOK) + } else { + w.WriteHeader(http.StatusForbidden) + } + })) + defer server.Close() + + service := &Service{options: ServiceOptions{TLSOnDemandUrl: server.URL}} + checker := NewTLSOnDemandChecker(service) + policy := checker.ExternalHostPolicy() + + err := policy(context.Background(), "test.example.com") + assert.NoError(t, err) +} + +func TestTLSOnDemandChecker_ExternalHostPolicy_Denied(t *testing.T) { + // Create a test server that returns 403 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusForbidden) + w.Write([]byte("Access denied")) + })) + defer server.Close() + + service := &Service{options: ServiceOptions{TLSOnDemandUrl: server.URL}} + checker := NewTLSOnDemandChecker(service) + policy := checker.ExternalHostPolicy() + + err := policy(context.Background(), "test.example.com") + assert.Error(t, err) + assert.Contains(t, err.Error(), "not allowed to get a certificate") + assert.Contains(t, err.Error(), "status: 403") +} + +func TestTLSOnDemandChecker_HostPolicy_LocalPath(t *testing.T) { + service := &Service{ + options: ServiceOptions{TLSOnDemandUrl: "/allow-host"}, + } + service.middleware = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + }) + + checker := NewTLSOnDemandChecker(service) + policy, _ := checker.HostPolicy() + + err := policy(context.Background(), "test.example.com") + assert.NoError(t, err) +} + +func TestTLSOnDemandChecker_HostPolicy_ExternalURL(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + })) + defer server.Close() + + service := &Service{options: ServiceOptions{TLSOnDemandUrl: server.URL}} + checker := NewTLSOnDemandChecker(service) + policy, _ := checker.HostPolicy() + + err := policy(context.Background(), "test.example.com") + assert.NoError(t, err) +} + +func TestTLSOnDemandChecker_HostPolicy_InvalidExternalURL(t *testing.T) { + service := &Service{options: ServiceOptions{TLSOnDemandUrl: "://invalid-url"}} + checker := NewTLSOnDemandChecker(service) + _, err := checker.HostPolicy() + + assert.Error(t, err) + assert.Contains(t, err.Error(), "missing protocol scheme") +} + +func TestTLSOnDemandChecker_buildURLOrPath(t *testing.T) { + service := &Service{options: ServiceOptions{TLSOnDemandUrl: "/allow-host"}} + checker := NewTLSOnDemandChecker(service) + + url := checker.buildURLOrPath("test.example.com") + assert.Equal(t, "/allow-host?host=test.example.com", url) + + // Test with special characters + url = checker.buildURLOrPath("test.example.com:8080") + assert.Equal(t, "/allow-host?host=test.example.com%3A8080", url) +} From adf8772456ce77b08779a56bc6c29c3a3ac5f4fc Mon Sep 17 00:00:00 2001 From: Did Date: Fri, 11 Jul 2025 15:38:20 +0200 Subject: [PATCH 12/25] chore: update the README.md based on the new path functionality --- README.md | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/README.md b/README.md index 436b9117..39fec0fe 100644 --- a/README.md +++ b/README.md @@ -170,6 +170,29 @@ 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 +## TLSOnDemandUrl Option + +The `TLSOnDemandUrl` option can be set to either: + +- **An external URL** (e.g., `https://my-allow-service/allow-host`): + - The service will make an HTTP request to this external URL to determine if a certificate should be issued for a given host. + +- **A local path** (e.g., `/allow-host`): + - The service will internally route a request to this path using its own load balancer and handler. You must ensure your service responds to this path appropriately. + +### Example: External URL +```yaml +TLSOnDemandUrl: "https://my-allow-service/allow-host" +``` + +### Example: Local Path +```yaml +TLSOnDemandUrl: "/allow-host" +``` + +When using a local path, your service should implement a handler for the specified path (e.g., `/allow-host`) that returns `200 OK` to allow certificate issuance, or another status code to deny it. + + ## Specifying `run` options with environment variables In some environments, like when running a Docker container, it can be convenient From 30eadc4bfbb1862e1cb19a349894406f8ecea6ea Mon Sep 17 00:00:00 2001 From: Did Date: Fri, 8 Aug 2025 12:13:29 +0200 Subject: [PATCH 13/25] fix: don't pass a Nil body when contacting a local host --- internal/server/tls_on_demand.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/server/tls_on_demand.go b/internal/server/tls_on_demand.go index 4a8cac14..c0d3562e 100644 --- a/internal/server/tls_on_demand.go +++ b/internal/server/tls_on_demand.go @@ -49,7 +49,7 @@ func (c *TLSOnDemandChecker) HostPolicy() (autocert.HostPolicy, error) { func (c *TLSOnDemandChecker) LocalHostPolicy() autocert.HostPolicy { return func(ctx context.Context, host string) error { path := c.buildURLOrPath(host) - req, err := http.NewRequestWithContext(ctx, http.MethodGet, path, nil) + req, err := http.NewRequestWithContext(ctx, http.MethodGet, path, http.NoBody) if err != nil { return err } From 6daf93ab9c84e3783a8e16e7d7df8eacaddc5c33 Mon Sep 17 00:00:00 2001 From: Did Date: Wed, 25 Feb 2026 18:52:14 +0100 Subject: [PATCH 14/25] fix: new options were not correctly applied when restoring the state --- internal/server/router_test.go | 121 +++++++++++++++++++++++++-------- internal/server/service.go | 12 +++- 2 files changed, 102 insertions(+), 31 deletions(-) diff --git a/internal/server/router_test.go b/internal/server/router_test.go index c1252b3c..4602fbd1 100644 --- a/internal/server/router_test.go +++ b/internal/server/router_test.go @@ -1,8 +1,10 @@ package server import ( + "context" + "crypto/tls" "encoding/json" - "crypto/tls" + "fmt" "net/http" "net/http/httptest" "os" @@ -11,6 +13,8 @@ import ( "testing" "time" + "golang.org/x/crypto/acme/autocert" + "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -211,45 +215,45 @@ func TestRouter_UpdatingOptions(t *testing.T) { } func TestRouter_CanonicalHostRedirect(t *testing.T) { - router := testRouter(t) - _, target := testBackend(t, "first", http.StatusOK) + router := testRouter(t) + _, target := testBackend(t, "first", http.StatusOK) - serviceOptions := defaultServiceOptions - serviceOptions.Hosts = []string{"example.com", "www.example.com"} - serviceOptions.CanonicalHost = "example.com" + serviceOptions := defaultServiceOptions + serviceOptions.Hosts = []string{"example.com", "www.example.com"} + serviceOptions.CanonicalHost = "example.com" - require.NoError(t, router.DeployService("service1", []string{target}, defaultEmptyReaders, serviceOptions, defaultTargetOptions, defaultDeploymentOptions)) + require.NoError(t, router.DeployService("service1", []string{target}, defaultEmptyReaders, serviceOptions, defaultTargetOptions, defaultDeploymentOptions)) - statusCode, _ := sendGETRequest(router, "http://www.example.com/") - assert.Equal(t, http.StatusMovedPermanently, statusCode) + statusCode, _ := sendGETRequest(router, "http://www.example.com/") + assert.Equal(t, http.StatusMovedPermanently, statusCode) - statusCode, body := sendGETRequest(router, "http://example.com/") - assert.Equal(t, http.StatusOK, statusCode) - assert.Equal(t, "first", body) + statusCode, body := sendGETRequest(router, "http://example.com/") + assert.Equal(t, http.StatusOK, statusCode) + assert.Equal(t, "first", body) } func TestRouter_CanonicalHostRedirectWithTLS(t *testing.T) { - router := testRouter(t) - _, target := testBackend(t, "first", http.StatusOK) + router := testRouter(t) + _, target := testBackend(t, "first", http.StatusOK) - serviceOptions := defaultServiceOptions - serviceOptions.Hosts = []string{"example.com", "www.example.com"} - serviceOptions.CanonicalHost = "example.com" - serviceOptions.TLSEnabled = true - serviceOptions.TLSRedirect = true + serviceOptions := defaultServiceOptions + serviceOptions.Hosts = []string{"example.com", "www.example.com"} + serviceOptions.CanonicalHost = "example.com" + serviceOptions.TLSEnabled = true + serviceOptions.TLSRedirect = true - require.NoError(t, router.DeployService("service1", []string{target}, defaultEmptyReaders, serviceOptions, defaultTargetOptions, defaultDeploymentOptions)) + require.NoError(t, router.DeployService("service1", []string{target}, defaultEmptyReaders, serviceOptions, defaultTargetOptions, defaultDeploymentOptions)) - // Should go directly to https://example.com in a single redirect - statusCode, _ := sendGETRequest(router, "http://www.example.com/") - assert.Equal(t, http.StatusMovedPermanently, statusCode) + // Should go directly to https://example.com in a single redirect + statusCode, _ := sendGETRequest(router, "http://www.example.com/") + assert.Equal(t, http.StatusMovedPermanently, statusCode) - // HTTPS request to non-canonical host should redirect to canonical host but remain HTTPS - req := httptest.NewRequest(http.MethodGet, "https://www.example.com/", nil) - req.TLS = &tls.ConnectionState{} - w := httptest.NewRecorder() - router.ServeHTTP(w, req) - assert.Equal(t, http.StatusMovedPermanently, w.Result().StatusCode) + // HTTPS request to non-canonical host should redirect to canonical host but remain HTTPS + req := httptest.NewRequest(http.MethodGet, "https://www.example.com/", nil) + req.TLS = &tls.ConnectionState{} + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + assert.Equal(t, http.StatusMovedPermanently, w.Result().StatusCode) } func TestRouter_DeploymentsWithErrorsDoNotUpdateService(t *testing.T) { @@ -771,6 +775,65 @@ func TestRouter_RestoreLastSavedState(t *testing.T) { assert.Equal(t, "third", body) } +func TestRouter_RestoreLastSavedState_WithTLSOnDemandURL_HostPolicyUsesOnDemandChecker(t *testing.T) { + statePath := filepath.Join(t.TempDir(), "state.json") + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + })) + defer server.Close() + + state := fmt.Sprintf(`[{ + "name":"ondemand", + "options":{ + "hosts":[""], + "path_prefixes":["/"], + "tls_enabled":true, + "tls_certificate_path":"", + "tls_private_key_path":"", + "tls_on_demand_url":"%s", + "tls_redirect":false, + "canonical_host":"", + "acme_directory":"", + "acme_cache_path":"", + "error_page_path":"", + "strip_prefix":true, + "writer_affinity_timeout":1000000000, + "read_targets_accept_websockets":false + }, + "target_options":{ + "health_check_config":{"path":"/up","port":0,"interval":1000000000,"timeout":5000000000,"host":""}, + "response_timeout":30000000000, + "buffer_requests":false, + "buffer_responses":false, + "max_memory_buffer_size":1048576, + "max_request_body_size":0, + "max_response_body_size":0, + "log_request_headers":null, + "log_response_headers":null, + "forward_headers":false, + "scope_cookie_paths":false + }, + "active_targets":["localhost:3000"], + "active_readers":[], + "rollout_targets":null, + "rollout_readers":null, + "pause_controller":{"state":0,"stop_message":"","fail_after":0}, + "rollout_controller":null + }]`, server.URL) + require.NoError(t, os.WriteFile(statePath, []byte(state), 0600)) + + router := NewRouter(statePath) + require.NoError(t, router.RestoreLastSavedState()) + + service := router.services.Get("ondemand") + require.NotNil(t, service) + manager, ok := service.certManager.(*autocert.Manager) + require.True(t, ok) + require.NotNil(t, manager.HostPolicy) + + assert.NoError(t, manager.HostPolicy(context.Background(), "tenant.example.com")) +} + // Helpers func testRouter(t *testing.T) *Router { diff --git a/internal/server/service.go b/internal/server/service.go index e767c01d..18747bf9 100644 --- a/internal/server/service.go +++ b/internal/server/service.go @@ -331,18 +331,26 @@ func (s *Service) Resume() error { // Private func (s *Service) initialize(options ServiceOptions, targetOptions TargetOptions) error { + previousOptions := s.options + previousTargetOptions := s.targetOptions + + s.options = options + s.targetOptions = targetOptions + certManager, err := s.createCertManager(options) if err != nil { + s.options = previousOptions + s.targetOptions = previousTargetOptions return err } middleware, err := s.createMiddleware(options, certManager) if err != nil { + s.options = previousOptions + s.targetOptions = previousTargetOptions return err } - s.options = options - s.targetOptions = targetOptions s.certManager = certManager s.middleware = middleware From 7fa1b2d3a410e392ceffa3b42e6f535b8fbe24d8 Mon Sep 17 00:00:00 2001 From: Did Date: Thu, 26 Feb 2026 22:56:00 +0100 Subject: [PATCH 15/25] fix: treat local TLS on-demand checks as HTTPS when redirects are enabled --- internal/server/tls_on_demand.go | 2 ++ internal/server/tls_on_demand_test.go | 30 +++++++++++++++++++++++++++ 2 files changed, 32 insertions(+) diff --git a/internal/server/tls_on_demand.go b/internal/server/tls_on_demand.go index c0d3562e..d10eaca5 100644 --- a/internal/server/tls_on_demand.go +++ b/internal/server/tls_on_demand.go @@ -2,6 +2,7 @@ package server import ( "context" + "crypto/tls" "fmt" "net/http" "net/http/httptest" @@ -53,6 +54,7 @@ func (c *TLSOnDemandChecker) LocalHostPolicy() autocert.HostPolicy { if err != nil { return err } + req.TLS = &tls.ConnectionState{} // We use httptest.NewRecorder here to route the request through the service's // load balancer and handler, capturing the response in-memory without making diff --git a/internal/server/tls_on_demand_test.go b/internal/server/tls_on_demand_test.go index 3896925a..c411d926 100644 --- a/internal/server/tls_on_demand_test.go +++ b/internal/server/tls_on_demand_test.go @@ -44,6 +44,36 @@ func TestTLSOnDemandChecker_LocalHostPolicy_Success(t *testing.T) { assert.NoError(t, err) } +func TestTLSOnDemandChecker_LocalHostPolicy_WithTLSRedirect(t *testing.T) { + service := testCreateServiceWithHandler( + t, + ServiceOptions{ + Hosts: []string{"example.com"}, + TLSEnabled: true, + TLSRedirect: true, + TLSOnDemandUrl: "/allow-host", + }, + defaultTargetOptions, + http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/up" { + w.WriteHeader(http.StatusOK) + return + } + if r.URL.Path == "/allow-host" && r.URL.Query().Get("host") == "test.example.com" { + w.WriteHeader(http.StatusOK) + return + } + w.WriteHeader(http.StatusForbidden) + }), + ) + + checker := NewTLSOnDemandChecker(service) + policy := checker.LocalHostPolicy() + + err := policy(context.Background(), "test.example.com") + assert.NoError(t, err) +} + func TestTLSOnDemandChecker_LocalHostPolicy_Denied(t *testing.T) { // Create a mock service that returns 403 for /allow-host service := &Service{ From 65bef1e2081bf02560eb4c2d1fb1e91a4ce5eb6b Mon Sep 17 00:00:00 2001 From: Did Date: Thu, 26 Feb 2026 23:22:51 +0100 Subject: [PATCH 16/25] fix: the previous didn't work in production, use another approach by enhancing the context of the check request --- internal/server/service.go | 5 ++++- internal/server/tls_on_demand.go | 6 ++++-- internal/server/tls_on_demand_test.go | 4 ++++ 3 files changed, 12 insertions(+), 3 deletions(-) diff --git a/internal/server/service.go b/internal/server/service.go index 18747bf9..859fb704 100644 --- a/internal/server/service.go +++ b/internal/server/service.go @@ -54,6 +54,8 @@ var ( ErrorRolloutTargetNotSet = errors.New("rollout target not set") ErrorUnableToLoadErrorPages = errors.New("unable to load error pages") ErrorAutomaticTLSDoesNotSupportWildcards = errors.New("automatic TLS does not support wildcards") + + contextKeyTLSOnDemandCheck = contextKey("tls-on-demand-check") ) type TargetSlot int @@ -516,7 +518,8 @@ func (s *Service) redirectURLIfNeeded(r *http.Request) string { } desiredScheme := currentScheme - if s.options.TLSEnabled && s.options.TLSRedirect && currentScheme == "http" { + isTLSOnDemandCheck, _ := r.Context().Value(contextKeyTLSOnDemandCheck).(bool) + if s.options.TLSEnabled && s.options.TLSRedirect && currentScheme == "http" && !isTLSOnDemandCheck { desiredScheme = "https" } diff --git a/internal/server/tls_on_demand.go b/internal/server/tls_on_demand.go index d10eaca5..761b1439 100644 --- a/internal/server/tls_on_demand.go +++ b/internal/server/tls_on_demand.go @@ -2,7 +2,6 @@ package server import ( "context" - "crypto/tls" "fmt" "net/http" "net/http/httptest" @@ -54,7 +53,10 @@ func (c *TLSOnDemandChecker) LocalHostPolicy() autocert.HostPolicy { if err != nil { return err } - req.TLS = &tls.ConnectionState{} + + // We need to set this context value to true to indicate that this is a TLS on demand check + ctx = context.WithValue(req.Context(), contextKeyTLSOnDemandCheck, true) + req = req.WithContext(ctx) // We use httptest.NewRecorder here to route the request through the service's // load balancer and handler, capturing the response in-memory without making diff --git a/internal/server/tls_on_demand_test.go b/internal/server/tls_on_demand_test.go index c411d926..89acabfc 100644 --- a/internal/server/tls_on_demand_test.go +++ b/internal/server/tls_on_demand_test.go @@ -45,6 +45,8 @@ func TestTLSOnDemandChecker_LocalHostPolicy_Success(t *testing.T) { } func TestTLSOnDemandChecker_LocalHostPolicy_WithTLSRedirect(t *testing.T) { + var forwardedProto string + service := testCreateServiceWithHandler( t, ServiceOptions{ @@ -60,6 +62,7 @@ func TestTLSOnDemandChecker_LocalHostPolicy_WithTLSRedirect(t *testing.T) { return } if r.URL.Path == "/allow-host" && r.URL.Query().Get("host") == "test.example.com" { + forwardedProto = r.Header.Get("X-Forwarded-Proto") w.WriteHeader(http.StatusOK) return } @@ -72,6 +75,7 @@ func TestTLSOnDemandChecker_LocalHostPolicy_WithTLSRedirect(t *testing.T) { err := policy(context.Background(), "test.example.com") assert.NoError(t, err) + assert.Equal(t, "http", forwardedProto) } func TestTLSOnDemandChecker_LocalHostPolicy_Denied(t *testing.T) { From a55cfa5a2bc5a5897f11dfbfb7264dc7bcfaf88e Mon Sep 17 00:00:00 2001 From: Did Date: Wed, 4 Mar 2026 23:34:54 +0100 Subject: [PATCH 17/25] chore: satisfy Copilot code review (WIP) --- README.md | 3 +- internal/server/router_test.go | 64 +++++++++++++++++++++++++++ internal/server/tls_on_demand.go | 4 +- internal/server/tls_on_demand_test.go | 10 +++-- 4 files changed, 74 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 39fec0fe..50d4cf8c 100644 --- a/README.md +++ b/README.md @@ -142,12 +142,13 @@ for any host allowed by an external API endpoint of your choice. This avoids har kamal-proxy deploy service1 --target web-1:3000 --host "" --tls --tls-on-demand-url="http://localhost:4567/check" The On-demand URL endpoint must return a 200 HTTP status code to allow certificate issuance. -Kamal Proxy will call the on-demand URL with a query string of `?host=` containing the host received by Kamal Proxy. +Kamal Proxy will call the on-demand URL with a query string parameter `host` containing the hostname received by Kamal Proxy (for example, `?host=hostname.example.com`). - The HTTP request to the on-demand URL will time out after 2 seconds. If the endpoint is unreachable or slow, certificate issuance will fail for that host. - If the endpoint returns any status other than 200, Kamal Proxy will log the status code and up to 256 bytes of the response body for debugging. - **Security note:** The on-demand URL acts as an authorization gate for certificate issuance. It should be protected and only allow trusted hosts. If compromised, unauthorized certificates could be issued. - If `--tls-on-demand-url` is not set, Kamal Proxy falls back to a static whitelist of hosts. +- If using a local path (for example, `/allow-host`), ensure that endpoint is reachable through your deployed app and returns quickly. **Best practice:** - Ensure your on-demand endpoint is fast, reliable, and protected (e.g., behind authentication or on a private network). diff --git a/internal/server/router_test.go b/internal/server/router_test.go index 4602fbd1..5784028b 100644 --- a/internal/server/router_test.go +++ b/internal/server/router_test.go @@ -834,6 +834,70 @@ func TestRouter_RestoreLastSavedState_WithTLSOnDemandURL_HostPolicyUsesOnDemandC assert.NoError(t, manager.HostPolicy(context.Background(), "tenant.example.com")) } +func TestRouter_RestoreLastSavedState_WithTLSOnDemandURL_HostPolicyDeniesWhenCheckerRejects(t *testing.T) { + statePath := filepath.Join(t.TempDir(), "state-deny.json") + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Query().Get("host") == "tenant.example.com" { + w.WriteHeader(http.StatusOK) + return + } + w.WriteHeader(http.StatusForbidden) + })) + defer server.Close() + + state := fmt.Sprintf(`[{ + "name":"ondemand-deny", + "options":{ + "hosts":[""], + "path_prefixes":["/"], + "tls_enabled":true, + "tls_certificate_path":"", + "tls_private_key_path":"", + "tls_on_demand_url":"%s", + "tls_redirect":false, + "canonical_host":"", + "acme_directory":"", + "acme_cache_path":"", + "error_page_path":"", + "strip_prefix":true, + "writer_affinity_timeout":1000000000, + "read_targets_accept_websockets":false + }, + "target_options":{ + "health_check_config":{"path":"/up","port":0,"interval":1000000000,"timeout":5000000000,"host":""}, + "response_timeout":30000000000, + "buffer_requests":false, + "buffer_responses":false, + "max_memory_buffer_size":1048576, + "max_request_body_size":0, + "max_response_body_size":0, + "log_request_headers":null, + "log_response_headers":null, + "forward_headers":false, + "scope_cookie_paths":false + }, + "active_targets":["localhost:3000"], + "active_readers":[], + "rollout_targets":null, + "rollout_readers":null, + "pause_controller":{"state":0,"stop_message":"","fail_after":0}, + "rollout_controller":null + }]`, server.URL) + require.NoError(t, os.WriteFile(statePath, []byte(state), 0600)) + + router := NewRouter(statePath) + require.NoError(t, router.RestoreLastSavedState()) + + service := router.services.Get("ondemand-deny") + require.NotNil(t, service) + manager, ok := service.certManager.(*autocert.Manager) + require.True(t, ok) + require.NotNil(t, manager.HostPolicy) + + assert.NoError(t, manager.HostPolicy(context.Background(), "tenant.example.com")) + assert.Error(t, manager.HostPolicy(context.Background(), "denied.example.com")) +} + // Helpers func testRouter(t *testing.T) *Router { diff --git a/internal/server/tls_on_demand.go b/internal/server/tls_on_demand.go index 761b1439..5d178278 100644 --- a/internal/server/tls_on_demand.go +++ b/internal/server/tls_on_demand.go @@ -81,8 +81,8 @@ func (c *TLSOnDemandChecker) LocalHostPolicy() autocert.HostPolicy { func (c *TLSOnDemandChecker) ExternalHostPolicy() autocert.HostPolicy { return func(ctx context.Context, host string) error { client := &http.Client{Timeout: 2 * time.Second} - url := c.buildURLOrPath(host) - resp, err := client.Get(url) + requestURL := c.buildURLOrPath(host) + resp, err := client.Get(requestURL) if err != nil { return err } diff --git a/internal/server/tls_on_demand_test.go b/internal/server/tls_on_demand_test.go index 89acabfc..20b121c3 100644 --- a/internal/server/tls_on_demand_test.go +++ b/internal/server/tls_on_demand_test.go @@ -166,9 +166,10 @@ func TestTLSOnDemandChecker_HostPolicy_LocalPath(t *testing.T) { }) checker := NewTLSOnDemandChecker(service) - policy, _ := checker.HostPolicy() + policy, err := checker.HostPolicy() + assert.NoError(t, err) - err := policy(context.Background(), "test.example.com") + err = policy(context.Background(), "test.example.com") assert.NoError(t, err) } @@ -180,9 +181,10 @@ func TestTLSOnDemandChecker_HostPolicy_ExternalURL(t *testing.T) { service := &Service{options: ServiceOptions{TLSOnDemandUrl: server.URL}} checker := NewTLSOnDemandChecker(service) - policy, _ := checker.HostPolicy() + policy, err := checker.HostPolicy() + assert.NoError(t, err) - err := policy(context.Background(), "test.example.com") + err = policy(context.Background(), "test.example.com") assert.NoError(t, err) } From bb45ac320cf44e30b4a335672358a0c42a50edbf Mon Sep 17 00:00:00 2001 From: Didier Lafforgue Date: Wed, 4 Mar 2026 23:36:54 +0100 Subject: [PATCH 18/25] Update internal/server/service_test.go removing it to keep the diff focused on functional changes Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- internal/server/service_test.go | 1 - 1 file changed, 1 deletion(-) diff --git a/internal/server/service_test.go b/internal/server/service_test.go index e9cffcd5..6dd39d46 100644 --- a/internal/server/service_test.go +++ b/internal/server/service_test.go @@ -224,7 +224,6 @@ func testCreateService(t *testing.T, options ServiceOptions, targetOptions Targe func testCreateServiceWithHandler(t *testing.T, options ServiceOptions, targetOptions TargetOptions, handler http.Handler) *Service { server := httptest.NewServer(handler) - t.Cleanup(server.Close) serverURL, err := url.Parse(server.URL) From 78f89e8e284d556c4d59716e16f54c59a26e08be Mon Sep 17 00:00:00 2001 From: Didier Lafforgue Date: Wed, 4 Mar 2026 23:39:19 +0100 Subject: [PATCH 19/25] Update internal/server/tls_on_demand.go include the actual error object with the "error" key for better debugging Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- internal/server/tls_on_demand.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/server/tls_on_demand.go b/internal/server/tls_on_demand.go index 5d178278..82fad931 100644 --- a/internal/server/tls_on_demand.go +++ b/internal/server/tls_on_demand.go @@ -39,7 +39,7 @@ func (c *TLSOnDemandChecker) HostPolicy() (autocert.HostPolicy, error) { _, err := url.ParseRequestURI(c.options.TLSOnDemandUrl) if err != nil { - slog.Error("Unable to parse the tls_on_demand_url URL") + slog.Error("Unable to parse the tls_on_demand_url URL", "error", err, "url", c.options.TLSOnDemandUrl) return nil, err } From d588d81649e431d657fcfe1710da6a046d25c4cc Mon Sep 17 00:00:00 2001 From: Did Date: Wed, 4 Mar 2026 23:52:04 +0100 Subject: [PATCH 20/25] chore: don't bypass the path prefix validation check --- internal/cmd/deploy.go | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/internal/cmd/deploy.go b/internal/cmd/deploy.go index c7514001..888b3ea5 100644 --- a/internal/cmd/deploy.go +++ b/internal/cmd/deploy.go @@ -102,6 +102,10 @@ func (c *deployCommand) preRun(cmd *cobra.Command, args []string) error { } if c.args.ServiceOptions.TLSEnabled { + if !slices.Contains(c.args.ServiceOptions.PathPrefixes, "/") { + return fmt.Errorf("TLS settings must be specified on the root path service") + } + if c.args.ServiceOptions.TLSOnDemandUrl != "" { c.args.ServiceOptions.Hosts = []string{""} return nil @@ -110,10 +114,6 @@ func (c *deployCommand) preRun(cmd *cobra.Command, args []string) error { if len(c.args.ServiceOptions.Hosts) == 0 { return fmt.Errorf("host must be set when using TLS") } - - if !slices.Contains(c.args.ServiceOptions.PathPrefixes, "/") { - return fmt.Errorf("TLS settings must be specified on the root path service") - } } // Validate canonical host is present in hosts when both are specified From af473991f97c1ec490c4cdc6bd559971c3f7e3ec Mon Sep 17 00:00:00 2001 From: Didier Lafforgue Date: Wed, 4 Mar 2026 23:54:44 +0100 Subject: [PATCH 21/25] Update internal/server/router_test.go add an assertion that verifies a different host is denied when the external URL returns a non-200 status code Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- internal/server/router_test.go | 58 ++++++++++++++++++++++++++++++++++ 1 file changed, 58 insertions(+) diff --git a/internal/server/router_test.go b/internal/server/router_test.go index 5784028b..599c8149 100644 --- a/internal/server/router_test.go +++ b/internal/server/router_test.go @@ -834,6 +834,64 @@ func TestRouter_RestoreLastSavedState_WithTLSOnDemandURL_HostPolicyUsesOnDemandC assert.NoError(t, manager.HostPolicy(context.Background(), "tenant.example.com")) } +func TestRouter_RestoreLastSavedState_WithTLSOnDemandURL_HostPolicyDeniesOnNon200(t *testing.T) { + statePath := filepath.Join(t.TempDir(), "state-deny.json") + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusForbidden) + })) + defer server.Close() + + state := fmt.Sprintf(`[{ + "name":"ondemand-deny", + "options":{ + "hosts":[""], + "path_prefixes":["/"], + "tls_enabled":true, + "tls_certificate_path":"", + "tls_private_key_path":"", + "tls_on_demand_url":"%s", + "tls_redirect":false, + "canonical_host":"", + "acme_directory":"", + "acme_cache_path":"", + "error_page_path":"", + "strip_prefix":true, + "writer_affinity_timeout":1000000000, + "read_targets_accept_websockets":false + }, + "target_options":{ + "health_check_config":{"path":"/up","port":0,"interval":1000000000,"timeout":5000000000,"host":""}, + "response_timeout":30000000000, + "buffer_requests":false, + "buffer_responses":false, + "max_memory_buffer_size":1048576, + "max_request_body_size":0, + "max_response_body_size":0, + "log_request_headers":null, + "log_response_headers":null, + "forward_headers":false, + "scope_cookie_paths":false + }, + "active_targets":["localhost:3000"], + "active_readers":[], + "rollout_targets":null, + "rollout_readers":null, + "pause_controller":{"state":0,"stop_message":"","fail_after":0}, + "rollout_controller":null + }]`, server.URL) + require.NoError(t, os.WriteFile(statePath, []byte(state), 0600)) + + router := NewRouter(statePath) + require.NoError(t, router.RestoreLastSavedState()) + + service := router.services.Get("ondemand-deny") + require.NotNil(t, service) + manager, ok := service.certManager.(*autocert.Manager) + require.True(t, ok) + require.NotNil(t, manager.HostPolicy) + + assert.Error(t, manager.HostPolicy(context.Background(), "denied.example.com")) +} func TestRouter_RestoreLastSavedState_WithTLSOnDemandURL_HostPolicyDeniesWhenCheckerRejects(t *testing.T) { statePath := filepath.Join(t.TempDir(), "state-deny.json") server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { From d0228bc6461691fe86fecc401ea663ce86cbf38d Mon Sep 17 00:00:00 2001 From: Did Date: Wed, 4 Mar 2026 23:59:21 +0100 Subject: [PATCH 22/25] chore: rename TLSOnDemandUrl into TLSOnDemandURL --- README.md | 25 +++++++++---------------- internal/cmd/deploy.go | 4 ++-- internal/cmd/deploy_test.go | 2 +- internal/server/service.go | 2 +- internal/server/tls_on_demand.go | 10 +++++----- internal/server/tls_on_demand_test.go | 20 ++++++++++---------- 6 files changed, 28 insertions(+), 35 deletions(-) diff --git a/README.md b/README.md index 50d4cf8c..9c619d92 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,5 @@ # Kamal Proxy - A minimal HTTP proxy for zero-downtime deployments - ## What it does Kamal Proxy is a tiny HTTP proxy, designed to make it easy to coordinate @@ -14,7 +13,6 @@ which provides a complete deployment experience including container packaging and provisioning. However, Kamal Proxy could also be used standalone or as part of other deployment tooling. - ## A quick overview To run an instance of the proxy, use the `kamal-proxy run` command. There's no @@ -98,7 +96,6 @@ Only one service at a time can route a specific host: kamal-proxy remove service1 kamal-proxy deploy service2 --target web-2:3000 --host app1.example.com # succeeds - ### Path-based routing For applications that split their traffic to different services based on the @@ -118,7 +115,6 @@ the original path (including the prefix), specify `--strip-path-prefix=false`: kamal-proxy deploy service1 --target web-1:3000 --path-prefix=/api --strip-path-prefix=false - ### Automatic TLS Kamal Proxy can automatically obtain and renew TLS certificates for your @@ -133,15 +129,14 @@ Additionally, when using path-based routing, TLS options must be set on the root path. Services deployed to other paths on the same host will use the same TLS settings as those specified for the root path. - ### On-demand TLS -In addition to the automatic TLS functionality, Kamal Proxy can also dynamically obtain a TLS certificate +In addition to the automatic TLS functionality, Kamal Proxy can also dynamically obtain a TLS certificate for 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 startup. kamal-proxy deploy service1 --target web-1:3000 --host "" --tls --tls-on-demand-url="http://localhost:4567/check" -The On-demand URL endpoint must return a 200 HTTP status code to allow certificate issuance. +The On-demand URL endpoint must return a 200 HTTP status code to allow certificate issuance. Kamal Proxy will call the on-demand URL with a query string parameter `host` containing the hostname received by Kamal Proxy (for example, `?host=hostname.example.com`). - The HTTP request to the on-demand URL will time out after 2 seconds. If the endpoint is unreachable or slow, certificate issuance will fail for that host. @@ -151,6 +146,7 @@ Kamal Proxy will call the on-demand URL with a query string parameter `host` con - If using a local path (for example, `/allow-host`), ensure that endpoint is reachable through your deployed app and returns quickly. **Best practice:** + - Ensure your on-demand endpoint is fast, reliable, and protected (e.g., behind authentication or on a private network). - Only allow hosts you control to prevent abuse. @@ -161,7 +157,6 @@ Example endpoint logic (pseudo-code): else: return 403 Forbidden - ### Custom TLS certificate When you obtained your TLS certificate manually, manage your own certificate authority, @@ -170,10 +165,9 @@ 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 +## TLSOnDemandURL Option -## TLSOnDemandUrl Option - -The `TLSOnDemandUrl` option can be set to either: +The `TLSOnDemandURL` option can be set to either: - **An external URL** (e.g., `https://my-allow-service/allow-host`): - The service will make an HTTP request to this external URL to determine if a certificate should be issued for a given host. @@ -182,18 +176,19 @@ The `TLSOnDemandUrl` option can be set to either: - The service will internally route a request to this path using its own load balancer and handler. You must ensure your service responds to this path appropriately. ### Example: External URL + ```yaml -TLSOnDemandUrl: "https://my-allow-service/allow-host" +TLSOnDemandURL: "https://my-allow-service/allow-host" ``` ### Example: Local Path + ```yaml -TLSOnDemandUrl: "/allow-host" +TLSOnDemandURL: "/allow-host" ``` When using a local path, your service should implement a handler for the specified path (e.g., `/allow-host`) that returns `200 OK` to allow certificate issuance, or another status code to deny it. - ## Specifying `run` options with environment variables In some environments, like when running a Docker container, it can be convenient @@ -214,7 +209,6 @@ example: KAMAL_PROXY_HTTP_PORT=8080 kamal-proxy run - ## Building To build Kamal Proxy locally, if you have a working Go environment you can: @@ -225,7 +219,6 @@ Alternatively, build as a Docker container: make docker - ## Trying it out See the [example](./example) folder for a Docker Compose setup that you can use diff --git a/internal/cmd/deploy.go b/internal/cmd/deploy.go index 888b3ea5..bb82a364 100644 --- a/internal/cmd/deploy.go +++ b/internal/cmd/deploy.go @@ -34,7 +34,7 @@ func newDeployCommand() *deployCommand { deployCommand.cmd.Flags().BoolVar(&deployCommand.args.ServiceOptions.StripPrefix, "strip-path-prefix", true, "With --path-prefix, strip prefix from request before forwarding") 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().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)") @@ -106,7 +106,7 @@ func (c *deployCommand) preRun(cmd *cobra.Command, args []string) error { return fmt.Errorf("TLS settings must be specified on the root path service") } - if c.args.ServiceOptions.TLSOnDemandUrl != "" { + if c.args.ServiceOptions.TLSOnDemandURL != "" { c.args.ServiceOptions.Hosts = []string{""} return nil } diff --git a/internal/cmd/deploy_test.go b/internal/cmd/deploy_test.go index 1e3ffc30..a61cf54c 100644 --- a/internal/cmd/deploy_test.go +++ b/internal/cmd/deploy_test.go @@ -77,7 +77,7 @@ func TestDeployCommand_CanonicalHostValidation(t *testing.T) { } } -func TestDeployCommand_preRun_TLSOnDemandUrl(t *testing.T) { +func TestDeployCommand_preRun_TLSOnDemandURL(t *testing.T) { t.Run("TLS enabled with TLS on-demand URL should set hosts to empty string", func(t *testing.T) { deployCmd := newDeployCommand() diff --git a/internal/server/service.go b/internal/server/service.go index 859fb704..edcaf9af 100644 --- a/internal/server/service.go +++ b/internal/server/service.go @@ -85,7 +85,7 @@ type ServiceOptions struct { TLSEnabled bool `json:"tls_enabled"` TLSCertificatePath string `json:"tls_certificate_path"` TLSPrivateKeyPath string `json:"tls_private_key_path"` - TLSOnDemandUrl string `json:"tls_on_demand_url"` + TLSOnDemandURL string `json:"tls_on_demand_url"` TLSRedirect bool `json:"tls_redirect"` CanonicalHost string `json:"canonical_host"` ACMEDirectory string `json:"acme_directory"` diff --git a/internal/server/tls_on_demand.go b/internal/server/tls_on_demand.go index 82fad931..1523c642 100644 --- a/internal/server/tls_on_demand.go +++ b/internal/server/tls_on_demand.go @@ -26,20 +26,20 @@ func NewTLSOnDemandChecker(service *Service) *TLSOnDemandChecker { } func (c *TLSOnDemandChecker) HostPolicy() (autocert.HostPolicy, error) { - if c.options.TLSOnDemandUrl == "" { + if c.options.TLSOnDemandURL == "" { return autocert.HostWhitelist(c.options.Hosts...), nil } // If the URL starts with '/', treat it as a local path - if len(c.options.TLSOnDemandUrl) > 0 && c.options.TLSOnDemandUrl[0] == '/' { + if len(c.options.TLSOnDemandURL) > 0 && c.options.TLSOnDemandURL[0] == '/' { return c.LocalHostPolicy(), nil } // Otherwise, treat as external URL - _, err := url.ParseRequestURI(c.options.TLSOnDemandUrl) + _, err := url.ParseRequestURI(c.options.TLSOnDemandURL) if err != nil { - slog.Error("Unable to parse the tls_on_demand_url URL", "error", err, "url", c.options.TLSOnDemandUrl) + slog.Error("Unable to parse the tls_on_demand_url URL", "error", err, "url", c.options.TLSOnDemandURL) return nil, err } @@ -99,7 +99,7 @@ func (c *TLSOnDemandChecker) ExternalHostPolicy() autocert.HostPolicy { } func (c *TLSOnDemandChecker) buildURLOrPath(host string) string { - return fmt.Sprintf("%s?host=%s", c.options.TLSOnDemandUrl, url.QueryEscape(host)) + return fmt.Sprintf("%s?host=%s", c.options.TLSOnDemandURL, url.QueryEscape(host)) } func (c *TLSOnDemandChecker) handleError(host string, status int, body string) error { diff --git a/internal/server/tls_on_demand_test.go b/internal/server/tls_on_demand_test.go index 20b121c3..5e10e142 100644 --- a/internal/server/tls_on_demand_test.go +++ b/internal/server/tls_on_demand_test.go @@ -27,7 +27,7 @@ func TestTLSOnDemandChecker_HostPolicy_EmptyURL(t *testing.T) { func TestTLSOnDemandChecker_LocalHostPolicy_Success(t *testing.T) { // Create a mock service that returns 200 for /allow-host service := &Service{ - options: ServiceOptions{TLSOnDemandUrl: "/allow-host"}, + options: ServiceOptions{TLSOnDemandURL: "/allow-host"}, } service.middleware = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.URL.Path == "/allow-host" && r.URL.Query().Get("host") == "test.example.com" { @@ -53,7 +53,7 @@ func TestTLSOnDemandChecker_LocalHostPolicy_WithTLSRedirect(t *testing.T) { Hosts: []string{"example.com"}, TLSEnabled: true, TLSRedirect: true, - TLSOnDemandUrl: "/allow-host", + TLSOnDemandURL: "/allow-host", }, defaultTargetOptions, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { @@ -81,7 +81,7 @@ func TestTLSOnDemandChecker_LocalHostPolicy_WithTLSRedirect(t *testing.T) { func TestTLSOnDemandChecker_LocalHostPolicy_Denied(t *testing.T) { // Create a mock service that returns 403 for /allow-host service := &Service{ - options: ServiceOptions{TLSOnDemandUrl: "/allow-host"}, + options: ServiceOptions{TLSOnDemandURL: "/allow-host"}, } service.middleware = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusForbidden) @@ -102,7 +102,7 @@ func TestTLSOnDemandChecker_LocalHostPolicy_LargeResponseBody(t *testing.T) { largeBody := string(make([]byte, 500)) // 500 bytes service := &Service{ - options: ServiceOptions{TLSOnDemandUrl: "/allow-host"}, + options: ServiceOptions{TLSOnDemandURL: "/allow-host"}, } service.middleware = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusForbidden) @@ -131,7 +131,7 @@ func TestTLSOnDemandChecker_ExternalHostPolicy_Success(t *testing.T) { })) defer server.Close() - service := &Service{options: ServiceOptions{TLSOnDemandUrl: server.URL}} + service := &Service{options: ServiceOptions{TLSOnDemandURL: server.URL}} checker := NewTLSOnDemandChecker(service) policy := checker.ExternalHostPolicy() @@ -147,7 +147,7 @@ func TestTLSOnDemandChecker_ExternalHostPolicy_Denied(t *testing.T) { })) defer server.Close() - service := &Service{options: ServiceOptions{TLSOnDemandUrl: server.URL}} + service := &Service{options: ServiceOptions{TLSOnDemandURL: server.URL}} checker := NewTLSOnDemandChecker(service) policy := checker.ExternalHostPolicy() @@ -159,7 +159,7 @@ func TestTLSOnDemandChecker_ExternalHostPolicy_Denied(t *testing.T) { func TestTLSOnDemandChecker_HostPolicy_LocalPath(t *testing.T) { service := &Service{ - options: ServiceOptions{TLSOnDemandUrl: "/allow-host"}, + options: ServiceOptions{TLSOnDemandURL: "/allow-host"}, } service.middleware = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) @@ -179,7 +179,7 @@ func TestTLSOnDemandChecker_HostPolicy_ExternalURL(t *testing.T) { })) defer server.Close() - service := &Service{options: ServiceOptions{TLSOnDemandUrl: server.URL}} + service := &Service{options: ServiceOptions{TLSOnDemandURL: server.URL}} checker := NewTLSOnDemandChecker(service) policy, err := checker.HostPolicy() assert.NoError(t, err) @@ -189,7 +189,7 @@ func TestTLSOnDemandChecker_HostPolicy_ExternalURL(t *testing.T) { } func TestTLSOnDemandChecker_HostPolicy_InvalidExternalURL(t *testing.T) { - service := &Service{options: ServiceOptions{TLSOnDemandUrl: "://invalid-url"}} + service := &Service{options: ServiceOptions{TLSOnDemandURL: "://invalid-url"}} checker := NewTLSOnDemandChecker(service) _, err := checker.HostPolicy() @@ -198,7 +198,7 @@ func TestTLSOnDemandChecker_HostPolicy_InvalidExternalURL(t *testing.T) { } func TestTLSOnDemandChecker_buildURLOrPath(t *testing.T) { - service := &Service{options: ServiceOptions{TLSOnDemandUrl: "/allow-host"}} + service := &Service{options: ServiceOptions{TLSOnDemandURL: "/allow-host"}} checker := NewTLSOnDemandChecker(service) url := checker.buildURLOrPath("test.example.com") From fec3323f84cac822048ca7f35afaf06abfe60c1b Mon Sep 17 00:00:00 2001 From: Did Date: Thu, 5 Mar 2026 00:14:46 +0100 Subject: [PATCH 23/25] chore: parse and merge query params instead of string-concatenating: --- internal/server/tls_on_demand.go | 23 +++++++++++++++++++---- internal/server/tls_on_demand_test.go | 20 ++++++++++++++++++-- 2 files changed, 37 insertions(+), 6 deletions(-) diff --git a/internal/server/tls_on_demand.go b/internal/server/tls_on_demand.go index 1523c642..dff70562 100644 --- a/internal/server/tls_on_demand.go +++ b/internal/server/tls_on_demand.go @@ -48,7 +48,10 @@ func (c *TLSOnDemandChecker) HostPolicy() (autocert.HostPolicy, error) { func (c *TLSOnDemandChecker) LocalHostPolicy() autocert.HostPolicy { return func(ctx context.Context, host string) error { - path := c.buildURLOrPath(host) + path, err := c.buildURLOrPath(host) + if err != nil { + return err + } req, err := http.NewRequestWithContext(ctx, http.MethodGet, path, http.NoBody) if err != nil { return err @@ -81,7 +84,10 @@ func (c *TLSOnDemandChecker) LocalHostPolicy() autocert.HostPolicy { func (c *TLSOnDemandChecker) ExternalHostPolicy() autocert.HostPolicy { return func(ctx context.Context, host string) error { client := &http.Client{Timeout: 2 * time.Second} - requestURL := c.buildURLOrPath(host) + requestURL, err := c.buildURLOrPath(host) + if err != nil { + return err + } resp, err := client.Get(requestURL) if err != nil { return err @@ -98,8 +104,17 @@ func (c *TLSOnDemandChecker) ExternalHostPolicy() autocert.HostPolicy { } } -func (c *TLSOnDemandChecker) buildURLOrPath(host string) string { - return fmt.Sprintf("%s?host=%s", c.options.TLSOnDemandURL, url.QueryEscape(host)) +func (c *TLSOnDemandChecker) buildURLOrPath(host string) (string, error) { + u, err := url.Parse(c.options.TLSOnDemandURL) + if err != nil { + return "", err + } + + query := u.Query() + query.Set("host", host) + u.RawQuery = query.Encode() + + return u.String(), nil } func (c *TLSOnDemandChecker) handleError(host string, status int, body string) error { diff --git a/internal/server/tls_on_demand_test.go b/internal/server/tls_on_demand_test.go index 5e10e142..e0a1f67e 100644 --- a/internal/server/tls_on_demand_test.go +++ b/internal/server/tls_on_demand_test.go @@ -201,10 +201,26 @@ func TestTLSOnDemandChecker_buildURLOrPath(t *testing.T) { service := &Service{options: ServiceOptions{TLSOnDemandURL: "/allow-host"}} checker := NewTLSOnDemandChecker(service) - url := checker.buildURLOrPath("test.example.com") + url, err := checker.buildURLOrPath("test.example.com") + assert.NoError(t, err) assert.Equal(t, "/allow-host?host=test.example.com", url) // Test with special characters - url = checker.buildURLOrPath("test.example.com:8080") + url, err = checker.buildURLOrPath("test.example.com:8080") + assert.NoError(t, err) assert.Equal(t, "/allow-host?host=test.example.com%3A8080", url) + + // Keep existing query string on local path and append host correctly + service = &Service{options: ServiceOptions{TLSOnDemandURL: "/allow-host?token=abc"}} + checker = NewTLSOnDemandChecker(service) + url, err = checker.buildURLOrPath("test.example.com") + assert.NoError(t, err) + assert.Equal(t, "/allow-host?host=test.example.com&token=abc", url) + + // Keep existing query string on external URL and append host correctly + service = &Service{options: ServiceOptions{TLSOnDemandURL: "https://example.com/check?token=abc"}} + checker = NewTLSOnDemandChecker(service) + url, err = checker.buildURLOrPath("test.example.com") + assert.NoError(t, err) + assert.Equal(t, "https://example.com/check?host=test.example.com&token=abc", url) } From 3844ebdef5a51e7c15597db993834006d5ab07f3 Mon Sep 17 00:00:00 2001 From: Didier Lafforgue Date: Thu, 5 Mar 2026 00:22:18 +0100 Subject: [PATCH 24/25] Update internal/server/service.go fix: avoid potential host redirects if isTLSOnDemandCheck. Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- internal/server/service.go | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/internal/server/service.go b/internal/server/service.go index edcaf9af..8ff02791 100644 --- a/internal/server/service.go +++ b/internal/server/service.go @@ -519,7 +519,12 @@ func (s *Service) redirectURLIfNeeded(r *http.Request) string { desiredScheme := currentScheme isTLSOnDemandCheck, _ := r.Context().Value(contextKeyTLSOnDemandCheck).(bool) - if s.options.TLSEnabled && s.options.TLSRedirect && currentScheme == "http" && !isTLSOnDemandCheck { + if isTLSOnDemandCheck { + // During TLS-on-demand checks, avoid any redirects (TLS or canonical host) + // so that the internal probe sees the original endpoint response. + return "" + } + if s.options.TLSEnabled && s.options.TLSRedirect && currentScheme == "http" { desiredScheme = "https" } From 0b33d56f9685c4f0ed23d637013707be3909492a Mon Sep 17 00:00:00 2001 From: Didier Lafforgue Date: Thu, 5 Mar 2026 00:25:20 +0100 Subject: [PATCH 25/25] Update internal/server/tls_on_demand.go fix: avoid malformed external URL (TLSOnDemandURL) Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- internal/server/tls_on_demand.go | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/internal/server/tls_on_demand.go b/internal/server/tls_on_demand.go index dff70562..5bb6f585 100644 --- a/internal/server/tls_on_demand.go +++ b/internal/server/tls_on_demand.go @@ -36,13 +36,23 @@ func (c *TLSOnDemandChecker) HostPolicy() (autocert.HostPolicy, error) { } // Otherwise, treat as external URL - _, err := url.ParseRequestURI(c.options.TLSOnDemandURL) - + u, err := url.ParseRequestURI(c.options.TLSOnDemandURL) if err != nil { slog.Error("Unable to parse the tls_on_demand_url URL", "error", err, "url", c.options.TLSOnDemandURL) return nil, err } + if u.Scheme != "http" && u.Scheme != "https" { + err = fmt.Errorf("unsupported scheme %q in tls_on_demand_url", u.Scheme) + slog.Error("Invalid scheme in tls_on_demand_url", "error", err, "url", c.options.TLSOnDemandURL) + return nil, err + } + + if u.Host == "" { + err = fmt.Errorf("missing host in tls_on_demand_url") + slog.Error("Missing host in tls_on_demand_url", "error", err, "url", c.options.TLSOnDemandURL) + return nil, err + } return c.ExternalHostPolicy(), nil }