From 8174c3740bca0b0e1d492db52737e67f8683dc80 Mon Sep 17 00:00:00 2001 From: Jan Braun Date: Thu, 23 Apr 2026 13:30:41 +0200 Subject: [PATCH 01/10] make socket path configurable Read the envvar KAMAL_PROXY_SOCKET to determine the control socket path. This makes it possible to control a running kamal-proxy instance from a different user, by making the socket accessible via file system permissions. If KAMAL_PROXY_SOCKET is unset, retain the previous ${XDG_RUNTIME_DIR:/tmp}/kamal-proxy.sock behaviour. --- internal/server/config.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/server/config.go b/internal/server/config.go index 0314825..66f632a 100644 --- a/internal/server/config.go +++ b/internal/server/config.go @@ -23,7 +23,7 @@ type Config struct { } func (c Config) SocketPath() string { - return path.Join(c.runtimeDirectory(), "kamal-proxy.sock") + return cmp.Or(os.Getenv("KAMAL_PROXY_SOCKET"), path.Join(c.runtimeDirectory(), "kamal-proxy.sock")) } func (c Config) StatePath() string { From ba55aa4d47b2336410fcb985f9bd2d5500e9a20c Mon Sep 17 00:00:00 2001 From: Lewis Buckley Date: Tue, 5 May 2026 16:51:51 +0100 Subject: [PATCH 02/10] Add --exclude-metrics-path to filter request paths from metrics MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Lets services opt specific paths (typically health checks from upstream load balancers or uptime monitors) out of the Prometheus request and in-flight metrics. Matches are exact, can be repeated, and only suppress metrics — request logs are still emitted. Co-Authored-By: Claude Opus 4.7 (1M context) --- README.md | 17 +++++++++++++ internal/cmd/deploy.go | 1 + internal/server/logging_middleware.go | 5 +++- internal/server/service.go | 11 +++++++++ internal/server/service_test.go | 35 +++++++++++++++++++++++++++ 5 files changed, 68 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 03d34de..4371c2f 100644 --- a/README.md +++ b/README.md @@ -119,6 +119,23 @@ 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 +### Excluding paths from metrics + +When metrics are enabled (with `--metrics-port`), every request handled by +the proxy is recorded in the Prometheus output. This includes traffic from +upstream load balancers or uptime monitors hitting health endpoints, which +can be noisy. + +To exclude one or more paths from the metrics for a service, use +`--exclude-metrics-path` when deploying. The flag may be repeated, and matches +are exact: + + kamal-proxy deploy service1 --target web-1:3000 --exclude-metrics-path /up --exclude-metrics-path /healthz + +Excluded requests are still logged; only the Prometheus counters and +in-flight gauge are skipped. + + ### Automatic TLS Kamal Proxy can automatically obtain and renew TLS certificates for your diff --git a/internal/cmd/deploy.go b/internal/cmd/deploy.go index 01010c8..5f205ad 100644 --- a/internal/cmd/deploy.go +++ b/internal/cmd/deploy.go @@ -63,6 +63,7 @@ func newDeployCommand() *deployCommand { deployCommand.cmd.Flags().StringSliceVar(&deployCommand.args.TargetOptions.LogRequestHeaders, "log-request-header", nil, "Additional request header to log (may be specified multiple times)") deployCommand.cmd.Flags().StringSliceVar(&deployCommand.args.TargetOptions.LogResponseHeaders, "log-response-header", nil, "Additional response header to log (may be specified multiple times)") + deployCommand.cmd.Flags().StringSliceVar(&deployCommand.args.ServiceOptions.ExcludeMetricsPaths, "exclude-metrics-path", nil, "Request path(s) to exclude from Prometheus metrics (may be specified multiple times)") deployCommand.cmd.Flags().BoolVar(&deployCommand.args.TargetOptions.ForwardHeaders, "forward-headers", false, "Forward X-Forwarded headers to target (default false if TLS enabled; otherwise true)") deployCommand.cmd.Flags().BoolVar(&deployCommand.args.TargetOptions.ScopeCookiePaths, "scope-cookie-paths", false, "Scope cookie paths to match path prefix") diff --git a/internal/server/logging_middleware.go b/internal/server/logging_middleware.go index 53c5734..c99840e 100644 --- a/internal/server/logging_middleware.go +++ b/internal/server/logging_middleware.go @@ -22,6 +22,7 @@ type loggingRequestContext struct { Target string RequestHeaders []string ResponseHeaders []string + ExcludeMetrics bool } type LoggingMiddleware struct { @@ -105,7 +106,9 @@ func (h *LoggingMiddleware) ServeHTTP(w http.ResponseWriter, r *http.Request) { attrs = append(attrs, h.retrieveCustomHeaders(loggingRequestContext.ResponseHeaders, writer.Header(), "resp")...) h.logger.LogAttrs(context.Background(), slog.LevelInfo, "Request", attrs...) - metrics.Tracker.TrackRequest(loggingRequestContext.Service, r.Method, writer.statusCode, elapsed) + if !loggingRequestContext.ExcludeMetrics { + metrics.Tracker.TrackRequest(loggingRequestContext.Service, r.Method, writer.statusCode, elapsed) + } }() h.next.ServeHTTP(writer, r) diff --git a/internal/server/service.go b/internal/server/service.go index 5695313..d39cdf4 100644 --- a/internal/server/service.go +++ b/internal/server/service.go @@ -91,6 +91,11 @@ type ServiceOptions struct { StripPrefix bool `json:"strip_prefix"` WriterAffinityTimeout time.Duration `json:"writer_affinity_timeout"` ReadTargetsAcceptWebsockets bool `json:"read_targets_accept_websockets"` + ExcludeMetricsPaths []string `json:"exclude_metrics_paths"` +} + +func (so *ServiceOptions) IsMetricsExcluded(r *http.Request) bool { + return slices.Contains(so.ExcludeMetricsPaths, r.URL.Path) } func (so *ServiceOptions) Normalize() { @@ -202,6 +207,12 @@ func (s *Service) StopRollout() error { } func (s *Service) ServeHTTP(w http.ResponseWriter, r *http.Request) { + if s.options.IsMetricsExcluded(r) { + LoggingRequestContext(r).ExcludeMetrics = true + s.middleware.ServeHTTP(w, r) + return + } + metrics.Tracker.AddInflightRequest(s.name) defer metrics.Tracker.SubtractInflightRequest(s.name) diff --git a/internal/server/service_test.go b/internal/server/service_test.go index 6dd39d4..18e2943 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" @@ -130,6 +131,40 @@ func TestService_ReturnSuccessfulHealthCheckWhilePausedOrStopped(t *testing.T) { assert.Equal(t, http.StatusOK, checkRequest("/other")) } +func TestServiceOptions_IsMetricsExcluded(t *testing.T) { + options := ServiceOptions{ExcludeMetricsPaths: []string{"/up", "/healthz"}} + + assert.True(t, options.IsMetricsExcluded(httptest.NewRequest(http.MethodGet, "/up", nil))) + assert.True(t, options.IsMetricsExcluded(httptest.NewRequest(http.MethodPost, "/healthz", nil))) + assert.False(t, options.IsMetricsExcluded(httptest.NewRequest(http.MethodGet, "/api/users", nil))) + assert.False(t, options.IsMetricsExcluded(httptest.NewRequest(http.MethodGet, "/up/nested", nil))) + + empty := ServiceOptions{} + assert.False(t, empty.IsMetricsExcluded(httptest.NewRequest(http.MethodGet, "/up", nil))) +} + +func TestService_ExcludeMetricsPathsMarksRequestContext(t *testing.T) { + options := defaultServiceOptions + options.ExcludeMetricsPaths = []string{"/up", "/metrics"} + + service := testCreateService(t, options, defaultTargetOptions) + + checkExcluded := func(path string) bool { + req := httptest.NewRequest(http.MethodGet, path, nil) + ctx := &loggingRequestContext{} + req = req.WithContext(context.WithValue(req.Context(), contextKeyRequestContext, ctx)) + + w := httptest.NewRecorder() + service.ServeHTTP(w, req) + require.Equal(t, http.StatusOK, w.Result().StatusCode) + return ctx.ExcludeMetrics + } + + assert.True(t, checkExcluded("/up")) + assert.True(t, checkExcluded("/metrics")) + assert.False(t, checkExcluded("/other")) +} + func TestService_MarshallingState(t *testing.T) { targetOptions := TargetOptions{ HealthCheckConfig: HealthCheckConfig{Path: "/health", Interval: time.Second, Timeout: 2 * time.Second}, From 50478dc001f46581904e9d3d06eb7e67bfa36458 Mon Sep 17 00:00:00 2001 From: Lewis Buckley Date: Tue, 5 May 2026 16:53:54 +0100 Subject: [PATCH 03/10] Reframe metrics-exclusion rationale around accuracy and volume MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The motivation for `--exclude-metrics-path` is that high-volume healthcheck traffic distorts aggregate metrics (request rate, latency percentiles, error rates) and inflates the metrics pipeline — not that the output is "noisy". Co-Authored-By: Claude Opus 4.7 (1M context) --- README.md | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 4371c2f..f6c4f06 100644 --- a/README.md +++ b/README.md @@ -122,13 +122,15 @@ the original path (including the prefix), specify `--strip-path-prefix=false`: ### Excluding paths from metrics When metrics are enabled (with `--metrics-port`), every request handled by -the proxy is recorded in the Prometheus output. This includes traffic from -upstream load balancers or uptime monitors hitting health endpoints, which -can be noisy. +the proxy is recorded in the Prometheus output. High-volume traffic from +upstream load balancers or uptime monitors hitting health endpoints can +both inflate the metrics pipeline and dominate aggregate measures like +request rate, latency percentiles, and error rates, making the resulting +metrics a poor reflection of real user traffic. To exclude one or more paths from the metrics for a service, use -`--exclude-metrics-path` when deploying. The flag may be repeated, and matches -are exact: +`--exclude-metrics-path` when deploying. The flag may be repeated, and +matches are exact: kamal-proxy deploy service1 --target web-1:3000 --exclude-metrics-path /up --exclude-metrics-path /healthz From 26b7e7d8fbb4b91388e34c03b28cd4a992c2743e Mon Sep 17 00:00:00 2001 From: Kevin McConnell Date: Wed, 15 Jul 2026 17:31:30 +0100 Subject: [PATCH 04/10] Tweak naming & conditional --- internal/server/service.go | 12 +++++------- internal/server/service_test.go | 18 ++++++++++-------- 2 files changed, 15 insertions(+), 15 deletions(-) diff --git a/internal/server/service.go b/internal/server/service.go index d39cdf4..5abb036 100644 --- a/internal/server/service.go +++ b/internal/server/service.go @@ -94,7 +94,7 @@ type ServiceOptions struct { ExcludeMetricsPaths []string `json:"exclude_metrics_paths"` } -func (so *ServiceOptions) IsMetricsExcluded(r *http.Request) bool { +func (so *ServiceOptions) ShouldExcludeMetrics(r *http.Request) bool { return slices.Contains(so.ExcludeMetricsPaths, r.URL.Path) } @@ -207,15 +207,13 @@ func (s *Service) StopRollout() error { } func (s *Service) ServeHTTP(w http.ResponseWriter, r *http.Request) { - if s.options.IsMetricsExcluded(r) { + if s.options.ShouldExcludeMetrics(r) { LoggingRequestContext(r).ExcludeMetrics = true - s.middleware.ServeHTTP(w, r) - return + } else { + metrics.Tracker.AddInflightRequest(s.name) + defer metrics.Tracker.SubtractInflightRequest(s.name) } - metrics.Tracker.AddInflightRequest(s.name) - defer metrics.Tracker.SubtractInflightRequest(s.name) - s.middleware.ServeHTTP(w, r) } diff --git a/internal/server/service_test.go b/internal/server/service_test.go index 18e2943..9e48ac2 100644 --- a/internal/server/service_test.go +++ b/internal/server/service_test.go @@ -47,7 +47,8 @@ func TestService_RedirectToHTTPSWhenTLSRequired(t *testing.T) { func TestService_DontRedirectToHTTPSWhenTLSAndPlainHTTPAllowed(t *testing.T) { var forwardedProto string - service := testCreateServiceWithHandler(t, ServiceOptions{Hosts: []string{"example.com"}, TLSEnabled: true, TLSRedirect: false}, defaultTargetOptions, + service := testCreateServiceWithHandler( + t, ServiceOptions{Hosts: []string{"example.com"}, TLSEnabled: true, TLSRedirect: false}, defaultTargetOptions, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { forwardedProto = r.Header.Get("X-Forwarded-Proto") }), @@ -131,16 +132,16 @@ func TestService_ReturnSuccessfulHealthCheckWhilePausedOrStopped(t *testing.T) { assert.Equal(t, http.StatusOK, checkRequest("/other")) } -func TestServiceOptions_IsMetricsExcluded(t *testing.T) { +func TestServiceOptions_ShouldExcludeMetrics(t *testing.T) { options := ServiceOptions{ExcludeMetricsPaths: []string{"/up", "/healthz"}} - assert.True(t, options.IsMetricsExcluded(httptest.NewRequest(http.MethodGet, "/up", nil))) - assert.True(t, options.IsMetricsExcluded(httptest.NewRequest(http.MethodPost, "/healthz", nil))) - assert.False(t, options.IsMetricsExcluded(httptest.NewRequest(http.MethodGet, "/api/users", nil))) - assert.False(t, options.IsMetricsExcluded(httptest.NewRequest(http.MethodGet, "/up/nested", nil))) + assert.True(t, options.ShouldExcludeMetrics(httptest.NewRequest(http.MethodGet, "/up", nil))) + assert.True(t, options.ShouldExcludeMetrics(httptest.NewRequest(http.MethodPost, "/healthz", nil))) + assert.False(t, options.ShouldExcludeMetrics(httptest.NewRequest(http.MethodGet, "/api/users", nil))) + assert.False(t, options.ShouldExcludeMetrics(httptest.NewRequest(http.MethodGet, "/up/nested", nil))) empty := ServiceOptions{} - assert.False(t, empty.IsMetricsExcluded(httptest.NewRequest(http.MethodGet, "/up", nil))) + assert.False(t, empty.ShouldExcludeMetrics(httptest.NewRequest(http.MethodGet, "/up", nil))) } func TestService_ExcludeMetricsPathsMarksRequestContext(t *testing.T) { @@ -252,7 +253,8 @@ func TestService_UnmarshallingStateFromLegacyFormat(t *testing.T) { } func testCreateService(t *testing.T, options ServiceOptions, targetOptions TargetOptions) *Service { - return testCreateServiceWithHandler(t, options, targetOptions, + return testCreateServiceWithHandler( + t, options, targetOptions, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {}), ) } From cb4fd870f49163ac7fbb78113044fc8883f913c7 Mon Sep 17 00:00:00 2001 From: Kevin McConnell Date: Thu, 16 Jul 2026 08:12:31 +0100 Subject: [PATCH 05/10] Respect path prefix when matching paths There are a couple places where behviour is dependent on whether a request path matches a configured value: identifying incoming health check requests; and identifying paths for which metrics should not be tracked. In both of these cases, if an app is deployed under a path prefix (and strips the prefix) then the expected behaviour is that we match that path against what the upstream sees. So we need to factor in any prefix-stripping before paths are matched. --- README.md | 4 ++++ internal/server/router.go | 11 +++++++++++ internal/server/router_test.go | 32 ++++++++++++++++++++++++++++++++ internal/server/service.go | 2 +- internal/server/service_test.go | 5 +++++ internal/server/target.go | 11 ++++------- internal/server/target_test.go | 5 +++++ internal/server/testing.go | 6 ++++++ 8 files changed, 68 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index f6c4f06..42059ba 100644 --- a/README.md +++ b/README.md @@ -137,6 +137,10 @@ matches are exact: Excluded requests are still logged; only the Prometheus counters and in-flight gauge are skipped. +Paths are specified as the upstream receives them. Services deployed using +stripped path prefixes should specify their excluded paths in the un-prefixed +form. + ### Automatic TLS diff --git a/internal/server/router.go b/internal/server/router.go index 9f42c2a..3b891b6 100644 --- a/internal/server/router.go +++ b/internal/server/router.go @@ -35,6 +35,17 @@ func RoutingContext(r *http.Request) *routingContext { return rc } +func RoutedTargetPath(r *http.Request) string { + path := r.URL.Path + if rc := RoutingContext(r); rc != nil { + path = strings.TrimPrefix(path, rc.MatchedPrefix) + if path == "" { + path = rootPath + } + } + return path +} + type Router struct { statePath string services *ServiceMap diff --git a/internal/server/router_test.go b/internal/server/router_test.go index df7c484..7a1a55c 100644 --- a/internal/server/router_test.go +++ b/internal/server/router_test.go @@ -563,6 +563,38 @@ func TestRouter_PathBasedRoutingStripPrefix(t *testing.T) { assert.Equal(t, "/app", body) } +func TestRouter_HealthCheckWhilePausedWithPathPrefix(t *testing.T) { + router := testRouter(t) + _, backend := testBackend(t, "ok", http.StatusOK) + + serviceOptions := defaultServiceOptions + serviceOptions.PathPrefixes = []string{"/api"} + serviceOptions.StripPrefix = true + require.NoError(t, router.DeployService("service1", []string{backend}, defaultEmptyReaders, serviceOptions, defaultTargetOptions, defaultDeploymentOptions)) + + serviceOptions = defaultServiceOptions + serviceOptions.PathPrefixes = []string{"/admin"} + serviceOptions.StripPrefix = false + targetOptions := defaultTargetOptions + targetOptions.HealthCheckConfig.Path = "/admin/up" + require.NoError(t, router.DeployService("service2", []string{backend}, defaultEmptyReaders, serviceOptions, targetOptions, defaultDeploymentOptions)) + + require.NoError(t, router.PauseService("service1", time.Second, time.Millisecond*10)) + require.NoError(t, router.PauseService("service2", time.Second, time.Millisecond*10)) + + // Health checks succeed while paused, with the health check path matched + // against the target's view of the path + statusCode, _ := sendGETRequest(router, "http://example.com/api/up") + assert.Equal(t, http.StatusOK, statusCode) + + statusCode, _ = sendGETRequest(router, "http://example.com/admin/up") + assert.Equal(t, http.StatusOK, statusCode) + + // Other requests are still paused + statusCode, _ = sendGETRequest(router, "http://example.com/api/other") + assert.Equal(t, http.StatusGatewayTimeout, statusCode) +} + func TestRouter_PathBasedRoutingWithHosts(t *testing.T) { router := testRouter(t) _, first := testBackend(t, "first", http.StatusOK) diff --git a/internal/server/service.go b/internal/server/service.go index bb20edc..1407f4c 100644 --- a/internal/server/service.go +++ b/internal/server/service.go @@ -97,7 +97,7 @@ type ServiceOptions struct { } func (so *ServiceOptions) ShouldExcludeMetrics(r *http.Request) bool { - return slices.Contains(so.ExcludeMetricsPaths, r.URL.Path) + return slices.Contains(so.ExcludeMetricsPaths, RoutedTargetPath(r)) } func (so *ServiceOptions) Normalize() { diff --git a/internal/server/service_test.go b/internal/server/service_test.go index 0523c18..8893a70 100644 --- a/internal/server/service_test.go +++ b/internal/server/service_test.go @@ -175,6 +175,11 @@ func TestServiceOptions_ShouldExcludeMetrics(t *testing.T) { assert.False(t, options.ShouldExcludeMetrics(httptest.NewRequest(http.MethodGet, "/api/users", nil))) assert.False(t, options.ShouldExcludeMetrics(httptest.NewRequest(http.MethodGet, "/up/nested", nil))) + // When a path prefix is due to be stripped, match against the target's view of the path + assert.True(t, options.ShouldExcludeMetrics(testRequestWithMatchedPrefix(httptest.NewRequest(http.MethodGet, "/api/up", nil), "/api"))) + assert.False(t, options.ShouldExcludeMetrics(testRequestWithMatchedPrefix(httptest.NewRequest(http.MethodGet, "/api/users", nil), "/api"))) + assert.False(t, options.ShouldExcludeMetrics(httptest.NewRequest(http.MethodGet, "/api/up", nil))) + empty := ServiceOptions{} assert.False(t, empty.ShouldExcludeMetrics(httptest.NewRequest(http.MethodGet, "/up", nil))) } diff --git a/internal/server/target.go b/internal/server/target.go index 09084a0..b1bd627 100644 --- a/internal/server/target.go +++ b/internal/server/target.go @@ -12,7 +12,6 @@ import ( "net/http/httputil" "net/url" "regexp" - "strings" "sync" "time" ) @@ -77,7 +76,7 @@ type TargetOptions struct { } func (to *TargetOptions) IsHealthCheckRequest(r *http.Request) bool { - return (r.Method == http.MethodGet || r.Method == http.MethodHead) && r.URL.Path == to.HealthCheckConfig.Path + return (r.Method == http.MethodGet || r.Method == http.MethodHead) && RoutedTargetPath(r) == to.HealthCheckConfig.Path } func (to *TargetOptions) canonicalizeLogHeaders() { @@ -221,7 +220,8 @@ func (t *Target) BeginHealthChecks(stateConsumer TargetStateConsumer) { t.withInflightLock(func() { healthCheckURL := t.buildHealthCheckURL() - t.healthcheck = NewHealthCheck(t, + t.healthcheck = NewHealthCheck( + t, healthCheckURL, t.options.HealthCheckConfig.Interval, t.options.HealthCheckConfig.Timeout, @@ -310,10 +310,7 @@ func (t *Target) rewrite(req *httputil.ProxyRequest) { req.SetURL(t.targetURL) req.Out.Host = req.In.Host - routingContext := RoutingContext(req.In) - if routingContext != nil { - req.Out.URL.Path = strings.TrimPrefix(req.Out.URL.Path, routingContext.MatchedPrefix) - } + req.Out.URL.Path = RoutedTargetPath(req.In) // Ensure query params are preserved exactly, including those we could not // parse. diff --git a/internal/server/target_test.go b/internal/server/target_test.go index bfcaa4b..dbcc99d 100644 --- a/internal/server/target_test.go +++ b/internal/server/target_test.go @@ -285,6 +285,11 @@ func TestTarget_IsHealthCheckRequest(t *testing.T) { assert.False(t, target.options.IsHealthCheckRequest(httptest.NewRequest(http.MethodGet, "/up/other", nil))) assert.False(t, target.options.IsHealthCheckRequest(httptest.NewRequest(http.MethodGet, "/health", nil))) + + // When a path prefix is due to be stripped, match against the target's view of the path + assert.True(t, target.options.IsHealthCheckRequest(testRequestWithMatchedPrefix(httptest.NewRequest(http.MethodGet, "/api/up", nil), "/api"))) + assert.False(t, target.options.IsHealthCheckRequest(testRequestWithMatchedPrefix(httptest.NewRequest(http.MethodGet, "/api/health", nil), "/api"))) + assert.False(t, target.options.IsHealthCheckRequest(httptest.NewRequest(http.MethodGet, "/api/up", nil))) } func TestTarget_AddedTargetBecomesHealthy(t *testing.T) { diff --git a/internal/server/testing.go b/internal/server/testing.go index 15b1147..61576bf 100644 --- a/internal/server/testing.go +++ b/internal/server/testing.go @@ -1,6 +1,7 @@ package server import ( + "context" "net/http" "net/http/httptest" "net/url" @@ -48,6 +49,11 @@ func testTargetWithOptions(t testing.TB, targetOptions TargetOptions, handler ht return target } +func testRequestWithMatchedPrefix(req *http.Request, prefix string) *http.Request { + ctx := context.WithValue(req.Context(), contextKeyRoutingContext, &routingContext{MatchedPrefix: prefix}) + return req.WithContext(ctx) +} + func testBackend(t testing.TB, body string, statusCode int) (*httptest.Server, string) { t.Helper() From b44a731f7f1af4582d1abbe14ef4c22d5bcca642 Mon Sep 17 00:00:00 2001 From: Kevin McConnell Date: Thu, 16 Jul 2026 10:36:02 +0100 Subject: [PATCH 06/10] Allow specifying header source for client IP Typically a downstreeam proxy will pass the original client IP via `X-Forwarded-For`, which we already handle. However some proxies use a different header. For example, Cloudflare typically sets it in `True-Client-IP`. To support this, add a new `--client-ip-header` deploy flag which specifies the name of the header to use. When this is set, we copy the content of that header into `X-Forwarded-For` before logging and proxying, as if `X-Forwarded-For` had been set that way in the request. --- internal/cmd/deploy.go | 1 + internal/server/client_ip_middleware.go | 24 ++++++++++++ internal/server/logging_middleware_test.go | 23 +++++++++++ internal/server/service.go | 5 +++ internal/server/service_test.go | 44 ++++++++++++++++++++++ 5 files changed, 97 insertions(+) create mode 100644 internal/server/client_ip_middleware.go diff --git a/internal/cmd/deploy.go b/internal/cmd/deploy.go index 93e02cf..610a7a5 100644 --- a/internal/cmd/deploy.go +++ b/internal/cmd/deploy.go @@ -64,6 +64,7 @@ func newDeployCommand() *deployCommand { deployCommand.cmd.Flags().StringSliceVar(&deployCommand.args.TargetOptions.LogResponseHeaders, "log-response-header", nil, "Additional response header to log (may be specified multiple times)") deployCommand.cmd.Flags().StringSliceVar(&deployCommand.args.ServiceOptions.ExcludeMetricsPaths, "exclude-metrics-path", nil, "Request path(s) to exclude from Prometheus metrics (may be specified multiple times)") deployCommand.cmd.Flags().BoolVar(&deployCommand.args.TargetOptions.ForwardHeaders, "forward-headers", false, "Forward X-Forwarded headers to target (default false if TLS enabled; otherwise true)") + deployCommand.cmd.Flags().StringVar(&deployCommand.args.ServiceOptions.ClientIPHeader, "client-ip-header", "", "Request header containing the original client IP; used to populate X-Forwarded-For when present") deployCommand.cmd.Flags().BoolVar(&deployCommand.args.TargetOptions.ScopeCookiePaths, "scope-cookie-paths", false, "Scope cookie paths to match path prefix") deployCommand.cmd.MarkFlagRequired("target") diff --git a/internal/server/client_ip_middleware.go b/internal/server/client_ip_middleware.go new file mode 100644 index 0000000..73248ea --- /dev/null +++ b/internal/server/client_ip_middleware.go @@ -0,0 +1,24 @@ +package server + +import ( + "net/http" +) + +type ClientIPMiddleware struct { + headerName string + next http.Handler +} + +func WithClientIPMiddleware(headerName string, next http.Handler) http.Handler { + return &ClientIPMiddleware{ + headerName: http.CanonicalHeaderKey(headerName), + next: next, + } +} + +func (h *ClientIPMiddleware) ServeHTTP(w http.ResponseWriter, r *http.Request) { + if clientIP := r.Header.Get(h.headerName); clientIP != "" { + r.Header.Set("X-Forwarded-For", clientIP) + } + h.next.ServeHTTP(w, r) +} diff --git a/internal/server/logging_middleware_test.go b/internal/server/logging_middleware_test.go index 46eae0d..967a00f 100644 --- a/internal/server/logging_middleware_test.go +++ b/internal/server/logging_middleware_test.go @@ -98,3 +98,26 @@ func TestMiddleware_LoggingMiddleware(t *testing.T) { assert.Equal(t, "HTTP/1.1", logline.Proto) assert.Equal(t, "http", logline.Scheme) } + +func TestMiddleware_LoggingMiddlewareLogsClientIPHeaderAsRemoteAddr(t *testing.T) { + out := &strings.Builder{} + logger := slog.New(slog.NewJSONHandler(out, nil)) + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {}) + + middleware := WithLoggingMiddleware(logger, 80, 443, WithClientIPMiddleware("True-Client-IP", handler)) + + req := httptest.NewRequest("GET", "http://app.example.com/", nil) + req.Header.Set("X-Forwarded-For", "10.10.10.10") + req.Header.Set("True-Client-IP", "203.0.113.7") + + middleware.ServeHTTP(httptest.NewRecorder(), req) + + logline := struct { + RemoteAddr string `json:"remote_addr"` + }{} + + err := json.NewDecoder(strings.NewReader(out.String())).Decode(&logline) + require.NoError(t, err) + + assert.Equal(t, "203.0.113.7", logline.RemoteAddr) +} diff --git a/internal/server/service.go b/internal/server/service.go index 1407f4c..c068fe2 100644 --- a/internal/server/service.go +++ b/internal/server/service.go @@ -94,6 +94,7 @@ type ServiceOptions struct { WriterAffinityTimeout time.Duration `json:"writer_affinity_timeout"` ReadTargetsAcceptWebsockets bool `json:"read_targets_accept_websockets"` ExcludeMetricsPaths []string `json:"exclude_metrics_paths"` + ClientIPHeader string `json:"client_ip_header"` } func (so *ServiceOptions) ShouldExcludeMetrics(r *http.Request) bool { @@ -456,6 +457,10 @@ func (s *Service) createMiddleware(options ServiceOptions, certManager CertManag handler = certManager.HTTPHandler(handler) } + if options.ClientIPHeader != "" { + handler = WithClientIPMiddleware(options.ClientIPHeader, handler) + } + return handler, nil } diff --git a/internal/server/service_test.go b/internal/server/service_test.go index 8893a70..b5e7576 100644 --- a/internal/server/service_test.go +++ b/internal/server/service_test.go @@ -4,6 +4,7 @@ import ( "bytes" "context" "encoding/json" + "net" "net/http" "net/http/httptest" "net/url" @@ -25,6 +26,49 @@ func TestService_ServeRequest(t *testing.T) { require.Equal(t, http.StatusOK, w.Result().StatusCode) } +func TestService_ClientIPHeaderRewritesXForwardedFor(t *testing.T) { + var xForwardedFor, trueClientIP string + + serviceOptions := defaultServiceOptions + serviceOptions.ClientIPHeader = "True-Client-IP" + + targetOptions := defaultTargetOptions + targetOptions.ForwardHeaders = true + + service := testCreateServiceWithHandler(t, serviceOptions, targetOptions, + http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != defaultHealthCheckConfig.Path { + xForwardedFor = r.Header.Get("X-Forwarded-For") + trueClientIP = r.Header.Get("True-Client-IP") + } + })) + + req := httptest.NewRequest(http.MethodGet, "http://example.com/", nil) + req.Header.Set("True-Client-IP", "203.0.113.9") + req.Header.Set("X-Forwarded-For", "6.6.6.6") + + clientIP, _, err := net.SplitHostPort(req.RemoteAddr) + require.NoError(t, err) + + w := httptest.NewRecorder() + service.ServeHTTP(w, req) + + require.Equal(t, http.StatusOK, w.Result().StatusCode) + require.Equal(t, "203.0.113.9, "+clientIP, xForwardedFor) + require.Equal(t, "203.0.113.9", trueClientIP) + + // Without the trusted header, the client-supplied X-Forwarded-For is + // forwarded unmodified, as usual. + req = httptest.NewRequest(http.MethodGet, "http://example.com/", nil) + req.Header.Set("X-Forwarded-For", "6.6.6.6") + + w = httptest.NewRecorder() + service.ServeHTTP(w, req) + + require.Equal(t, http.StatusOK, w.Result().StatusCode) + require.Equal(t, "6.6.6.6, "+clientIP, xForwardedFor) +} + func TestService_RedirectToHTTPSWhenTLSRequired(t *testing.T) { service := testCreateService(t, ServiceOptions{Hosts: []string{"example.com"}, TLSEnabled: true, TLSRedirect: true}, defaultTargetOptions) From 1ff4fe74ea897972d7b57fd2068e4fa420ed4152 Mon Sep 17 00:00:00 2001 From: Kevin McConnell Date: Thu, 16 Jul 2026 09:28:53 +0100 Subject: [PATCH 07/10] On-demand TLS Allows applications to provision TLS certificates for multiple hosts on-demand, by way of an application endpoint that gates issuance on a host-by-host basis. To use, specify `--tls-on-demand-url` rather than `--host`. The URL can be directed to an external service, or plain path routed to the service. Co-authored-by: Didier Lafforgue --- README.md | 27 +++ internal/cmd/deploy.go | 1 + internal/cmd/deploy_test.go | 31 ++++ internal/server/router_test.go | 38 ++++ internal/server/service.go | 99 +++++++--- internal/server/service_map.go | 2 +- internal/server/service_map_test.go | 13 ++ internal/server/service_test.go | 11 ++ internal/server/tls_on_demand.go | 222 +++++++++++++++++++++++ internal/server/tls_on_demand_test.go | 252 ++++++++++++++++++++++++++ 10 files changed, 674 insertions(+), 22 deletions(-) create mode 100644 internal/server/tls_on_demand.go create mode 100644 internal/server/tls_on_demand_test.go diff --git a/README.md b/README.md index 42059ba..2bdbb09 100644 --- a/README.md +++ b/README.md @@ -157,6 +157,33 @@ 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 + +Instead of specifying a static list of hosts, Kamal Proxy can also obtain TLS +certificates dynamically, for any host approved by an HTTP endpoint of your +choice. This is useful when the full set of hosts is not known at deploy time, +such as when serving customer domains. + +To enable this, specify `--tls-on-demand-url` (instead of `--host`) when +deploying: + + kamal-proxy deploy service1 --target web-1:3000 --tls --tls-on-demand-url="http://localhost:4567/check" + +The URL may be: + +- An external URL (like `http://localhost:4567/check`), which Kamal Proxy will + call directly, or +- A path (like `/check`), which Kamal Proxy will route through the service to + your application, letting the application decide which hosts to allow. + +Before issuing a certificate for a host, Kamal Proxy will send a `GET` request +to the endpoint, with the hostname in a `host` query parameter (for example, +`?host=app1.example.com`) and matching `Host` header. A `200` response allows +certificate issuance; any other response denies it, and the status code and up +to 256 bytes of the response body are logged to help with debugging. Checks +time out after 2 seconds, denying issuance for that attempt. + + ### 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 610a7a5..82af0e1 100644 --- a/internal/cmd/deploy.go +++ b/internal/cmd/deploy.go @@ -33,6 +33,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/cmd/deploy_test.go b/internal/cmd/deploy_test.go index fee09b8..fccadc4 100644 --- a/internal/cmd/deploy_test.go +++ b/internal/cmd/deploy_test.go @@ -36,6 +36,37 @@ func TestDeployCommand_TLSRequiresHost(t *testing.T) { assertTLSHostValidation(t, []string{"example.com", "*.example.com"}, true) } +func TestDeployCommand_TLSOnDemandURL(t *testing.T) { + t.Run("host is not required when a TLS on-demand URL is set", func(t *testing.T) { + cmd := newDeployCommand() + cmd.args.ServiceOptions.TLSEnabled = true + cmd.args.ServiceOptions.TLSOnDemandURL = "https://example.com/allow-host" + + require.NoError(t, cmd.preRun(cmd.cmd, []string{"test-service"})) + }) + + t.Run("hosts cannot be combined with a TLS on-demand URL", func(t *testing.T) { + cmd := newDeployCommand() + cmd.args.ServiceOptions.TLSEnabled = true + cmd.args.ServiceOptions.TLSOnDemandURL = "https://example.com/allow-host" + cmd.args.ServiceOptions.Hosts = []string{"example.com"} + + err := cmd.preRun(cmd.cmd, []string{"test-service"}) + require.ErrorContains(t, err, "cannot set hosts when using a TLS on-demand URL") + require.ErrorIs(t, err, server.ErrServiceOptionsInvalid) + }) + + t.Run("the TLS on-demand URL must be valid", func(t *testing.T) { + cmd := newDeployCommand() + cmd.args.ServiceOptions.TLSEnabled = true + cmd.args.ServiceOptions.TLSOnDemandURL = "ftp://example.com/allow-host" + + err := cmd.preRun(cmd.cmd, []string{"test-service"}) + require.ErrorContains(t, err, "unsupported scheme") + require.ErrorIs(t, err, server.ErrServiceOptionsInvalid) + }) +} + func TestDeployCommand_CanonicalHostValidation(t *testing.T) { tests := []struct { name string diff --git a/internal/server/router_test.go b/internal/server/router_test.go index 7a1a55c..2818486 100644 --- a/internal/server/router_test.go +++ b/internal/server/router_test.go @@ -1,6 +1,7 @@ package server import ( + "context" "crypto/tls" "encoding/json" "net/http" @@ -11,6 +12,8 @@ import ( "testing" "time" + "golang.org/x/crypto/acme/autocert" + "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -803,6 +806,41 @@ func TestRouter_RestoreLastSavedState(t *testing.T) { assert.Equal(t, "third", body) } +func TestRouter_RestoreLastSavedState_TLSOnDemandURL(t *testing.T) { + statePath := filepath.Join(t.TempDir(), "state.json") + + allowServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Query().Get("host") == "allowed.example.com" { + w.WriteHeader(http.StatusOK) + return + } + w.WriteHeader(http.StatusForbidden) + })) + defer allowServer.Close() + + _, target := testBackend(t, "first", http.StatusOK) + + serviceOptions := defaultServiceOptions + serviceOptions.TLSEnabled = true + serviceOptions.TLSOnDemandURL = allowServer.URL + + router := NewRouter(statePath) + require.NoError(t, router.DeployService("ondemand", []string{target}, defaultEmptyReaders, serviceOptions, defaultTargetOptions, defaultDeploymentOptions)) + + 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(), "allowed.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 c068fe2..d3c88d6 100644 --- a/internal/server/service.go +++ b/internal/server/service.go @@ -1,6 +1,7 @@ package server import ( + "context" "crypto/sha256" "encoding/hex" "encoding/json" @@ -56,8 +57,22 @@ var ( ErrorUnableToLoadErrorPages = errors.New("unable to load error pages") ErrorAutomaticTLSDoesNotSupportWildcards = errors.New("automatic TLS does not support wildcards") ErrServiceOptionsInvalid = errors.New("service options invalid") + + contextKeyInternalRequest = contextKey("internal-request") ) +// markInternalRequest marks the context as belonging to an internal request: +// one synthesized inside the proxy itself, such as a TLS on-demand check +// probe, rather than arriving over a client connection. +func markInternalRequest(ctx context.Context) context.Context { + return context.WithValue(ctx, contextKeyInternalRequest, true) +} + +func isInternalRequest(r *http.Request) bool { + internal, _ := r.Context().Value(contextKeyInternalRequest).(bool) + return internal +} + type TargetSlot int const ( @@ -85,6 +100,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"` @@ -109,8 +125,28 @@ func (so *ServiceOptions) Normalize() { func (so ServiceOptions) Validate() error { so.Normalize() + if so.TLSOnDemandURL != "" && !so.TLSEnabled { + return fmt.Errorf("%w: TLS must be enabled to use a TLS on-demand URL", ErrServiceOptionsInvalid) + } + if so.TLSEnabled { - if !so.HasConfiguredHosts() { + if so.TLSOnDemandURL != "" { + if so.HasConfiguredHosts() { + return fmt.Errorf("%w: cannot set hosts when using a TLS on-demand URL", ErrServiceOptionsInvalid) + } + + if so.TLSCertificatePath != "" || so.TLSPrivateKeyPath != "" { + return fmt.Errorf("%w: cannot use a custom TLS certificate with a TLS on-demand URL", ErrServiceOptionsInvalid) + } + + if so.CanonicalHost != "" { + return fmt.Errorf("%w: cannot set a canonical host when using a TLS on-demand URL", ErrServiceOptionsInvalid) + } + + if err := validateTLSOnDemandURL(so.TLSOnDemandURL); err != nil { + return fmt.Errorf("%w: %w", ErrServiceOptionsInvalid, err) + } + } else if !so.HasConfiguredHosts() { return fmt.Errorf("%w: host must be set when using TLS", ErrServiceOptionsInvalid) } @@ -430,14 +466,33 @@ func (s *Service) createCertManager(options ServiceOptions) (CertManager, error) } } + certCache := autocert.DirCache(options.ScopedCachePath()) + + hostPolicy, err := s.createHostPolicy(options, certCache) + if err != nil { + return nil, err + } + return &autocert.Manager{ Prompt: autocert.AcceptTOS, - Cache: autocert.DirCache(options.ScopedCachePath()), - HostPolicy: autocert.HostWhitelist(options.Hosts...), + Cache: certCache, + HostPolicy: hostPolicy, Client: &acme.Client{DirectoryURL: options.ACMEDirectory}, }, nil } +func (s *Service) createHostPolicy(options ServiceOptions, certCache autocert.Cache) (autocert.HostPolicy, error) { + if options.TLSOnDemandURL != "" { + checker, err := newTLSOnDemandChecker(s, options.TLSOnDemandURL, certCache) + if err != nil { + return nil, err + } + return checker.hostPolicy(), nil + } + + return autocert.HostWhitelist(options.Hosts...), nil +} + func (s *Service) createMiddleware(options ServiceOptions, certManager CertManager) (http.Handler, error) { var err error var handler http.Handler = http.HandlerFunc(s.serviceRequestWithTarget) @@ -533,28 +588,30 @@ func (s *Service) handleRedirectsIfNeeded(w http.ResponseWriter, r *http.Request // TLS redirection or canonical host redirection should occur. If no redirect is // needed, it returns an empty string. func (s *Service) redirectURLIfNeeded(r *http.Request) string { - host, _, err := net.SplitHostPort(r.Host) - if err != nil { - host = r.Host - } + if !isInternalRequest(r) { + host, _, err := net.SplitHostPort(r.Host) + if err != nil { + host = r.Host + } - currentScheme := "http" - if r.TLS != nil { - currentScheme = "https" - } + currentScheme := "http" + if r.TLS != nil { + currentScheme = "https" + } - desiredScheme := currentScheme - if s.options.TLSEnabled && s.options.TLSRedirect && currentScheme == "http" { - desiredScheme = "https" - } + desiredScheme := currentScheme + if s.options.TLSEnabled && s.options.TLSRedirect && currentScheme == "http" { + desiredScheme = "https" + } - desiredHost := host - if s.options.CanonicalHost != "" && host != s.options.CanonicalHost { - desiredHost = s.options.CanonicalHost - } + desiredHost := host + if s.options.CanonicalHost != "" && host != s.options.CanonicalHost { + desiredHost = s.options.CanonicalHost + } - if desiredScheme != currentScheme || desiredHost != host { - return desiredScheme + "://" + desiredHost + r.URL.RequestURI() + if desiredScheme != currentScheme || desiredHost != host { + return desiredScheme + "://" + desiredHost + r.URL.RequestURI() + } } return "" diff --git a/internal/server/service_map.go b/internal/server/service_map.go index e214d7f..c2df79c 100644 --- a/internal/server/service_map.go +++ b/internal/server/service_map.go @@ -155,7 +155,7 @@ func (m *ServiceMap) updateRequestServiceMap() { func (m *ServiceMap) updateDefaultTLSHostname() { for _, service := range m.services { - if service.options.TLSEnabled && len(service.options.Hosts) > 0 { + if service.options.TLSEnabled && len(service.options.Hosts) > 0 && service.options.Hosts[0] != "" { m.defaultTLSHostname = service.options.Hosts[0] return } diff --git a/internal/server/service_map_test.go b/internal/server/service_map_test.go index 4461345..6b55f19 100644 --- a/internal/server/service_map_test.go +++ b/internal/server/service_map_test.go @@ -81,6 +81,19 @@ func TestServiceMap_DefaultTLSHostname(t *testing.T) { assert.Equal(t, "example.com", sm.DefaultTLSHostname()) } +func TestServiceMap_DefaultTLSHostnameIgnoresOnDemandTLSServices(t *testing.T) { + sm := NewServiceMap() + sm.Set(&Service{name: "1", options: normalizedServiceOptions(ServiceOptions{TLSEnabled: true, TLSOnDemandURL: "/allow-host"})}) + assert.Empty(t, sm.DefaultTLSHostname()) + + sm.Set(&Service{name: "2", options: normalizedServiceOptions(ServiceOptions{Hosts: []string{"example.com"}, TLSEnabled: true})}) + assert.Equal(t, "example.com", sm.DefaultTLSHostname()) + + // Re-setting the on-demand service must not displace the default hostname. + sm.Set(&Service{name: "1", options: normalizedServiceOptions(ServiceOptions{TLSEnabled: true, TLSOnDemandURL: "/allow-host"})}) + assert.Equal(t, "example.com", sm.DefaultTLSHostname()) +} + func TestServiceMap_SyncingTLSSettingsFromRootPath(t *testing.T) { sm := NewServiceMap() sm.Set(&Service{name: "1", options: normalizedServiceOptions(ServiceOptions{Hosts: []string{"1.example.com"}, TLSEnabled: true, TLSRedirect: false})}) diff --git a/internal/server/service_test.go b/internal/server/service_test.go index b5e7576..4a74219 100644 --- a/internal/server/service_test.go +++ b/internal/server/service_test.go @@ -121,6 +121,17 @@ func TestServiceOptions_Validate(t *testing.T) { assertNotValid(ServiceOptions{Hosts: []string{"example.com", "www.example.com"}, CanonicalHost: "api.example.com"}, "canonical-host 'api.example.com' must be present in the hosts list: [example.com www.example.com]") assertValid(ServiceOptions{Hosts: []string{"example.com", "www.example.com"}, CanonicalHost: "www.example.com"}) + + assertValid(ServiceOptions{TLSEnabled: true, TLSOnDemandURL: "/allow-host"}) + assertValid(ServiceOptions{TLSEnabled: true, TLSOnDemandURL: "https://example.com/allow-host"}) + assertNotValid(ServiceOptions{Hosts: []string{"example.com"}, TLSEnabled: true, TLSOnDemandURL: "/allow-host"}, "cannot set hosts when using a TLS on-demand URL") + assertNotValid(ServiceOptions{TLSEnabled: true, TLSOnDemandURL: "ftp://example.com/allow-host"}, "unsupported scheme") + assertNotValid(ServiceOptions{TLSEnabled: true, TLSOnDemandURL: "://invalid-url"}, "unable to parse tls-on-demand-url") + assertNotValid(ServiceOptions{TLSEnabled: true, TLSOnDemandURL: "//example.com/allow-host"}, "must be a path or an absolute http(s) URL") + assertNotValid(ServiceOptions{PathPrefixes: []string{"/api"}, TLSEnabled: true, TLSOnDemandURL: "/allow-host"}, "TLS settings must be specified on the root path service") + assertNotValid(ServiceOptions{TLSOnDemandURL: "/allow-host"}, "TLS must be enabled to use a TLS on-demand URL") + assertNotValid(ServiceOptions{TLSEnabled: true, TLSOnDemandURL: "/allow-host", TLSCertificatePath: "cert.pem", TLSPrivateKeyPath: "key.pem"}, "cannot use a custom TLS certificate with a TLS on-demand URL") + assertNotValid(ServiceOptions{TLSEnabled: true, TLSOnDemandURL: "/allow-host", CanonicalHost: "example.com"}, "cannot set a canonical host when using a TLS on-demand URL") } func TestService_DontRedirectToHTTPSWhenTLSAndPlainHTTPAllowed(t *testing.T) { diff --git a/internal/server/tls_on_demand.go b/internal/server/tls_on_demand.go new file mode 100644 index 0000000..82b484a --- /dev/null +++ b/internal/server/tls_on_demand.go @@ -0,0 +1,222 @@ +package server + +import ( + "bytes" + "context" + "fmt" + "io" + "log/slog" + "net/http" + "net/url" + "strings" + "time" + + "golang.org/x/crypto/acme/autocert" +) + +const ( + tlsOnDemandCheckTimeout = 2 * time.Second + tlsOnDemandCheckMaxBodyBytes = 256 +) + +// tlsOnDemandChecker builds an autocert.HostPolicy that approves hosts by +// querying the configured TLS on-demand endpoint (with a `host` query +// parameter); a 200 response allows certificate issuance. +type tlsOnDemandChecker struct { + service *Service + certCache autocert.Cache + endpoint *url.URL + local bool // endpoint is a path served by the service itself + + checkTimeout time.Duration +} + +func newTLSOnDemandChecker(service *Service, onDemandURL string, certCache autocert.Cache) (*tlsOnDemandChecker, error) { + endpoint, local, err := parseTLSOnDemandURL(onDemandURL) + if err != nil { + return nil, err + } + + return &tlsOnDemandChecker{ + service: service, + certCache: certCache, + endpoint: endpoint, + local: local, + checkTimeout: tlsOnDemandCheckTimeout, + }, nil +} + +// parseTLSOnDemandURL parses a TLS on-demand URL, returning the parsed URL and +// whether it names a path on the service itself rather than an external +// endpoint. +func parseTLSOnDemandURL(onDemandURL string) (*url.URL, bool, error) { + if strings.HasPrefix(onDemandURL, "//") { + return nil, false, fmt.Errorf("tls-on-demand-url must be a path or an absolute http(s) URL") + } + + if strings.HasPrefix(onDemandURL, "/") { + endpoint, err := url.Parse(onDemandURL) + if err != nil { + return nil, false, fmt.Errorf("unable to parse tls-on-demand-url: %w", err) + } + return endpoint, true, nil + } + + endpoint, err := url.ParseRequestURI(onDemandURL) + if err != nil { + return nil, false, fmt.Errorf("unable to parse tls-on-demand-url: %w", err) + } + if endpoint.Scheme != "http" && endpoint.Scheme != "https" { + return nil, false, fmt.Errorf("unsupported scheme %q in tls-on-demand-url", endpoint.Scheme) + } + if endpoint.Host == "" { + return nil, false, fmt.Errorf("missing host in tls-on-demand-url") + } + return endpoint, false, nil +} + +func validateTLSOnDemandURL(onDemandURL string) error { + _, _, err := parseTLSOnDemandURL(onDemandURL) + return err +} + +func (c *tlsOnDemandChecker) hostPolicy() autocert.HostPolicy { + policy := c.externalHostPolicy() + if c.local { + policy = c.localHostPolicy() + } + + return func(ctx context.Context, host string) error { + // Since x/crypto v0.34.0, autocert consults the host policy on every + // handshake, not just when issuing a certificate. A host that + // already has a certificate was approved when that certificate was + // issued, so we skip re-checking it. This keeps the endpoint off the + // handshake hot path, and means an endpoint outage cannot affect + // hosts that already serve TLS. + if c.hasCachedCert(ctx, host) { + return nil + } + + return policy(ctx, host) + } +} + +// hasCachedCert reports whether the certificate cache already holds a +// certificate for the host. autocert stores certificates keyed by the +// punycoded domain (which the host already is, by the time the policy is +// called), with a "+rsa" variant for RSA-only clients. +func (c *tlsOnDemandChecker) hasCachedCert(ctx context.Context, host string) bool { + host = strings.TrimSuffix(host, ".") + + for _, key := range []string{host, host + "+rsa"} { + if _, err := c.certCache.Get(ctx, key); err == nil { + return true + } + } + return false +} + +// localHostPolicy approves hosts by routing a request for the configured path +// through the service's own handler chain, so the backend application decides +// which hosts are allowed. +func (c *tlsOnDemandChecker) localHostPolicy() autocert.HostPolicy { + return func(ctx context.Context, host string) error { + ctx, cancel := context.WithTimeout(ctx, c.checkTimeout) + defer cancel() + ctx = markInternalRequest(ctx) + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.checkURL(host), http.NoBody) + if err != nil { + return err + } + + // The probe carries the hostname being validated, so backends that + // use host authorization see that rather than the target's address. + req.Host = host + + recorder := newBoundedResponseRecorder(tlsOnDemandCheckMaxBodyBytes) + c.service.ServeHTTP(recorder, req) + + if recorder.status != http.StatusOK { + return c.denialError(host, recorder.status, recorder.body.String()) + } + return nil + } +} + +// externalHostPolicy approves hosts by making an HTTP request to the +// configured external endpoint. +func (c *tlsOnDemandChecker) externalHostPolicy() autocert.HostPolicy { + client := &http.Client{ + Timeout: c.checkTimeout, + + // A redirect is the endpoint's final answer; following it could turn + // a denial into a 200 from wherever it redirects to. + CheckRedirect: func(*http.Request, []*http.Request) error { + return http.ErrUseLastResponse + }, + } + + return func(ctx context.Context, host string) error { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.checkURL(host), http.NoBody) + if err != nil { + return err + } + + resp, err := client.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(io.LimitReader(resp.Body, tlsOnDemandCheckMaxBodyBytes)) + return c.denialError(host, resp.StatusCode, string(body)) + } + return nil + } +} + +func (c *tlsOnDemandChecker) checkURL(host string) string { + endpoint := *c.endpoint + + query := endpoint.Query() + query.Set("host", host) + endpoint.RawQuery = query.Encode() + + return endpoint.String() +} + +func (c *tlsOnDemandChecker) denialError(host string, status int, body string) error { + slog.Warn("TLS on-demand check denied host", "host", host, "status", status, "body", body) + + return fmt.Errorf("%s is not allowed to get a certificate (status: %d, body: %q)", host, status, body) +} + +// boundedResponseRecorder is an http.ResponseWriter that captures the response +// status and at most `limit` bytes of the body, discarding the rest. +type boundedResponseRecorder struct { + status int + body bytes.Buffer + limit int + header http.Header +} + +func newBoundedResponseRecorder(limit int) *boundedResponseRecorder { + return &boundedResponseRecorder{status: http.StatusOK, limit: limit, header: http.Header{}} +} + +func (r *boundedResponseRecorder) Header() http.Header { + return r.header +} + +func (r *boundedResponseRecorder) WriteHeader(status int) { + r.status = status +} + +func (r *boundedResponseRecorder) Write(p []byte) (int, error) { + if remaining := r.limit - r.body.Len(); remaining > 0 { + r.body.Write(p[:min(len(p), remaining)]) + } + return len(p), nil +} diff --git a/internal/server/tls_on_demand_test.go b/internal/server/tls_on_demand_test.go new file mode 100644 index 0000000..ffef8e1 --- /dev/null +++ b/internal/server/tls_on_demand_test.go @@ -0,0 +1,252 @@ +package server + +import ( + "context" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "golang.org/x/crypto/acme/autocert" +) + +func testHostPolicy(t *testing.T, service *Service, onDemandURL string) autocert.HostPolicy { + checker, err := newTLSOnDemandChecker(service, onDemandURL, autocert.DirCache(t.TempDir())) + require.NoError(t, err) + return checker.hostPolicy() +} + +func TestTLSOnDemandChecker_LocalHostPolicy(t *testing.T) { + service := testCreateServiceWithHandler( + t, + ServiceOptions{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") == "allowed.example.com" { + w.WriteHeader(http.StatusOK) + return + } + w.WriteHeader(http.StatusForbidden) + w.Write([]byte("Access denied")) + }), + ) + + policy := testHostPolicy(t, service, service.options.TLSOnDemandURL) + + assert.NoError(t, policy(context.Background(), "allowed.example.com")) + + err := policy(context.Background(), "denied.example.com") + require.Error(t, err) + assert.Contains(t, err.Error(), "not allowed to get a certificate") + assert.Contains(t, err.Error(), "status: 403") + assert.Contains(t, err.Error(), "Access denied") +} + +func TestTLSOnDemandChecker_LocalHostPolicy_IsNotRedirectedWhenTLSRedirectEnabled(t *testing.T) { + var forwardedProto string + + service := testCreateServiceWithHandler( + t, + ServiceOptions{ + 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") == "allowed.example.com" { + forwardedProto = r.Header.Get("X-Forwarded-Proto") + w.WriteHeader(http.StatusOK) + return + } + w.WriteHeader(http.StatusForbidden) + }), + ) + + policy := testHostPolicy(t, service, service.options.TLSOnDemandURL) + + assert.NoError(t, policy(context.Background(), "allowed.example.com")) + assert.Equal(t, "http", forwardedProto) +} + +func TestTLSOnDemandChecker_LocalHostPolicy_SetsHostHeaderToCheckedHost(t *testing.T) { + var checkHost string + + service := testCreateServiceWithHandler( + t, + ServiceOptions{TLSOnDemandURL: "/allow-host"}, + defaultTargetOptions, + http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/allow-host" { + checkHost = r.Host + } + w.WriteHeader(http.StatusOK) + }), + ) + + policy := testHostPolicy(t, service, service.options.TLSOnDemandURL) + + assert.NoError(t, policy(context.Background(), "allowed.example.com")) + assert.Equal(t, "allowed.example.com", checkHost) +} + +func TestTLSOnDemandChecker_LocalHostPolicy_TruncatesLargeResponseBodies(t *testing.T) { + service := testCreateServiceWithHandler( + t, + ServiceOptions{TLSOnDemandURL: "/allow-host"}, + defaultTargetOptions, + http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/up" { + w.WriteHeader(http.StatusOK) + return + } + w.WriteHeader(http.StatusForbidden) + w.Write([]byte(strings.Repeat("a", 500))) + }), + ) + + policy := testHostPolicy(t, service, service.options.TLSOnDemandURL) + + err := policy(context.Background(), "denied.example.com") + require.Error(t, err) + assert.Contains(t, err.Error(), strings.Repeat("a", 256)) + assert.NotContains(t, err.Error(), strings.Repeat("a", 257)) +} + +func TestTLSOnDemandChecker_LocalHostPolicy_DeniesWhenStopped(t *testing.T) { + service := testCreateServiceWithHandler( + t, + ServiceOptions{TLSOnDemandURL: "/allow-host"}, + defaultTargetOptions, + http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + }), + ) + + policy := testHostPolicy(t, service, service.options.TLSOnDemandURL) + + require.NoError(t, service.Stop(time.Second, "stopped for maintenance")) + + err := policy(context.Background(), "allowed.example.com") + require.Error(t, err) + assert.Contains(t, err.Error(), "not allowed to get a certificate") +} + +func TestTLSOnDemandChecker_ExternalHostPolicy(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Query().Get("host") == "allowed.example.com" { + w.WriteHeader(http.StatusOK) + return + } + w.WriteHeader(http.StatusForbidden) + w.Write([]byte("Access denied")) + })) + defer server.Close() + + service := &Service{} + policy := testHostPolicy(t, service, server.URL) + + assert.NoError(t, policy(context.Background(), "allowed.example.com")) + + err := policy(context.Background(), "denied.example.com") + require.Error(t, err) + assert.Contains(t, err.Error(), "not allowed to get a certificate") + assert.Contains(t, err.Error(), "status: 403") + assert.Contains(t, err.Error(), "Access denied") +} + +func TestTLSOnDemandChecker_ExternalHostPolicy_DoesNotFollowRedirects(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/allowed" { + w.WriteHeader(http.StatusOK) + return + } + http.Redirect(w, r, "/allowed", http.StatusFound) + })) + defer server.Close() + + service := &Service{} + policy := testHostPolicy(t, service, server.URL) + + err := policy(context.Background(), "denied.example.com") + require.Error(t, err) + assert.Contains(t, err.Error(), "status: 302") +} + +func TestTLSOnDemandChecker_ExternalHostPolicy_HonoursContext(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + })) + defer server.Close() + + service := &Service{} + policy := testHostPolicy(t, service, server.URL) + + ctx, cancel := context.WithCancel(context.Background()) + cancel() + assert.Error(t, policy(ctx, "allowed.example.com")) +} + +func TestTLSOnDemandChecker_SkipsCheckWhenHostAlreadyHasCert(t *testing.T) { + requests := 0 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requests++ + w.WriteHeader(http.StatusForbidden) + })) + defer server.Close() + + // Certificates live in the cache keyed by domain, with a "+rsa" variant + // for RSA-only clients. + certCache := autocert.DirCache(t.TempDir()) + require.NoError(t, certCache.Put(context.Background(), "cached.example.com", []byte("cert"))) + require.NoError(t, certCache.Put(context.Background(), "rsa-only.example.com+rsa", []byte("cert"))) + + checker, err := newTLSOnDemandChecker(&Service{}, server.URL, certCache) + require.NoError(t, err) + policy := checker.hostPolicy() + + assert.NoError(t, policy(context.Background(), "cached.example.com")) + assert.NoError(t, policy(context.Background(), "cached.example.com.")) + assert.NoError(t, policy(context.Background(), "rsa-only.example.com")) + assert.Equal(t, 0, requests) + + assert.Error(t, policy(context.Background(), "uncached.example.com")) + assert.Equal(t, 1, requests) +} + +func TestTLSOnDemandChecker_CheckURL(t *testing.T) { + checkURL := func(onDemandURL, host string) string { + checker, err := newTLSOnDemandChecker(&Service{}, onDemandURL, autocert.DirCache(t.TempDir())) + require.NoError(t, err) + return checker.checkURL(host) + } + + assert.Equal(t, "/allow-host?host=test.example.com", checkURL("/allow-host", "test.example.com")) + assert.Equal(t, "/allow-host?host=test.example.com%3A8080", checkURL("/allow-host", "test.example.com:8080")) + assert.Equal(t, "/allow-host?host=test.example.com&token=abc", checkURL("/allow-host?token=abc", "test.example.com")) + assert.Equal(t, "https://example.com/check?host=test.example.com&token=abc", checkURL("https://example.com/check?token=abc", "test.example.com")) +} + +func TestValidateTLSOnDemandURL(t *testing.T) { + assert.NoError(t, validateTLSOnDemandURL("/allow-host")) + assert.NoError(t, validateTLSOnDemandURL("/allow-host?token=abc")) + assert.NoError(t, validateTLSOnDemandURL("http://example.com/check")) + assert.NoError(t, validateTLSOnDemandURL("https://example.com/check")) + + assert.ErrorContains(t, validateTLSOnDemandURL("://invalid-url"), "unable to parse tls-on-demand-url") + assert.ErrorContains(t, validateTLSOnDemandURL("ftp://example.com/check"), "unsupported scheme") + assert.ErrorContains(t, validateTLSOnDemandURL("allow-host"), "unable to parse tls-on-demand-url") + assert.ErrorContains(t, validateTLSOnDemandURL("http://"), "missing host") + assert.ErrorContains(t, validateTLSOnDemandURL("//example.com/check"), "must be a path or an absolute http(s) URL") +} From 0b339ce06d9e831a1b5281af92b42d446c98e91f Mon Sep 17 00:00:00 2001 From: Kevin McConnell Date: Thu, 16 Jul 2026 20:43:40 +0100 Subject: [PATCH 08/10] Build with Go 1.26.5 --- Dockerfile | 2 +- example/upstream/Dockerfile | 2 +- go.mod | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/Dockerfile b/Dockerfile index 28dfbd4..8cde29e 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -FROM golang:1.26.4 AS build +FROM golang:1.26.5 AS build WORKDIR /app diff --git a/example/upstream/Dockerfile b/example/upstream/Dockerfile index 9464bdc..f027545 100644 --- a/example/upstream/Dockerfile +++ b/example/upstream/Dockerfile @@ -1,4 +1,4 @@ -from golang:1.26.4 as build +from golang:1.26.5 as build workdir /app copy . . env CGO_ENABLED=0 diff --git a/go.mod b/go.mod index 8e1a118..82182ad 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module github.com/basecamp/kamal-proxy -go 1.26.4 +go 1.26.5 require ( github.com/coder/websocket v1.8.12 From 2ad0b2c00307bb8cfb075480e1015e27c35f7027 Mon Sep 17 00:00:00 2001 From: Kevin McConnell Date: Thu, 16 Jul 2026 20:44:41 +0100 Subject: [PATCH 09/10] Update deps --- go.mod | 13 ++++++------- go.sum | 44 ++++++++++++++------------------------------ 2 files changed, 20 insertions(+), 37 deletions(-) diff --git a/go.mod b/go.mod index 82182ad..184c237 100644 --- a/go.mod +++ b/go.mod @@ -9,7 +9,7 @@ require ( github.com/quic-go/quic-go v0.60.0 github.com/spf13/cobra v1.10.2 github.com/stretchr/testify v1.11.1 - golang.org/x/crypto v0.53.0 + golang.org/x/crypto v0.54.0 ) require ( @@ -21,14 +21,13 @@ require ( github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect github.com/prometheus/client_model v0.6.2 // indirect - github.com/prometheus/common v0.69.0 // indirect - github.com/prometheus/procfs v0.20.1 // indirect + github.com/prometheus/common v0.70.0 // indirect + github.com/prometheus/procfs v0.21.1 // indirect github.com/quic-go/qpack v0.6.0 // indirect github.com/spf13/pflag v1.0.10 // indirect - go.yaml.in/yaml/v2 v2.4.4 // indirect - golang.org/x/net v0.56.0 // indirect - golang.org/x/sys v0.46.0 // indirect - golang.org/x/text v0.38.0 // indirect + golang.org/x/net v0.57.0 // indirect + golang.org/x/sys v0.47.0 // indirect + golang.org/x/text v0.40.0 // indirect google.golang.org/protobuf v1.36.11 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index 94f261f..ad93b62 100644 --- a/go.sum +++ b/go.sum @@ -30,18 +30,14 @@ github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg= github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk= github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE= -github.com/prometheus/common v0.67.4 h1:yR3NqWO1/UyO1w2PhUvXlGQs/PtFmoveVO0KZ4+Lvsc= -github.com/prometheus/common v0.67.4/go.mod h1:gP0fq6YjjNCLssJCQp0yk4M8W6ikLURwkdd/YKtTbyI= -github.com/prometheus/common v0.69.0 h1:OA85nJQS/T/MaYh/Q2CcgDKSGWqNIgrBDvDH85CuiNk= -github.com/prometheus/common v0.69.0/go.mod h1:ZzL3f6u94qUxh9p+tJTrF+FvBS1XXbbRAZCQkytAL0Y= -github.com/prometheus/procfs v0.19.2 h1:zUMhqEW66Ex7OXIiDkll3tl9a1ZdilUOd/F6ZXw4Vws= -github.com/prometheus/procfs v0.19.2/go.mod h1:M0aotyiemPhBCM0z5w87kL22CxfcH05ZpYlu+b4J7mw= -github.com/prometheus/procfs v0.20.1 h1:XwbrGOIplXW/AU3YhIhLODXMJYyC1isLFfYCsTEycfc= -github.com/prometheus/procfs v0.20.1/go.mod h1:o9EMBZGRyvDrSPH1RqdxhojkuXstoe4UlK79eF5TGGo= +github.com/prometheus/common v0.70.0 h1:bcpru3tWPVnxGnETLgOV5jbp/JRXgYEyv65CuBLAMMI= +github.com/prometheus/common v0.70.0/go.mod h1:S/SFasQmgGiYH6C81LKCtYa8QACgthGg5zxL2udV7SY= +github.com/prometheus/procfs v0.21.1 h1:GljZCt+zSTS+NZq88cyQ1LjZ+RCHp3uVuabBWA5+OJI= +github.com/prometheus/procfs v0.21.1/go.mod h1:aB55Cww9pdSJVHk0hUf0inxWyyjPogFIjmHKYgMKmtY= +github.com/quic-go/go-ossfuzz-seeds v0.1.0 h1:APacT+iIaNF6fd8AGEiN3bT/Jtkd2jz4v4TzM7MFjy0= +github.com/quic-go/go-ossfuzz-seeds v0.1.0/go.mod h1:3IOHRbJIc+L6YKMwfDtJAM9Vj9k0YY4muhuyUYk5tbk= github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8= github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII= -github.com/quic-go/quic-go v0.57.1 h1:25KAAR9QR8KZrCZRThWMKVAwGoiHIrNbT72ULHTuI10= -github.com/quic-go/quic-go v0.57.1/go.mod h1:ly4QBAjHA2VhdnxhojRsCUOeJwKYg+taDlos92xb1+s= github.com/quic-go/quic-go v0.60.0 h1:xcQioE8OM66UQLeUMHltK1CCcOu3JbVB4JAQdDQSB+0= github.com/quic-go/quic-go v0.60.0/go.mod h1:wpKpjmPpftl30sL6pFh7REVpjbcCVy4zt2vDyK1TuJk= github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ= @@ -58,29 +54,17 @@ go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= go.uber.org/mock v0.5.2 h1:LbtPTcP8A5k9WPXj54PPPbjcI4Y6lhyOZXn+VS7wNko= go.uber.org/mock v0.5.2/go.mod h1:wLlUxC2vVTPTaE3UD51E0BGOAElKrILxhVSDYQLld5o= -go.yaml.in/yaml/v2 v2.4.3 h1:6gvOSjQoTB3vt1l+CU+tSyi/HOjfOjRLJ4YwYZGwRO0= -go.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8= go.yaml.in/yaml/v2 v2.4.4 h1:tuyd0P+2Ont/d6e2rl3be67goVK4R6deVxCUX5vyPaQ= go.yaml.in/yaml/v2 v2.4.4/go.mod h1:gMZqIpDtDqOfM0uNfy0SkpRhvUryYH0Z6wdMYcacYXQ= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= -golang.org/x/crypto v0.46.0 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU= -golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0= -golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto= -golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio= -golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU= -golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY= -golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o= -golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec= -golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk= -golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= -golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw= -golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= -golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU= -golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY= -golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE= -golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4= -golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE= -golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg= +golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw= +golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk= +golang.org/x/net v0.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE= +golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU= +golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= +golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs= +golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= From ce2aa7ebfc5ef69cae3e529fe68c1282c80c3bc3 Mon Sep 17 00:00:00 2001 From: mhenrixon Date: Sun, 26 Jul 2026 23:35:21 +0200 Subject: [PATCH 10/10] test(service): cover the two conflict resolutions from the upstream merge - tls-domains-source combined with tls-on-demand-url is rejected - an explicit --tls-on-demand-url takes precedence over the proxy-wide SAN certificate manager instead of being silently ignored --- internal/server/service_test.go | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/internal/server/service_test.go b/internal/server/service_test.go index d70dd68..7788aca 100644 --- a/internal/server/service_test.go +++ b/internal/server/service_test.go @@ -15,6 +15,7 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "golang.org/x/crypto/acme/autocert" ) func TestService_ServeRequest(t *testing.T) { @@ -160,6 +161,11 @@ func TestServiceOptions_Validate_DynamicDomains(t *testing.T) { // A source requires TLS to be enabled assertNotValid(ServiceOptions{TLSDomainsSource: "/domains"}, "tls-domains-source requires TLS") + // Upstream's on-demand TLS solves the same problem through a different + // manager, and only one of them can serve the handshake + assertNotValid(ServiceOptions{TLSEnabled: true, TLSDomainsSource: "/domains", TLSOnDemandURL: "https://app.internal/ask"}, + "tls-domains-source cannot be combined with tls-on-demand-url") + // The source must be a path on the service or an absolute http(s) URL assertNotValid(ServiceOptions{TLSEnabled: true, TLSDomainsSource: "domains"}, "tls-domains-source must be a path or an http(s) URL") assertNotValid(ServiceOptions{TLSEnabled: true, TLSDomainsSource: "ftp://app.internal/domains"}, "tls-domains-source must be a path or an http(s) URL") @@ -174,10 +180,17 @@ func TestServiceOptions_Validate_DynamicDomains(t *testing.T) { assertNotValid(ServiceOptions{Hosts: []string{"example.com"}, TLSEnabled: true, TLSDomainsBatchSize: 5}, "tls-domains-batch-size requires tls-domains-source") assertNotValid(ServiceOptions{Hosts: []string{"example.com"}, TLSEnabled: true, TLSDomainsInterval: time.Minute}, "tls-domains-interval requires tls-domains-source") + // An explicit on-demand URL is a per-service opt-in, so it wins over the + // proxy-wide SAN manager rather than being silently ignored + onDemandService, err := NewService("on-demand", + ServiceOptions{TLSEnabled: true, TLSOnDemandURL: "/ask"}, defaultTargetOptions, testSANCertManager(t)) + require.NoError(t, err) + assert.IsType(t, &autocert.Manager{}, onDemandService.certManager) + // A catch-all TLS service must never register the empty host for // certificate provisioning manager := testSANCertManager(t) - _, err := NewService("catch-all", ServiceOptions{TLSEnabled: true, TLSDomainsSource: "/domains", Hosts: []string{""}}, defaultTargetOptions, manager) + _, err = NewService("catch-all", ServiceOptions{TLSEnabled: true, TLSDomainsSource: "/domains", Hosts: []string{""}}, defaultTargetOptions, manager) require.NoError(t, err) assert.Empty(t, manager.pendingDomains)