diff --git a/README.md b/README.md index 03d34de..9c619d9 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,6 +129,33 @@ 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 +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. +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). +- 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 @@ -142,6 +165,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 @@ -163,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: @@ -174,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 01010c8..bb82a36 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)") @@ -101,13 +102,18 @@ func (c *deployCommand) preRun(cmd *cobra.Command, args []string) error { } if c.args.ServiceOptions.TLSEnabled { - 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") } + + 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") + } } // Validate canonical host is present in hosts when both are specified diff --git a/internal/cmd/deploy_test.go b/internal/cmd/deploy_test.go index ded8a2a..a61cf54 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) + }) +} diff --git a/internal/server/router_test.go b/internal/server/router_test.go index c1252b3..599c814 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,187 @@ 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")) +} + +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) { + 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/service.go b/internal/server/service.go index 5695313..8ff0279 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 @@ -83,6 +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"` TLSRedirect bool `json:"tls_redirect"` CanonicalHost string `json:"canonical_host"` ACMEDirectory string `json:"acme_directory"` @@ -330,18 +333,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 @@ -392,10 +403,15 @@ func (s *Service) createCertManager(options ServiceOptions) (CertManager, error) } } + hostPolicy, err := NewTLSOnDemandChecker(s).HostPolicy() + 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 } @@ -502,6 +518,12 @@ func (s *Service) redirectURLIfNeeded(r *http.Request) string { } desiredScheme := currentScheme + isTLSOnDemandCheck, _ := r.Context().Value(contextKeyTLSOnDemandCheck).(bool) + 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" } diff --git a/internal/server/tls_on_demand.go b/internal/server/tls_on_demand.go new file mode 100644 index 0000000..5bb6f58 --- /dev/null +++ b/internal/server/tls_on_demand.go @@ -0,0 +1,134 @@ +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 + 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 +} + +func (c *TLSOnDemandChecker) LocalHostPolicy() autocert.HostPolicy { + return func(ctx context.Context, host string) error { + 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 + } + + // 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 + // 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} + requestURL, err := c.buildURLOrPath(host) + if err != nil { + return err + } + resp, err := client.Get(requestURL) + 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, 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 { + 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 0000000..e0a1f67 --- /dev/null +++ b/internal/server/tls_on_demand_test.go @@ -0,0 +1,226 @@ +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_WithTLSRedirect(t *testing.T) { + var forwardedProto string + + 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" { + forwardedProto = r.Header.Get("X-Forwarded-Proto") + 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) + assert.Equal(t, "http", forwardedProto) +} + +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, err := checker.HostPolicy() + assert.NoError(t, err) + + 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, err := checker.HostPolicy() + assert.NoError(t, err) + + 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, 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, 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) +}