From 7dedd1486c252133c9fb0d2d28992c928ffc451d Mon Sep 17 00:00:00 2001 From: prettysunflower Date: Wed, 15 Apr 2026 02:58:53 -0400 Subject: [PATCH 01/13] fix(caddyfile): {block} in snippet (#7558) * fix(caddyfile): {block} in snippet Resolve issue #7557 So, here is the situation: - Pull request #7206 included some changes to the doImport's function of Caddyfile's parser. What it does is that if there is no token within a block that follows the import, and the import contains `{block}`, then the `{block}` token is discarded. - After this pull request: - Issue #7518 noticed that in cases that `{block}` was not imported, a runtime error was raised due to the assumption that tokens were always added to `tokensCopy` on every iteration of `importedTokens`. This was fixed by pull request #7543. - Issue #7557 notices that {block} can be ignored when imported from a certain file. There, it's again an issue with how the import works. When `import snippets` is called, this import instruction doesn't contains any nested blocks. And when the argument replacer that is the `importedTokens` loop is called and finds `{block}`, it uses the block from the file's import (which in this case is nothing), `{block}` is erased, and unavailable when the import directive is called for the imported snippet. The changed in this commit addresses the second issue by checking before replacing `{block}` if we're currently in a snippet definition, and appending the `{block}` token to `tokensCopy` if we are. With this changes, when importing those snippets, the `{block}` token will be available to be replaced by the nested blocks in `tokensToAdd` if needed, or erased if there are no nested blocks and `tokensToAdd` is empty. Tests added in pull requests #7206 and #7543 passes with this new implementation, confirming that unused `{block}` are accepted if nothing is passed to `import`, as well as the other usual tests. A new test was also added based on issue #7557 reporting, and also passes. Signed-off-by: prettysunflower * caddyfile: add imported snippet block placeholder coverage --------- Signed-off-by: prettysunflower Co-authored-by: Zen Dodd --- caddyconfig/caddyfile/parse.go | 6 +- caddyconfig/caddyfile/parse_test.go | 71 +++++++++++++++++++ ...snippet_invalid_subdirective.caddyfiletest | 15 ++++ ...sue_7557_invalid_subdirective_snippet.conf | 7 ++ 4 files changed, 98 insertions(+), 1 deletion(-) create mode 100644 caddytest/integration/caddyfile_adapt/import_block_snippet_invalid_subdirective.caddyfiletest create mode 100644 caddytest/integration/testdata/issue_7557_invalid_subdirective_snippet.conf diff --git a/caddyconfig/caddyfile/parse.go b/caddyconfig/caddyfile/parse.go index 6a4db5bbb..58d9b272c 100644 --- a/caddyconfig/caddyfile/parse.go +++ b/caddyconfig/caddyfile/parse.go @@ -550,7 +550,11 @@ func (p *parser) doImport(nesting int) error { } if foundBlockDirective { - tokensCopy = append(tokensCopy, tokensToAdd...) + if maybeSnippet { + tokensCopy = append(tokensCopy, token) + } else { + tokensCopy = append(tokensCopy, tokensToAdd...) + } continue } diff --git a/caddyconfig/caddyfile/parse_test.go b/caddyconfig/caddyfile/parse_test.go index 516b7dfd9..9403f7ac3 100644 --- a/caddyconfig/caddyfile/parse_test.go +++ b/caddyconfig/caddyfile/parse_test.go @@ -960,6 +960,77 @@ import `+importFile2+` } } +func TestImportedSnippetDefinitionRetainsBlockPlaceholder(t *testing.T) { + tempDir := t.TempDir() + importFile := filepath.Join(tempDir, "snippets.caddy") + + err := os.WriteFile(importFile, []byte(` + (site) { + http://{args[0]} { + respond "before" + {block} + respond "after" + } + } + `), 0o644) + if err != nil { + t.Fatalf("writing imported snippet file: %v", err) + } + + for _, tc := range []struct { + name string + input string + expectedDirectives []string + }{ + { + name: "with nested block", + input: ` + import ` + importFile + ` + + import site example.com { + redir https://example.net + } + `, + expectedDirectives: []string{"respond", "redir", "respond"}, + }, + { + name: "without nested block", + input: ` + import ` + importFile + ` + + import site example.com + `, + expectedDirectives: []string{"respond", "respond"}, + }, + } { + t.Run(tc.name, func(t *testing.T) { + p := testParser(tc.input) + blocks, err := p.parseAll() + if err != nil { + t.Fatalf("parseAll: %v", err) + } + + if len(blocks) != 1 { + t.Fatalf("expected exactly one server block, got %d", len(blocks)) + } + + if actual := blocks[0].GetKeysText(); len(actual) != 1 || actual[0] != "http://example.com" { + t.Fatalf("expected server block key http://example.com, got %v", actual) + } + + if len(blocks[0].Segments) != len(tc.expectedDirectives) { + t.Fatalf("expected %d segments, got %d", len(tc.expectedDirectives), len(blocks[0].Segments)) + } + + for i, directive := range tc.expectedDirectives { + if actual := blocks[0].Segments[i].Directive(); actual != directive { + t.Fatalf("segment %d: expected directive %q, got %q", i, directive, actual) + } + } + }) + } +} + func testParser(input string) parser { return parser{Dispenser: NewTestDispenser(input)} } diff --git a/caddytest/integration/caddyfile_adapt/import_block_snippet_invalid_subdirective.caddyfiletest b/caddytest/integration/caddyfile_adapt/import_block_snippet_invalid_subdirective.caddyfiletest new file mode 100644 index 000000000..6936bada1 --- /dev/null +++ b/caddytest/integration/caddyfile_adapt/import_block_snippet_invalid_subdirective.caddyfiletest @@ -0,0 +1,15 @@ +{ + admin off + auto_https off +} + +import testdata/issue_7557_invalid_subdirective_snippet.conf + +:8080 { + import test { + this_is_nonsense + } +} + +---------- +parsing caddyfile tokens for 'reverse_proxy': unrecognized subdirective this_is_nonsense \ No newline at end of file diff --git a/caddytest/integration/testdata/issue_7557_invalid_subdirective_snippet.conf b/caddytest/integration/testdata/issue_7557_invalid_subdirective_snippet.conf new file mode 100644 index 000000000..d7cb0c9ff --- /dev/null +++ b/caddytest/integration/testdata/issue_7557_invalid_subdirective_snippet.conf @@ -0,0 +1,7 @@ +# Used by import_block_snippet_invalid_subdirective.caddyfiletest + +(test) { + reverse_proxy { + {block} + } +} \ No newline at end of file From 24bebd0a07cef434ec9c807bdb3a5a2d5f5ae9c1 Mon Sep 17 00:00:00 2001 From: Steffen Busch <37350514+steffenbusch@users.noreply.github.com> Date: Fri, 17 Apr 2026 22:13:15 +0200 Subject: [PATCH 02/13] caddyhttp: Document missing placeholders for escaped URI and prefixed query (#7659) --- modules/caddyhttp/app.go | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/modules/caddyhttp/app.go b/modules/caddyhttp/app.go index 74f1466be..673c36d77 100644 --- a/modules/caddyhttp/app.go +++ b/modules/caddyhttp/app.go @@ -69,6 +69,7 @@ func init() { // `{http.request.orig_uri.path.dir}` | The request's original directory // `{http.request.orig_uri.path.file}` | The request's original filename // `{http.request.orig_uri.query}` | The request's original query string (without `?`) +// `{http.request.orig_uri.prefixed_query}` | The request's original query string with a `?` prefix, if non-empty // `{http.request.port}` | The port part of the request's Host header // `{http.request.proto}` | The protocol of the request // `{http.request.local.host}` | The host (IP) part of the local address the connection arrived on @@ -98,11 +99,15 @@ func init() { // `{http.request.tls.client.san.ips.*}` | SAN IP addresses (index optional) // `{http.request.tls.client.san.uris.*}` | SAN URIs (index optional) // `{http.request.uri}` | The full request URI +// `{http.request.uri_escaped}` | The full request URI with query-style URL encoding applied (using url.QueryEscape) // `{http.request.uri.path}` | The path component of the request URI +// `{http.request.uri.path_escaped}` | The path component of the request URI with query-style URL encoding applied (using url.QueryEscape) // `{http.request.uri.path.*}` | Parts of the path, split by `/` (0-based from left) // `{http.request.uri.path.dir}` | The directory, excluding leaf filename // `{http.request.uri.path.file}` | The filename of the path, excluding directory // `{http.request.uri.query}` | The query string (without `?`) +// `{http.request.uri.query_escaped}` | The query string with query-style URL encoding applied (using url.QueryEscape) +// `{http.request.uri.prefixed_query}` | The query string with a `?` prefix, if non-empty // `{http.request.uri.query.*}` | Individual query string value // `{http.response.header.*}` | Specific response header field // `{http.vars.*}` | Custom variables in the HTTP handler chain From bd9f1453219a2610c319192f23782abb01b96699 Mon Sep 17 00:00:00 2001 From: Mohammed Al Sahaf Date: Fri, 17 Apr 2026 23:49:58 +0300 Subject: [PATCH 03/13] chore: add `AGENTS.md` (#7652) * chore: add `AGENTS.md` Signed-off-by: Mohammed Al Sahaf * Apply suggestions from code review Co-authored-by: Francis Lavoie Co-authored-by: Matt Holt * review feedback Signed-off-by: Mohammed Al Sahaf --------- Signed-off-by: Mohammed Al Sahaf Co-authored-by: Francis Lavoie Co-authored-by: Matt Holt --- AGENTS.md | 217 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 217 insertions(+) create mode 100644 AGENTS.md diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 000000000..8b1b5eb8b --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,217 @@ +# Caddy Project Guidelines + +## Mission + +**Every site on HTTPS.** Caddy is a security-first, modular, extensible server platform. + +## Code Style + +### Go Idioms + +Follow [Go Code Review Comments](https://go.dev/wiki/CodeReviewComments): + +- **Error flow**: Early return, indent error handling—not else blocks + ```go + if err != nil { + return err + } + // normal code + ``` +- **Naming**: initialisms (`URL`, `HTTP`, `ID`—not `Url`, `Http`, `Id`) +- **Receiver names**: 1–2 letters reflecting type (`c` for `Client`, `h` for `Handler`) +- **Error strings**: Lowercase, no trailing punctuation (`"something failed"` not `"Something failed."`) +- **Doc comments**: Full sentences starting with the name being documented + ```go + // Handler serves HTTP requests for the file server. + type Handler struct { ... } + ``` +- **Empty slices**: `var t []string` (nil slice), not `t := []string{}` (non-nil zero-length) +- **Don't panic**: Use error returns for normal error handling + +### Caddy Patterns + +**Module registration**: +```go +func init() { + caddy.RegisterModule(MyModule{}) +} + +func (MyModule) CaddyModule() caddy.ModuleInfo { + return caddy.ModuleInfo{ + ID: "namespace.category.name", + New: func() caddy.Module { return new(MyModule) }, + } +} +``` + +**Module lifecycle**: `New()` → JSON unmarshal → `Provision()` → `Validate()` → use → `Cleanup()` + +**Interface guards** — compile-time verification that modules implement required interfaces: +```go +var ( + _ caddy.Provisioner = (*MyModule)(nil) + _ caddy.Validator = (*MyModule)(nil) + _ caddyfile.Unmarshaler = (*MyModule)(nil) +) +``` + +**Structured logging** — use the module-scoped logger from context: +```go +func (m *MyModule) Provision(ctx caddy.Context) error { + m.logger = ctx.Logger() + m.logger.Debug("provisioning", zap.String("field", m.Field)) + return nil +} +``` + +**Caddyfile support** — implement `UnmarshalCaddyfile(*caddyfile.Dispenser)` using the `Dispenser` API: +```go +// UnmarshalCaddyfile sets up the module from Caddyfile tokens. Syntax: +// +// directive [arg1] [arg2] { +// subdir value +// } +func (m *MyModule) UnmarshalCaddyfile(d *caddyfile.Dispenser) error { + d.Next() // consume directive name + for d.NextArg() { + // handle inline arguments + } + for nesting := d.Nesting(); d.NextBlock(nesting); { + switch d.Val() { + case "subdir": + if !d.NextArg() { + return d.ArgErr() + } + m.Field = d.Val() + default: + return d.Errf("unrecognized subdirective: %s", d.Val()) + } + } + return nil +} +``` + +**Admin API**: Implement `caddy.AdminRouter` for custom endpoints. + +**Context**: Use `caddy.Context` for accessing other apps/modules and logging—don't store contexts in structs. + +## Architecture + +Caddy is built around a **module system** where everything is a module registered via `caddy.RegisterModule()`: + +- **Apps** (`caddy.App`): Top-level modules like `http`, `tls`, `pki` that Caddy loads and runs +- **Modules** (`caddy.Module`): Extensible components with namespaced IDs (e.g., `http.handlers.file_server`) +- **Configuration**: Native JSON with adapters (Caddyfile → JSON via `caddyconfig/httpcaddyfile`) + +| Directory | Purpose | +|-----------|---------| +| `modules/` | All standard modules (HTTP, TLS, PKI, etc.) | +| `modules/standard/imports.go` | Standard module registry | +| `caddyconfig/httpcaddyfile/` | Caddyfile → JSON adapter for HTTP | +| `caddytest/` | Test utilities and integration tests | +| `cmd/caddy/` | CLI entry point with module imports | + +### Critical Packages + +`caddyhttp` and `caddytls` require **extra scrutiny** in code review—these are security-critical. + +## Quality Gates + + +**All required before PR is merge-ready:** + +| Gate | Command | Notes | +|------|---------|-------| +| Tests pass | `go test -race -short ./...` | Race detection enabled | +| Lint clean | `golangci-lint run --timeout 10m` | No warnings in changed files | +| Builds | `go build ./...` | Must compile | +| Benchmarks | `go test -bench=. -benchmem` | Required for optimizations | + +CI runs tests on **Linux, macOS, and Windows**—ensure cross-platform compatibility. + +### Build & Test + +```bash +# Build +cd cmd/caddy && go build + +# Tests with race detection (matches CI) +go test -race -short ./... + +# Integration tests +go test ./caddytest/integration/... + +# Lint (matches CI) +golangci-lint run --timeout 10m +``` + +## Testing Conventions + +**Table-driven tests** (preferred pattern): +```go +func TestFeature(t *testing.T) { + for i, tc := range []struct { + input string + expected string + wantErr bool + }{ + {input: "valid", expected: "result", wantErr: false}, + {input: "invalid", expected: "", wantErr: true}, + } { + actual, err := Function(tc.input) + if tc.wantErr && err == nil { + t.Errorf("Test %d: expected error but got none", i) + } + if !tc.wantErr && err != nil { + t.Errorf("Test %d: unexpected error: %v", i, err) + } + if actual != tc.expected { + t.Errorf("Test %d: expected %q, got %q", i, tc.expected, actual) + } + } +} +``` + +**Integration tests** use `caddytest.Tester`: +```go +func TestHTTPFeature(t *testing.T) { + tester := caddytest.NewTester(t) + tester.InitServer(` + { + admin localhost:2999 + http_port 9080 + } + localhost:9080 { + respond "hello" + }`, "caddyfile") + + tester.AssertGetResponse("http://localhost:9080/", 200, "hello") +} +``` + +Use non-standard ports (9080, 9443, 2999) to avoid conflicts with running servers. + +## AI Contribution Policy + +Per [CONTRIBUTING.md](.github/CONTRIBUTING.md), AI-assisted code **MUST** be: + +1. **Disclosed** — Tell reviewers when code was AI-generated or AI-assisted, mentioning which agent/model is used +2. **Fully comprehended** — You must be able to explain every line +3. **Tested** — Automated tests when feasible, thorough manual tests otherwise +4. **Licensed** — Verify AI output doesn't include plagiarized or incompatibly-licensed code +5. **Contributor License Agreement (CLA)** — The CLA must be signed by the human user + +**Do NOT submit code you cannot fully explain.** Contributors are responsible for their submissions. + +## Dependencies + +- **Avoid new dependencies** — Justify any additions; tiny deps can be inlined +- **No exported dependency types** — Caddy must not export types defined by external packages +- Use Go modules; check with `go mod tidy` + +## Further Reading + +- [CONTRIBUTING.md](.github/CONTRIBUTING.md) — Full PR process and expectations +- [Extending Caddy](https://caddyserver.com/docs/extending-caddy) — Module development guide +- [JSON Config](https://caddyserver.com/docs/json/) — Native configuration reference +- [Caddyfile](https://caddyserver.com/docs/caddyfile/concepts) — Caddyfile syntax guide From af89c5ab02c7260df28655697964f47a6181b481 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 17 Apr 2026 14:50:53 -0600 Subject: [PATCH 04/13] build(deps): bump github.com/jackc/pgx/v5 from 5.8.0 to 5.9.0 (#7655) Bumps [github.com/jackc/pgx/v5](https://github.com/jackc/pgx) from 5.8.0 to 5.9.0. - [Changelog](https://github.com/jackc/pgx/blob/master/CHANGELOG.md) - [Commits](https://github.com/jackc/pgx/compare/v5.8.0...v5.9.0) --- updated-dependencies: - dependency-name: github.com/jackc/pgx/v5 dependency-version: 5.9.0 dependency-type: indirect ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- go.mod | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index de2031c97..0df389b59 100644 --- a/go.mod +++ b/go.mod @@ -70,7 +70,7 @@ require ( github.com/googleapis/enterprise-certificate-proxy v0.3.14 // indirect github.com/googleapis/gax-go/v2 v2.19.0 // indirect github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0 // indirect - github.com/jackc/pgx/v5 v5.8.0 // indirect + github.com/jackc/pgx/v5 v5.9.0 // indirect github.com/jackc/puddle/v2 v2.2.2 // indirect github.com/kylelemons/godebug v1.1.0 // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect diff --git a/go.sum b/go.sum index 53018c2c1..4d6b748fa 100644 --- a/go.sum +++ b/go.sum @@ -205,8 +205,8 @@ github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsI github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= -github.com/jackc/pgx/v5 v5.8.0 h1:TYPDoleBBme0xGSAX3/+NujXXtpZn9HBONkQC7IEZSo= -github.com/jackc/pgx/v5 v5.8.0/go.mod h1:QVeDInX2m9VyzvNeiCJVjCkNFqzsNb43204HshNSZKw= +github.com/jackc/pgx/v5 v5.9.0 h1:T/dI+2TvmI2H8s/KH1/lXIbz1CUFk3gn5oTjr0/mBsE= +github.com/jackc/pgx/v5 v5.9.0/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4= github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= github.com/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI= From 4430756d5c3047564c4d5d72793de6685ba3efda Mon Sep 17 00:00:00 2001 From: Zen Dodd Date: Sat, 18 Apr 2026 06:56:42 +1000 Subject: [PATCH 05/13] admin: Redact sensitive request headers in API logs (#7578) * admin: Redact sensitive request headers in API logs * Fix govulncheck and typed atomic lint failures * Sync Go module metadata after dependency downgrade --- admin.go | 4 +- admin_test.go | 47 ++++++++++++++++ go.mod | 32 +++++------ go.sum | 72 ++++++++++++------------- internal/logmarshalers.go | 54 +++++++++++++++++++ modules/caddyhttp/marshalers.go | 47 +++------------- modules/caddyhttp/reverseproxy/hosts.go | 3 +- modules/logging/filters.go | 26 ++++----- modules/logging/filters_test.go | 32 +++++------ 9 files changed, 192 insertions(+), 125 deletions(-) create mode 100644 internal/logmarshalers.go diff --git a/admin.go b/admin.go index 9c9102120..a93595416 100644 --- a/admin.go +++ b/admin.go @@ -45,6 +45,8 @@ import ( "github.com/prometheus/client_golang/prometheus" "go.uber.org/zap" "go.uber.org/zap/zapcore" + + "github.com/caddyserver/caddy/v2/internal" ) // testCertMagicStorageOverride is a package-level test hook. Tests may set @@ -800,7 +802,7 @@ func (h adminHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { zap.String("uri", r.RequestURI), zap.String("remote_ip", ip), zap.String("remote_port", port), - zap.Reflect("headers", r.Header), + zap.Object("headers", internal.LoggableHTTPHeader{Header: r.Header}), ) if r.TLS != nil { log = log.With( diff --git a/admin_test.go b/admin_test.go index 97dc76f4d..3801c301a 100644 --- a/admin_test.go +++ b/admin_test.go @@ -31,6 +31,8 @@ import ( "github.com/caddyserver/certmagic" "github.com/prometheus/client_golang/prometheus" dto "github.com/prometheus/client_model/go" + "go.uber.org/zap" + "go.uber.org/zap/zaptest/observer" ) var testCfg = []byte(`{ @@ -242,6 +244,51 @@ func TestAdminHandlerErrorHandling(t *testing.T) { } } +func TestAdminHandlerServeHTTPRedactsSensitiveHeadersInLogs(t *testing.T) { + core, logs := observer.New(zap.InfoLevel) + + defaultLoggerMu.Lock() + origLogger := defaultLogger.logger + defaultLogger.logger = zap.New(core) + defaultLoggerMu.Unlock() + t.Cleanup(func() { + defaultLoggerMu.Lock() + defaultLogger.logger = origLogger + defaultLoggerMu.Unlock() + }) + + handler := adminHandler{ + mux: http.NewServeMux(), + } + req := httptest.NewRequest(http.MethodGet, "/", nil) + req.Header.Set("Authorization", "Bearer secret") + req.Header.Set("Cookie", "session=secret") + req.Header.Set("X-Test", "ok") + rr := httptest.NewRecorder() + + handler.ServeHTTP(rr, req) + + if logs.Len() == 0 { + t.Fatal("expected request log entry") + } + + ctx := logs.All()[0].ContextMap() + headers, ok := ctx["headers"].(map[string]any) + if !ok { + t.Fatalf("expected headers field in log context, got %T", ctx["headers"]) + } + + if got := headers["Authorization"]; !reflect.DeepEqual(got, []any{"REDACTED"}) { + t.Fatalf("expected redacted Authorization header, got %#v", got) + } + if got := headers["Cookie"]; !reflect.DeepEqual(got, []any{"REDACTED"}) { + t.Fatalf("expected redacted Cookie header, got %#v", got) + } + if got := headers["X-Test"]; !reflect.DeepEqual(got, []any{"ok"}) { + t.Fatalf("expected X-Test header to remain visible, got %#v", got) + } +} + func initAdminMetrics() { if adminMetrics.requestErrors != nil { prometheus.Unregister(adminMetrics.requestErrors) diff --git a/go.mod b/go.mod index 0df389b59..8796ad4d8 100644 --- a/go.mod +++ b/go.mod @@ -30,20 +30,20 @@ require ( github.com/tailscale/tscert v0.0.0-20251216020129-aea342f6d747 github.com/yuin/goldmark v1.8.2 github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc - go.opentelemetry.io/contrib/exporters/autoexport v0.68.0 - go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.68.0 - go.opentelemetry.io/contrib/propagators/autoprop v0.68.0 + go.opentelemetry.io/contrib/exporters/autoexport v0.65.0 + go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.65.0 + go.opentelemetry.io/contrib/propagators/autoprop v0.65.0 go.opentelemetry.io/otel v1.43.0 go.opentelemetry.io/otel/sdk v1.43.0 - go.step.sm/crypto v0.77.2 + go.step.sm/crypto v0.77.1 go.uber.org/automaxprocs v1.6.0 go.uber.org/zap v1.27.1 go.uber.org/zap/exp v0.3.0 - golang.org/x/crypto v0.50.0 - golang.org/x/crypto/x509roots/fallback v0.0.0-20260323153451-8400f4a93807 - golang.org/x/net v0.53.0 + golang.org/x/crypto v0.49.0 + golang.org/x/crypto/x509roots/fallback v0.0.0-20260213171211-a408498e5541 + golang.org/x/net v0.52.0 golang.org/x/sync v0.20.0 - golang.org/x/term v0.42.0 + golang.org/x/term v0.41.0 golang.org/x/time v0.15.0 gopkg.in/yaml.v3 v3.0.1 ) @@ -68,7 +68,7 @@ require ( github.com/google/go-tspi v0.3.0 // indirect github.com/google/s2a-go v0.1.9 // indirect github.com/googleapis/enterprise-certificate-proxy v0.3.14 // indirect - github.com/googleapis/gax-go/v2 v2.19.0 // indirect + github.com/googleapis/gax-go/v2 v2.18.0 // indirect github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0 // indirect github.com/jackc/pgx/v5 v5.9.0 // indirect github.com/jackc/puddle/v2 v2.2.2 // indirect @@ -109,9 +109,9 @@ require ( go.yaml.in/yaml/v3 v3.0.4 // indirect golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 // indirect golang.org/x/oauth2 v0.36.0 // indirect - google.golang.org/api v0.272.0 // indirect - google.golang.org/genproto/googleapis/api v0.0.0-20260406210006-6f92a3bedf2d // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20260406210006-6f92a3bedf2d // indirect + google.golang.org/api v0.271.0 // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20260401024825-9d38bb4040a9 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9 // indirect google.golang.org/grpc/cmd/protoc-gen-go-grpc v1.5.1 // indirect ) @@ -168,10 +168,10 @@ require ( go.opentelemetry.io/otel/trace v1.43.0 go.opentelemetry.io/proto/otlp v1.10.0 // indirect go.uber.org/multierr v1.11.0 // indirect - golang.org/x/mod v0.34.0 // indirect - golang.org/x/sys v0.43.0 - golang.org/x/text v0.36.0 - golang.org/x/tools v0.43.0 // indirect + golang.org/x/mod v0.33.0 // indirect + golang.org/x/sys v0.42.0 + golang.org/x/text v0.35.0 + golang.org/x/tools v0.42.0 // indirect google.golang.org/grpc v1.80.0 // indirect google.golang.org/protobuf v1.36.11 // indirect howett.net/plist v1.0.0 // indirect diff --git a/go.sum b/go.sum index 4d6b748fa..48a7d22bd 100644 --- a/go.sum +++ b/go.sum @@ -179,8 +179,8 @@ github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo= github.com/google/go-tpm v0.9.8/go.mod h1:h9jEsEECg7gtLis0upRBQU+GhYVH6jMjrFxI8u6bVUY= -github.com/google/go-tpm-tools v0.4.8 h1:V4oIYyAD3BykOycwYQzO29WefDouQMTsYZqmG3HxOfM= -github.com/google/go-tpm-tools v0.4.8/go.mod h1:4DfiOtiS1KppJjwf1+tqtW4K3PrCJjAAqFKj/TYTJKg= +github.com/google/go-tpm-tools v0.4.7 h1:J3ycC8umYxM9A4eF73EofRZu4BxY0jjQnUnkhIBbvws= +github.com/google/go-tpm-tools v0.4.7/go.mod h1:gSyXTZHe3fgbzb6WEGd90QucmsnT1SRdlye82gH8QjQ= github.com/google/go-tspi v0.3.0 h1:ADtq8RKfP+jrTyIWIZDIYcKOMecRqNJFOew2IT0Inus= github.com/google/go-tspi v0.3.0/go.mod h1:xfMGI3G0PhxCdNVcYr1C4C+EizojDg/TXuX5by8CiHI= github.com/google/s2a-go v0.1.9 h1:LGD7gtMgezd8a/Xak7mEWL0PjoTQFvpRudN895yqKW0= @@ -189,8 +189,8 @@ github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/googleapis/enterprise-certificate-proxy v0.3.14 h1:yh8ncqsbUY4shRD5dA6RlzjJaT4hi3kII+zYw8wmLb8= github.com/googleapis/enterprise-certificate-proxy v0.3.14/go.mod h1:vqVt9yG9480NtzREnTlmGSBmFrA+bzb0yl0TxoBQXOg= -github.com/googleapis/gax-go/v2 v2.19.0 h1:fYQaUOiGwll0cGj7jmHT/0nPlcrZDFPrZRhTsoCr8hE= -github.com/googleapis/gax-go/v2 v2.19.0/go.mod h1:w2ROXVdfGEVFXzmlciUU4EdjHgWvB5h2n6x/8XSTTJA= +github.com/googleapis/gax-go/v2 v2.18.0 h1:jxP5Uuo3bxm3M6gGtV94P4lliVetoCB4Wk2x8QA86LI= +github.com/googleapis/gax-go/v2 v2.18.0/go.mod h1:uSzZN4a356eRG985CzJ3WfbFSpqkLTjsnhWGJR6EwrE= github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0 h1:HWRh5R2+9EifMyIHV7ZV+MIZqgz+PMpZ14Jynv3O2Zs= github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0/go.mod h1:JfhWUomR1baixubs02l85lZYYOm7LV6om4ceouMv45c= github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ= @@ -375,14 +375,14 @@ go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= go.opentelemetry.io/contrib/bridges/prometheus v0.68.0 h1:w3zlHYETbDwXyWHZlyyR58ZC39XGi8rAhkBgUgJ9d5w= go.opentelemetry.io/contrib/bridges/prometheus v0.68.0/go.mod h1:GR/mClR2nn7vE8RLwxKjoBNg+QtgdDhRzxVa93koy5o= -go.opentelemetry.io/contrib/exporters/autoexport v0.68.0 h1:0D3GFvELGIwQGfC6agLsbrEYSGWZTRTxIXxcQUqrOuk= -go.opentelemetry.io/contrib/exporters/autoexport v0.68.0/go.mod h1:DM2NV7Zb8CcGeVPt6glouY0FAiwZQ/iqgcWExhgWeN8= +go.opentelemetry.io/contrib/exporters/autoexport v0.65.0 h1:2gApdml7SznX9szEKFjKjM4qGcGSvAybYLBY319XG3g= +go.opentelemetry.io/contrib/exporters/autoexport v0.65.0/go.mod h1:0QqAGlbHXhmPYACG3n5hNzO5DnEqqtg4VcK5pr22RI0= go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.61.0 h1:q4XOmH/0opmeuJtPsbFNivyl7bCt7yRBbeEm2sC/XtQ= go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.61.0/go.mod h1:snMWehoOh2wsEwnvvwtDyFCxVeDAODenXHtn5vzrKjo= -go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.68.0 h1:CqXxU8VOmDefoh0+ztfGaymYbhdB/tT3zs79QaZTNGY= -go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.68.0/go.mod h1:BuhAPThV8PBHBvg8ZzZ/Ok3idOdhWIodywz2xEcRbJo= -go.opentelemetry.io/contrib/propagators/autoprop v0.68.0 h1:wLGFvNBPqQhzBn0QRBZjrriH8lZ9gqtTz8ufHEjLg7k= -go.opentelemetry.io/contrib/propagators/autoprop v0.68.0/go.mod h1:evWK9nCqCzH8nhclTlpkdUzmxrmJQ2mrWCdKIvyOYec= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.65.0 h1:7iP2uCb7sGddAr30RRS6xjKy7AZ2JtTOPA3oolgVSw8= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.65.0/go.mod h1:c7hN3ddxs/z6q9xwvfLPk+UHlWRQyaeR1LdgfL/66l0= +go.opentelemetry.io/contrib/propagators/autoprop v0.65.0 h1:kTaCycF9Xkm8VBBvH0rJ4wFeRjtIV55Erk3uuVsIs5s= +go.opentelemetry.io/contrib/propagators/autoprop v0.65.0/go.mod h1:rooPzAbXfxMX9fsPJjmOBg2SN4RhFEV8D7cfGK+N3tE= go.opentelemetry.io/contrib/propagators/aws v1.43.0 h1:EwnsB3cXRLAh7/Nr/9rMuGw73nfb3z6uAvVDjRrbeUg= go.opentelemetry.io/contrib/propagators/aws v1.43.0/go.mod h1:CJjTym6F87tEdm61Qvnz5xrV8vKlH4C92djiqcn62k8= go.opentelemetry.io/contrib/propagators/b3 v1.43.0 h1:CETqV3QLLPTy5yNrqyMr41VnAOOD4lsRved7n4QG00A= @@ -431,8 +431,8 @@ go.opentelemetry.io/otel/trace v1.43.0 h1:BkNrHpup+4k4w+ZZ86CZoHHEkohws8AY+WTX09 go.opentelemetry.io/otel/trace v1.43.0/go.mod h1:/QJhyVBUUswCphDVxq+8mld+AvhXZLhe+8WVFxiFff0= go.opentelemetry.io/proto/otlp v1.10.0 h1:IQRWgT5srOCYfiWnpqUYz9CVmbO8bFmKcwYxpuCSL2g= go.opentelemetry.io/proto/otlp v1.10.0/go.mod h1:/CV4QoCR/S9yaPj8utp3lvQPoqMtxXdzn7ozvvozVqk= -go.step.sm/crypto v0.77.2 h1:qFjjei+RHc5kP5R7NW9OUWT7SqWIuAOvOkXqg4fNWj8= -go.step.sm/crypto v0.77.2/go.mod h1:W0YJb9onM5l78qgkXIJ2Up6grnwW8EtpCKIza/NCg0o= +go.step.sm/crypto v0.77.1 h1:4EEqfKdv0egQ1lqz2RhnU8Jv6QgXZfrgoxWMqJF9aDs= +go.step.sm/crypto v0.77.1/go.mod h1:U/SsmEm80mNnfD5WIkbhuW/B1eFp3fgFvdXyDLpU1AQ= go.uber.org/automaxprocs v1.6.0 h1:O3y2/QNTOdbF+e/dpXNNW7Rx2hZ4sTIPyybbxyNqTUs= go.uber.org/automaxprocs v1.6.0/go.mod h1:ifeIMSnPZuznNm6jmdzmU3/bfk01Fe2fotchwEFJ8r8= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= @@ -456,10 +456,10 @@ golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliY golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU= golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8= golang.org/x/crypto v0.33.0/go.mod h1:bVdXmD7IV/4GdElGPozy6U7lWdRXA4qyRVGJV57uQ5M= -golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI= -golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q= -golang.org/x/crypto/x509roots/fallback v0.0.0-20260323153451-8400f4a93807 h1:sQVhWLXbNsa8CTzHOX3IHc7C4Q2JyxI5AweuMQZ/5H0= -golang.org/x/crypto/x509roots/fallback v0.0.0-20260323153451-8400f4a93807/go.mod h1:+UoQFNBq2p2wO+Q6ddVtYc25GZ6VNdOMyyrd4nrqrKs= +golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4= +golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA= +golang.org/x/crypto/x509roots/fallback v0.0.0-20260213171211-a408498e5541 h1:FmKxj9ocLKn45jiR2jQMwCVhDvaK7fKQFzfuT9GvyK8= +golang.org/x/crypto/x509roots/fallback v0.0.0-20260213171211-a408498e5541/go.mod h1:+UoQFNBq2p2wO+Q6ddVtYc25GZ6VNdOMyyrd4nrqrKs= golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 h1:mgKeJMpvi0yx/sU5GsxQ7p6s2wtOnGAHZWCHUM4KGzY= golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546/go.mod h1:j/pmGrbnkbPtQfxEe5D0VQhZC6qKbfKifgD0oM7sR70= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= @@ -467,8 +467,8 @@ golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= -golang.org/x/mod v0.34.0 h1:xIHgNUUnW6sYkcM5Jleh05DvLOtwc6RitGHbDk4akRI= -golang.org/x/mod v0.34.0/go.mod h1:ykgH52iCZe79kzLLMhyCUzhMci+nQj+0XkbXpNYtVjY= +golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8= +golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= @@ -477,8 +477,8 @@ golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk= golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44= golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= -golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA= -golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs= +golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0= +golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw= golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= @@ -506,8 +506,8 @@ golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI= -golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= +golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= @@ -517,8 +517,8 @@ golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU= golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk= golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY= golang.org/x/term v0.29.0/go.mod h1:6bl4lRlvVuDgSf3179VpIxBF0o10JUpXWOnI7nErv7s= -golang.org/x/term v0.42.0 h1:UiKe+zDFmJobeJ5ggPwOshJIVt6/Ft0rcfrXZDLWAWY= -golang.org/x/term v0.42.0/go.mod h1:Dq/D+snpsbazcBG5+F9Q1n2rXV8Ma+71xEjTRufARgY= +golang.org/x/term v0.41.0 h1:QCgPso/Q3RTJx2Th4bDLqML4W6iJiaXFq2/ftQF13YU= +golang.org/x/term v0.41.0/go.mod h1:3pfBgksrReYfZ5lvYM0kSO0LIkAl4Yl2bXOkKP7Ec2A= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= @@ -528,8 +528,8 @@ golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY= -golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg= -golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164= +golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8= +golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA= golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U= golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= @@ -538,19 +538,19 @@ golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58= golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk= -golang.org/x/tools v0.43.0 h1:12BdW9CeB3Z+J/I/wj34VMl8X+fEXBxVR90JeMX5E7s= -golang.org/x/tools v0.43.0/go.mod h1:uHkMso649BX2cZK6+RpuIPXS3ho2hZo4FVwfoy1vIk0= +golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k= +golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E= -google.golang.org/api v0.272.0 h1:eLUQZGnAS3OHn31URRf9sAmRk3w2JjMx37d2k8AjJmA= -google.golang.org/api v0.272.0/go.mod h1:wKjowi5LNJc5qarNvDCvNQBn3rVK8nSy6jg2SwRwzIA= -google.golang.org/genproto v0.0.0-20260316180232-0b37fe3546d5 h1:JNfk58HZ8lfmXbYK2vx/UvsqIL59TzByCxPIX4TDmsE= -google.golang.org/genproto v0.0.0-20260316180232-0b37fe3546d5/go.mod h1:x5julN69+ED4PcFk/XWayw35O0lf/nGa4aNgODCmNmw= -google.golang.org/genproto/googleapis/api v0.0.0-20260406210006-6f92a3bedf2d h1:/aDRtSZJjyLQzm75d+a1wOJaqyKBMvIAfeQmoa3ORiI= -google.golang.org/genproto/googleapis/api v0.0.0-20260406210006-6f92a3bedf2d/go.mod h1:etfGUgejTiadZAUaEP14NP97xi1RGeawqkjDARA/UOs= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260406210006-6f92a3bedf2d h1:wT2n40TBqFY6wiwazVK9/iTWbsQrgk5ZfCSVFLO9LQA= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260406210006-6f92a3bedf2d/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= +google.golang.org/api v0.271.0 h1:cIPN4qcUc61jlh7oXu6pwOQqbJW2GqYh5PS6rB2C/JY= +google.golang.org/api v0.271.0/go.mod h1:CGT29bhwkbF+i11qkRUJb2KMKqcJ1hdFceEIRd9u64Q= +google.golang.org/genproto v0.0.0-20260217215200-42d3e9bedb6d h1:vsOm753cOAMkt76efriTCDKjpCbK18XGHMJHo0JUKhc= +google.golang.org/genproto v0.0.0-20260217215200-42d3e9bedb6d/go.mod h1:0oz9d7g9QLSdv9/lgbIjowW1JoxMbxmBVNe8i6tORJI= +google.golang.org/genproto/googleapis/api v0.0.0-20260401024825-9d38bb4040a9 h1:VPWxll4HlMw1Vs/qXtN7BvhZqsS9cdAittCNvVENElA= +google.golang.org/genproto/googleapis/api v0.0.0-20260401024825-9d38bb4040a9/go.mod h1:7QBABkRtR8z+TEnmXTqIqwJLlzrZKVfAUm7tY3yGv0M= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9 h1:m8qni9SQFH0tJc1X0vmnpw/0t+AImlSvp30sEupozUg= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= google.golang.org/grpc v1.80.0 h1:Xr6m2WmWZLETvUNvIUmeD5OAagMw3FiKmMlTdViWsHM= google.golang.org/grpc v1.80.0/go.mod h1:ho/dLnxwi3EDJA4Zghp7k2Ec1+c2jqup0bFkw07bwF4= google.golang.org/grpc/cmd/protoc-gen-go-grpc v1.5.1 h1:F29+wU6Ee6qgu9TddPgooOdaqsxTMunOoj8KA5yuS5A= diff --git a/internal/logmarshalers.go b/internal/logmarshalers.go new file mode 100644 index 000000000..3a8a553e6 --- /dev/null +++ b/internal/logmarshalers.go @@ -0,0 +1,54 @@ +package internal + +import ( + "net/http" + "strings" + + "go.uber.org/zap/zapcore" +) + +// LoggableHTTPHeader makes an HTTP header loggable with zap.Object(). +// Headers with potentially sensitive information (Cookie, Set-Cookie, +// Authorization, and Proxy-Authorization) are logged with empty values. +type LoggableHTTPHeader struct { + http.Header + + ShouldLogCredentials bool +} + +// MarshalLogObject satisfies the zapcore.ObjectMarshaler interface. +func (h LoggableHTTPHeader) MarshalLogObject(enc zapcore.ObjectEncoder) error { + if h.Header == nil { + return nil + } + for key, val := range h.Header { + if !h.ShouldLogCredentials { + switch strings.ToLower(key) { + case "cookie", "set-cookie", "authorization", "proxy-authorization": + val = []string{"REDACTED"} // see #5669. I still think ▒▒▒▒ would be cool. + } + } + enc.AddArray(key, LoggableStringArray(val)) + } + return nil +} + +// LoggableStringArray makes a slice of strings marshalable for logging. +type LoggableStringArray []string + +// MarshalLogArray satisfies the zapcore.ArrayMarshaler interface. +func (sa LoggableStringArray) MarshalLogArray(enc zapcore.ArrayEncoder) error { + if sa == nil { + return nil + } + for _, s := range sa { + enc.AppendString(s) + } + return nil +} + +// Interface guards +var ( + _ zapcore.ObjectMarshaler = (*LoggableHTTPHeader)(nil) + _ zapcore.ArrayMarshaler = (*LoggableStringArray)(nil) +) diff --git a/modules/caddyhttp/marshalers.go b/modules/caddyhttp/marshalers.go index 2a40b6cd7..15fa3e8bc 100644 --- a/modules/caddyhttp/marshalers.go +++ b/modules/caddyhttp/marshalers.go @@ -18,9 +18,10 @@ import ( "crypto/tls" "net" "net/http" - "strings" "go.uber.org/zap/zapcore" + + "github.com/caddyserver/caddy/v2/internal" ) // LoggableHTTPRequest makes an HTTP request loggable with zap.Object(). @@ -47,12 +48,12 @@ func (r LoggableHTTPRequest) MarshalLogObject(enc zapcore.ObjectEncoder) error { enc.AddString("method", r.Method) enc.AddString("host", r.Host) enc.AddString("uri", r.RequestURI) - enc.AddObject("headers", LoggableHTTPHeader{ + enc.AddObject("headers", internal.LoggableHTTPHeader{ Header: r.Header, ShouldLogCredentials: r.ShouldLogCredentials, }) if r.TransferEncoding != nil { - enc.AddArray("transfer_encoding", LoggableStringArray(r.TransferEncoding)) + enc.AddArray("transfer_encoding", internal.LoggableStringArray(r.TransferEncoding)) } if r.TLS != nil { enc.AddObject("tls", LoggableTLSConnState(*r.TLS)) @@ -61,44 +62,10 @@ func (r LoggableHTTPRequest) MarshalLogObject(enc zapcore.ObjectEncoder) error { } // LoggableHTTPHeader makes an HTTP header loggable with zap.Object(). -// Headers with potentially sensitive information (Cookie, Set-Cookie, -// Authorization, and Proxy-Authorization) are logged with empty values. -type LoggableHTTPHeader struct { - http.Header - - ShouldLogCredentials bool -} - -// MarshalLogObject satisfies the zapcore.ObjectMarshaler interface. -func (h LoggableHTTPHeader) MarshalLogObject(enc zapcore.ObjectEncoder) error { - if h.Header == nil { - return nil - } - for key, val := range h.Header { - if !h.ShouldLogCredentials { - switch strings.ToLower(key) { - case "cookie", "set-cookie", "authorization", "proxy-authorization": - val = []string{"REDACTED"} // see #5669. I still think ▒▒▒▒ would be cool. - } - } - enc.AddArray(key, LoggableStringArray(val)) - } - return nil -} +type LoggableHTTPHeader = internal.LoggableHTTPHeader // LoggableStringArray makes a slice of strings marshalable for logging. -type LoggableStringArray []string - -// MarshalLogArray satisfies the zapcore.ArrayMarshaler interface. -func (sa LoggableStringArray) MarshalLogArray(enc zapcore.ArrayEncoder) error { - if sa == nil { - return nil - } - for _, s := range sa { - enc.AppendString(s) - } - return nil -} +type LoggableStringArray = internal.LoggableStringArray // LoggableTLSConnState makes a TLS connection state loggable with zap.Object(). type LoggableTLSConnState tls.ConnectionState @@ -121,7 +88,5 @@ func (t LoggableTLSConnState) MarshalLogObject(enc zapcore.ObjectEncoder) error // Interface guards var ( _ zapcore.ObjectMarshaler = (*LoggableHTTPRequest)(nil) - _ zapcore.ObjectMarshaler = (*LoggableHTTPHeader)(nil) - _ zapcore.ArrayMarshaler = (*LoggableStringArray)(nil) _ zapcore.ObjectMarshaler = (*LoggableTLSConnState)(nil) ) diff --git a/modules/caddyhttp/reverseproxy/hosts.go b/modules/caddyhttp/reverseproxy/hosts.go index a5406e04e..e58d6825f 100644 --- a/modules/caddyhttp/reverseproxy/hosts.go +++ b/modules/caddyhttp/reverseproxy/hosts.go @@ -174,7 +174,7 @@ func (u *Upstream) fillDynamicHost() { // Host is the basic, in-memory representation of the state of a remote host. // Its fields are accessed atomically and Host values must not be copied. type Host struct { - numRequests atomic.Int64 // atomic.Int64 is automatically aligned for us (see https://golang.org/pkg/sync/atomic/#pkg-note-BUG) + numRequests atomic.Int64 fails atomic.Int64 activePasses atomic.Int64 activeFails atomic.Int64 @@ -250,7 +250,6 @@ func (h *Host) resetHealth() { // (This returns the status only from the "active" health checks.) func (u *Upstream) healthy() bool { return u.unhealthy.Load() == 0 - // return atomic.LoadInt32(&u.unhealthy) == 0 } // SetHealthy sets the upstream has healthy or unhealthy diff --git a/modules/logging/filters.go b/modules/logging/filters.go index 4574b7ca0..087b872e7 100644 --- a/modules/logging/filters.go +++ b/modules/logging/filters.go @@ -29,7 +29,7 @@ import ( "github.com/caddyserver/caddy/v2" "github.com/caddyserver/caddy/v2/caddyconfig/caddyfile" - "github.com/caddyserver/caddy/v2/modules/caddyhttp" + "github.com/caddyserver/caddy/v2/internal" ) func init() { @@ -100,8 +100,8 @@ func (f *HashFilter) UnmarshalCaddyfile(d *caddyfile.Dispenser) error { // Filter filters the input field with the replacement value. func (f *HashFilter) Filter(in zapcore.Field) zapcore.Field { - if array, ok := in.Interface.(caddyhttp.LoggableStringArray); ok { - newArray := make(caddyhttp.LoggableStringArray, len(array)) + if array, ok := in.Interface.(internal.LoggableStringArray); ok { + newArray := make(internal.LoggableStringArray, len(array)) for i, s := range array { newArray[i] = hash(s) } @@ -241,8 +241,8 @@ func (m *IPMaskFilter) Provision(ctx caddy.Context) error { // Filter filters the input field. func (m IPMaskFilter) Filter(in zapcore.Field) zapcore.Field { - if array, ok := in.Interface.(caddyhttp.LoggableStringArray); ok { - newArray := make(caddyhttp.LoggableStringArray, len(array)) + if array, ok := in.Interface.(internal.LoggableStringArray); ok { + newArray := make(internal.LoggableStringArray, len(array)) for i, s := range array { newArray[i] = m.mask(s) } @@ -392,8 +392,8 @@ func (m *QueryFilter) UnmarshalCaddyfile(d *caddyfile.Dispenser) error { // Filter filters the input field. func (m QueryFilter) Filter(in zapcore.Field) zapcore.Field { - if array, ok := in.Interface.(caddyhttp.LoggableStringArray); ok { - newArray := make(caddyhttp.LoggableStringArray, len(array)) + if array, ok := in.Interface.(internal.LoggableStringArray); ok { + newArray := make(internal.LoggableStringArray, len(array)) for i, s := range array { newArray[i] = m.processQueryString(s) } @@ -523,7 +523,7 @@ func (m *CookieFilter) UnmarshalCaddyfile(d *caddyfile.Dispenser) error { // Filter filters the input field. func (m CookieFilter) Filter(in zapcore.Field) zapcore.Field { - cookiesSlice, ok := in.Interface.(caddyhttp.LoggableStringArray) + cookiesSlice, ok := in.Interface.(internal.LoggableStringArray) if !ok { return in } @@ -559,7 +559,7 @@ OUTER: transformedRequest.AddCookie(c) } - in.Interface = caddyhttp.LoggableStringArray(transformedRequest.Header["Cookie"]) + in.Interface = internal.LoggableStringArray(transformedRequest.Header["Cookie"]) return in } @@ -613,8 +613,8 @@ func (m *RegexpFilter) Provision(ctx caddy.Context) error { // Filter filters the input field with the replacement value if it matches the regexp. func (f *RegexpFilter) Filter(in zapcore.Field) zapcore.Field { - if array, ok := in.Interface.(caddyhttp.LoggableStringArray); ok { - newArray := make(caddyhttp.LoggableStringArray, len(array)) + if array, ok := in.Interface.(internal.LoggableStringArray); ok { + newArray := make(internal.LoggableStringArray, len(array)) for i, s := range array { newArray[i] = f.regexp.ReplaceAllString(s, f.Value) } @@ -783,8 +783,8 @@ func (f *MultiRegexpFilter) Validate() error { // Filter applies all regexp operations sequentially to the input field. // Input is sanitized and validated for security. func (f *MultiRegexpFilter) Filter(in zapcore.Field) zapcore.Field { - if array, ok := in.Interface.(caddyhttp.LoggableStringArray); ok { - newArray := make(caddyhttp.LoggableStringArray, len(array)) + if array, ok := in.Interface.(internal.LoggableStringArray); ok { + newArray := make(internal.LoggableStringArray, len(array)) for i, s := range array { newArray[i] = f.processString(s) } diff --git a/modules/logging/filters_test.go b/modules/logging/filters_test.go index cf35e7178..8fbaed6f8 100644 --- a/modules/logging/filters_test.go +++ b/modules/logging/filters_test.go @@ -8,7 +8,7 @@ import ( "go.uber.org/zap/zapcore" "github.com/caddyserver/caddy/v2" - "github.com/caddyserver/caddy/v2/modules/caddyhttp" + "github.com/caddyserver/caddy/v2/internal" ) func TestIPMaskSingleValue(t *testing.T) { @@ -55,11 +55,11 @@ func TestIPMaskMultiValue(t *testing.T) { f := IPMaskFilter{IPv4MaskRaw: 16, IPv6MaskRaw: 32} f.Provision(caddy.Context{}) - out := f.Filter(zapcore.Field{Interface: caddyhttp.LoggableStringArray{ + out := f.Filter(zapcore.Field{Interface: internal.LoggableStringArray{ "255.255.255.255", "244.244.244.244", }}) - arr, ok := out.Interface.(caddyhttp.LoggableStringArray) + arr, ok := out.Interface.(internal.LoggableStringArray) if !ok { t.Fatalf("field is wrong type: %T", out.Integer) } @@ -70,11 +70,11 @@ func TestIPMaskMultiValue(t *testing.T) { t.Fatalf("field entry 1 has not been filtered: %s", arr[1]) } - out = f.Filter(zapcore.Field{Interface: caddyhttp.LoggableStringArray{ + out = f.Filter(zapcore.Field{Interface: internal.LoggableStringArray{ "ffff:ffff:ffff:ffff:ffff:ffff:ffff:ffff", "ff00:ffff:ffff:ffff:ffff:ffff:ffff:ffff", }}) - arr, ok = out.Interface.(caddyhttp.LoggableStringArray) + arr, ok = out.Interface.(internal.LoggableStringArray) if !ok { t.Fatalf("field is wrong type: %T", out.Integer) } @@ -120,11 +120,11 @@ func TestQueryFilterMultiValue(t *testing.T) { t.Fatalf("the filter must be valid") } - out := f.Filter(zapcore.Field{Interface: caddyhttp.LoggableStringArray{ + out := f.Filter(zapcore.Field{Interface: internal.LoggableStringArray{ "/path1?foo=a&foo=b&bar=c&bar=d&baz=e&hash=hashed", "/path2?foo=c&foo=d&bar=e&bar=f&baz=g&hash=hashed", }}) - arr, ok := out.Interface.(caddyhttp.LoggableStringArray) + arr, ok := out.Interface.(internal.LoggableStringArray) if !ok { t.Fatalf("field is wrong type: %T", out.Interface) } @@ -162,11 +162,11 @@ func TestCookieFilter(t *testing.T) { {hashAction, "hash", ""}, }} - out := f.Filter(zapcore.Field{Interface: caddyhttp.LoggableStringArray{ + out := f.Filter(zapcore.Field{Interface: internal.LoggableStringArray{ "foo=a; foo=b; bar=c; bar=d; baz=e; hash=hashed", }}) - outval := out.Interface.(caddyhttp.LoggableStringArray) - expected := caddyhttp.LoggableStringArray{ + outval := out.Interface.(internal.LoggableStringArray) + expected := internal.LoggableStringArray{ "foo=REDACTED; foo=REDACTED; baz=e; hash=1a06df82", } if outval[0] != expected[0] { @@ -204,8 +204,8 @@ func TestRegexpFilterMultiValue(t *testing.T) { f := RegexpFilter{RawRegexp: `secret`, Value: "REDACTED"} f.Provision(caddy.Context{}) - out := f.Filter(zapcore.Field{Interface: caddyhttp.LoggableStringArray{"foo-secret-bar", "bar-secret-foo"}}) - arr, ok := out.Interface.(caddyhttp.LoggableStringArray) + out := f.Filter(zapcore.Field{Interface: internal.LoggableStringArray{"foo-secret-bar", "bar-secret-foo"}}) + arr, ok := out.Interface.(internal.LoggableStringArray) if !ok { t.Fatalf("field is wrong type: %T", out.Integer) } @@ -229,8 +229,8 @@ func TestHashFilterSingleValue(t *testing.T) { func TestHashFilterMultiValue(t *testing.T) { f := HashFilter{} - out := f.Filter(zapcore.Field{Interface: caddyhttp.LoggableStringArray{"foo", "bar"}}) - arr, ok := out.Interface.(caddyhttp.LoggableStringArray) + out := f.Filter(zapcore.Field{Interface: internal.LoggableStringArray{"foo", "bar"}}) + arr, ok := out.Interface.(internal.LoggableStringArray) if !ok { t.Fatalf("field is wrong type: %T", out.Integer) } @@ -292,11 +292,11 @@ func TestMultiRegexpFilterMultiValue(t *testing.T) { t.Fatalf("unexpected error provisioning: %v", err) } - out := f.Filter(zapcore.Field{Interface: caddyhttp.LoggableStringArray{ + out := f.Filter(zapcore.Field{Interface: internal.LoggableStringArray{ "foo-secret-123", "bar-secret-456", }}) - arr, ok := out.Interface.(caddyhttp.LoggableStringArray) + arr, ok := out.Interface.(internal.LoggableStringArray) if !ok { t.Fatalf("field is wrong type: %T", out.Interface) } From aed1af59763d54520a9c72f1fd0222d43904ebfd Mon Sep 17 00:00:00 2001 From: Daniil Sivak Date: Tue, 21 Apr 2026 21:59:31 +0300 Subject: [PATCH 06/13] reverseproxy: add `lb_retry_match` condition on response status (#7569) --- ...se_proxy_retry_match_oneline.caddyfiletest | 58 +++ ...e_proxy_retry_match_response.caddyfiletest | 147 ++++++ caddytest/integration/reverseproxy_test.go | 231 +++++++++ modules/caddyhttp/matchers.go | 8 + modules/caddyhttp/reverseproxy/caddyfile.go | 2 +- .../caddyhttp/reverseproxy/retries_test.go | 475 ++++++++++++++++++ .../caddyhttp/reverseproxy/reverseproxy.go | 116 ++++- replacer.go | 12 + 8 files changed, 1029 insertions(+), 20 deletions(-) create mode 100644 caddytest/integration/caddyfile_adapt/reverse_proxy_retry_match_oneline.caddyfiletest create mode 100644 caddytest/integration/caddyfile_adapt/reverse_proxy_retry_match_response.caddyfiletest diff --git a/caddytest/integration/caddyfile_adapt/reverse_proxy_retry_match_oneline.caddyfiletest b/caddytest/integration/caddyfile_adapt/reverse_proxy_retry_match_oneline.caddyfiletest new file mode 100644 index 000000000..8faed5220 --- /dev/null +++ b/caddytest/integration/caddyfile_adapt/reverse_proxy_retry_match_oneline.caddyfiletest @@ -0,0 +1,58 @@ +:8884 + +reverse_proxy 127.0.0.1:65535 { + lb_retries 3 + lb_retry_match expression `{rp.status_code} in [502, 503]` + lb_retry_match expression `{rp.is_transport_error} || {rp.status_code} == 502` + lb_retry_match expression `method('POST') && {rp.status_code} == 503` + lb_retry_match `{rp.status_code} == 504` + lb_retry_match `{rp.is_transport_error} && method('PUT')` +} +---------- +{ + "apps": { + "http": { + "servers": { + "srv0": { + "listen": [ + ":8884" + ], + "routes": [ + { + "handle": [ + { + "handler": "reverse_proxy", + "load_balancing": { + "retries": 3, + "retry_match": [ + { + "expression": "{http.reverse_proxy.status_code} in [502, 503]" + }, + { + "expression": "{http.reverse_proxy.is_transport_error} || {http.reverse_proxy.status_code} == 502" + }, + { + "expression": "method('POST') \u0026\u0026 {http.reverse_proxy.status_code} == 503" + }, + { + "expression": "{http.reverse_proxy.status_code} == 504" + }, + { + "expression": "{http.reverse_proxy.is_transport_error} \u0026\u0026 method('PUT')" + } + ] + }, + "upstreams": [ + { + "dial": "127.0.0.1:65535" + } + ] + } + ] + } + ] + } + } + } + } +} diff --git a/caddytest/integration/caddyfile_adapt/reverse_proxy_retry_match_response.caddyfiletest b/caddytest/integration/caddyfile_adapt/reverse_proxy_retry_match_response.caddyfiletest new file mode 100644 index 000000000..d5a1a9a40 --- /dev/null +++ b/caddytest/integration/caddyfile_adapt/reverse_proxy_retry_match_response.caddyfiletest @@ -0,0 +1,147 @@ +:8884 + +reverse_proxy 127.0.0.1:65535 { + lb_retries 5 + + # request matchers (backward-compatible, non-expression) + lb_retry_match { + method POST PUT + } + lb_retry_match { + path /foo* + } + lb_retry_match { + header X-Idempotency-Key * + } + + # response status code via expression + lb_retry_match { + expression `{rp.status_code} in [502, 503, 504]` + } + + # response header via expression + lb_retry_match { + expression `{rp.header.X-Retry} == "true"` + } + + # CEL request functions combined with response placeholders + lb_retry_match { + expression `method('POST') && {rp.status_code} >= 500` + } + lb_retry_match { + expression `path('/api*') && {rp.status_code} in [502, 503]` + } + lb_retry_match { + expression `host('example.com') && {rp.status_code} == 503` + } + lb_retry_match { + expression `query({'retry': 'true'}) && {rp.status_code} >= 500` + } + lb_retry_match { + expression `header({'X-Idempotency-Key': '*'}) && {rp.status_code} in [502, 503]` + } + lb_retry_match { + expression `protocol('https') && {rp.status_code} == 502` + } + lb_retry_match { + expression `path_regexp('^/api/v[0-9]+/') && {rp.status_code} >= 500` + } + lb_retry_match { + expression `header_regexp('Content-Type', '^application/json') && {rp.status_code} == 502` + } + + # transport error handling via placeholder + lb_retry_match { + expression `{rp.is_transport_error} || {rp.status_code} in [502, 503]` + } + lb_retry_match { + expression `{rp.is_transport_error} && method('POST')` + } +} +---------- +{ + "apps": { + "http": { + "servers": { + "srv0": { + "listen": [ + ":8884" + ], + "routes": [ + { + "handle": [ + { + "handler": "reverse_proxy", + "load_balancing": { + "retries": 5, + "retry_match": [ + { + "method": [ + "POST", + "PUT" + ] + }, + { + "path": [ + "/foo*" + ] + }, + { + "header": { + "X-Idempotency-Key": [ + "*" + ] + } + }, + { + "expression": "{http.reverse_proxy.status_code} in [502, 503, 504]" + }, + { + "expression": "{http.reverse_proxy.header.X-Retry} == \"true\"" + }, + { + "expression": "method('POST') \u0026\u0026 {http.reverse_proxy.status_code} \u003e= 500" + }, + { + "expression": "path('/api*') \u0026\u0026 {http.reverse_proxy.status_code} in [502, 503]" + }, + { + "expression": "host('example.com') \u0026\u0026 {http.reverse_proxy.status_code} == 503" + }, + { + "expression": "query({'retry': 'true'}) \u0026\u0026 {http.reverse_proxy.status_code} \u003e= 500" + }, + { + "expression": "header({'X-Idempotency-Key': '*'}) \u0026\u0026 {http.reverse_proxy.status_code} in [502, 503]" + }, + { + "expression": "protocol('https') \u0026\u0026 {http.reverse_proxy.status_code} == 502" + }, + { + "expression": "path_regexp('^/api/v[0-9]+/') \u0026\u0026 {http.reverse_proxy.status_code} \u003e= 500" + }, + { + "expression": "header_regexp('Content-Type', '^application/json') \u0026\u0026 {http.reverse_proxy.status_code} == 502" + }, + { + "expression": "{http.reverse_proxy.is_transport_error} || {http.reverse_proxy.status_code} in [502, 503]" + }, + { + "expression": "{http.reverse_proxy.is_transport_error} \u0026\u0026 method('POST')" + } + ] + }, + "upstreams": [ + { + "dial": "127.0.0.1:65535" + } + ] + } + ] + } + ] + } + } + } + } +} diff --git a/caddytest/integration/reverseproxy_test.go b/caddytest/integration/reverseproxy_test.go index 6e0b3dcff..cbccfd74f 100644 --- a/caddytest/integration/reverseproxy_test.go +++ b/caddytest/integration/reverseproxy_test.go @@ -7,6 +7,7 @@ import ( "os" "runtime" "strings" + "sync/atomic" "testing" "github.com/caddyserver/caddy/v2/caddytest" @@ -562,3 +563,233 @@ func TestReverseProxyHealthCheckUnixSocketWithoutPort(t *testing.T) { tester.AssertGetResponse("http://localhost:9080/", 200, "Hello, World!") } + +// TestReverseProxyRetryMatchStatusCode verifies that lb_retry_match with a +// CEL expression matching on {rp.status_code} causes the request to be +// retried on the next upstream when the first upstream returns a matching +// status code +func TestReverseProxyRetryMatchStatusCode(t *testing.T) { + // Bad upstream: returns 502 + badSrv := &http.Server{ + Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusBadGateway) + }), + } + badLn, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("failed to listen: %v", err) + } + go badSrv.Serve(badLn) + t.Cleanup(func() { badSrv.Close(); badLn.Close() }) + + // Good upstream: returns 200 + goodSrv := &http.Server{ + Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Write([]byte("ok")) + }), + } + goodLn, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("failed to listen: %v", err) + } + go goodSrv.Serve(goodLn) + t.Cleanup(func() { goodSrv.Close(); goodLn.Close() }) + + tester := caddytest.NewTester(t) + tester.InitServer(fmt.Sprintf(` + { + skip_install_trust + admin localhost:2999 + http_port 9080 + https_port 9443 + grace_period 1ns + } + http://localhost:9080 { + reverse_proxy %s %s { + lb_policy round_robin + lb_retries 1 + lb_retry_match { + expression `+"`{rp.status_code} in [502, 503]`"+` + } + } + } + `, goodLn.Addr().String(), badLn.Addr().String()), "caddyfile") + + tester.AssertGetResponse("http://localhost:9080/", 200, "ok") +} + +// TestReverseProxyRetryMatchHeader verifies that lb_retry_match with a CEL +// expression matching on {rp.header.*} causes the request to be retried when +// the upstream sets a matching response header +func TestReverseProxyRetryMatchHeader(t *testing.T) { + var badHits atomic.Int32 + + // Bad upstream: returns 200 but signals retry via header + badSrv := &http.Server{ + Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + badHits.Add(1) + w.Header().Set("X-Upstream-Retry", "true") + w.Write([]byte("bad")) + }), + } + badLn, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("failed to listen: %v", err) + } + go badSrv.Serve(badLn) + t.Cleanup(func() { badSrv.Close(); badLn.Close() }) + + // Good upstream: returns 200 without retry header + goodSrv := &http.Server{ + Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Write([]byte("good")) + }), + } + goodLn, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("failed to listen: %v", err) + } + go goodSrv.Serve(goodLn) + t.Cleanup(func() { goodSrv.Close(); goodLn.Close() }) + + tester := caddytest.NewTester(t) + tester.InitServer(fmt.Sprintf(` + { + skip_install_trust + admin localhost:2999 + http_port 9080 + https_port 9443 + grace_period 1ns + } + http://localhost:9080 { + reverse_proxy %s %s { + lb_policy round_robin + lb_retries 1 + lb_retry_match { + expression `+"`{rp.header.X-Upstream-Retry} == \"true\"`"+` + } + } + } + `, goodLn.Addr().String(), badLn.Addr().String()), "caddyfile") + + tester.AssertGetResponse("http://localhost:9080/", 200, "good") + + if badHits.Load() != 1 { + t.Errorf("bad upstream hits: got %d, want 1", badHits.Load()) + } +} + +// TestReverseProxyRetryMatchCombined verifies that a CEL expression combining +// request path matching with response status code matching works correctly - +// only retrying when both conditions are met +func TestReverseProxyRetryMatchCombined(t *testing.T) { + // Upstream: returns 502 for all requests + srv := &http.Server{ + Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusBadGateway) + }), + } + ln, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("failed to listen: %v", err) + } + go srv.Serve(ln) + t.Cleanup(func() { srv.Close(); ln.Close() }) + + // Good upstream + goodSrv := &http.Server{ + Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Write([]byte("ok")) + }), + } + goodLn, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("failed to listen: %v", err) + } + go goodSrv.Serve(goodLn) + t.Cleanup(func() { goodSrv.Close(); goodLn.Close() }) + + tester := caddytest.NewTester(t) + tester.InitServer(fmt.Sprintf(` + { + skip_install_trust + admin localhost:2999 + http_port 9080 + https_port 9443 + grace_period 1ns + } + http://localhost:9080 { + reverse_proxy %s %s { + lb_policy round_robin + lb_retries 1 + lb_retry_match { + expression `+"`path('/retry*') && {rp.status_code} in [502, 503]`"+` + } + } + } + `, goodLn.Addr().String(), ln.Addr().String()), "caddyfile") + + // /retry path matches the expression - should retry to good upstream + tester.AssertGetResponse("http://localhost:9080/retry", 200, "ok") + + // /other path does NOT match - should return the 502 + req, _ := http.NewRequest(http.MethodGet, "http://localhost:9080/other", nil) + tester.AssertResponse(req, 502, "") +} + +// TestReverseProxyRetryMatchIsTransportError verifies that the +// {rp.is_transport_error} == true CEL function correctly identifies transport errors +// and allows retrying them alongside response-based matching +func TestReverseProxyRetryMatchIsTransportError(t *testing.T) { + // Good upstream: returns 200 + goodSrv := &http.Server{ + Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Write([]byte("ok")) + }), + } + goodLn, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("failed to listen: %v", err) + } + go goodSrv.Serve(goodLn) + t.Cleanup(func() { goodSrv.Close(); goodLn.Close() }) + + // Broken upstream: accepts connections but closes immediately + brokenLn, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("failed to listen: %v", err) + } + t.Cleanup(func() { brokenLn.Close() }) + go func() { + for { + conn, err := brokenLn.Accept() + if err != nil { + return + } + conn.Close() + } + }() + + tester := caddytest.NewTester(t) + tester.InitServer(fmt.Sprintf(` + { + skip_install_trust + admin localhost:2999 + http_port 9080 + https_port 9443 + grace_period 1ns + } + http://localhost:9080 { + reverse_proxy %s %s { + lb_policy round_robin + lb_retries 1 + lb_retry_match { + expression `+"`{rp.is_transport_error} || {rp.status_code} in [502, 503]`"+` + } + } + } + `, goodLn.Addr().String(), brokenLn.Addr().String()), "caddyfile") + + // Transport error on broken upstream should be retried to good upstream + tester.AssertGetResponse("http://localhost:9080/", 200, "ok") +} diff --git a/modules/caddyhttp/matchers.go b/modules/caddyhttp/matchers.go index 27e5c5ae6..f179b9c11 100644 --- a/modules/caddyhttp/matchers.go +++ b/modules/caddyhttp/matchers.go @@ -1562,6 +1562,14 @@ func ParseCaddyfileNestedMatcherSet(d *caddyfile.Dispenser) (caddy.ModuleMap, er // instances of the matcher in this set tokensByMatcherName := make(map[string][]caddyfile.Token) for nesting := d.Nesting(); d.NextArg() || d.NextBlock(nesting); { + // if the token is quoted (backtick), treat it as a shorthand + // for an expression matcher, same as @named matcher parsing + if d.Token().Quoted() { + expressionToken := d.Token().Clone() + expressionToken.Text = "expression" + tokensByMatcherName["expression"] = append(tokensByMatcherName["expression"], expressionToken, d.Token()) + continue + } matcherName := d.Val() tokensByMatcherName[matcherName] = append(tokensByMatcherName[matcherName], d.NextSegment()...) } diff --git a/modules/caddyhttp/reverseproxy/caddyfile.go b/modules/caddyhttp/reverseproxy/caddyfile.go index a370a2873..8716babe3 100644 --- a/modules/caddyhttp/reverseproxy/caddyfile.go +++ b/modules/caddyhttp/reverseproxy/caddyfile.go @@ -67,7 +67,7 @@ func parseCaddyfile(h httpcaddyfile.Helper) (caddyhttp.MiddlewareHandler, error) // lb_retries // lb_try_duration // lb_try_interval -// lb_retry_match +// lb_retry_match // // # active health checking // health_uri diff --git a/modules/caddyhttp/reverseproxy/retries_test.go b/modules/caddyhttp/reverseproxy/retries_test.go index 056223d4c..b0f78bac0 100644 --- a/modules/caddyhttp/reverseproxy/retries_test.go +++ b/modules/caddyhttp/reverseproxy/retries_test.go @@ -1,6 +1,7 @@ package reverseproxy import ( + "context" "errors" "io" "net" @@ -8,11 +9,13 @@ import ( "net/http/httptest" "strings" "sync" + "sync/atomic" "testing" "go.uber.org/zap" "github.com/caddyserver/caddy/v2" + "github.com/caddyserver/caddy/v2/caddyconfig/caddyfile" "github.com/caddyserver/caddy/v2/modules/caddyhttp" ) @@ -255,3 +258,475 @@ func TestDialErrorBodyRetry(t *testing.T) { }) } } + +// newExpressionMatcher provisions a MatchExpression for use in tests +func newExpressionMatcher(t *testing.T, expr string) *caddyhttp.MatchExpression { + t.Helper() + ctx, cancel := caddy.NewContext(caddy.Context{Context: context.Background()}) + t.Cleanup(cancel) + m := &caddyhttp.MatchExpression{Expr: expr} + if err := m.Provision(ctx); err != nil { + t.Fatalf("failed to provision expression %q: %v", expr, err) + } + return m +} + +// minimalHandlerWithRetryMatch is like minimalHandler but also configures +// RetryMatch so that response-based retry can be tested +func minimalHandlerWithRetryMatch(retries int, retryMatch caddyhttp.MatcherSets, upstreams ...*Upstream) *Handler { + h := minimalHandler(retries, upstreams...) + h.LoadBalancing.RetryMatch = retryMatch + return h +} + +// TestResponseRetryStatusCode verifies that when an upstream returns a status +// code matching a retry_match expression, the request is retried on the next +// upstream +func TestResponseRetryStatusCode(t *testing.T) { + // Bad upstream: returns 502 + badServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusBadGateway) + })) + t.Cleanup(badServer.Close) + + // Good upstream: returns 200 + goodServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + w.Write([]byte("ok")) + })) + t.Cleanup(goodServer.Close) + + retryMatch := caddyhttp.MatcherSets{ + caddyhttp.MatcherSet{ + newExpressionMatcher(t, "{http.reverse_proxy.status_code} in [502, 503]"), + }, + } + + // RoundRobin picks index 1 first, then 0 + upstreams := []*Upstream{ + {Host: new(Host), Dial: goodServer.Listener.Addr().String()}, + {Host: new(Host), Dial: badServer.Listener.Addr().String()}, + } + + h := minimalHandlerWithRetryMatch(1, retryMatch, upstreams...) + + req := httptest.NewRequest(http.MethodGet, "http://example.com/", nil) + req = prepareTestRequest(req) + rec := httptest.NewRecorder() + + err := h.ServeHTTP(rec, req, caddyhttp.HandlerFunc(func(w http.ResponseWriter, r *http.Request) error { + return nil + })) + + gotStatus := rec.Code + if err != nil { + if herr, ok := err.(caddyhttp.HandlerError); ok { + gotStatus = herr.StatusCode + } + } + + if gotStatus != http.StatusOK { + t.Errorf("status: got %d, want %d (err=%v)", gotStatus, http.StatusOK, err) + } +} + +// TestResponseRetryHeader verifies that response header matching triggers +// retries via a CEL expression checking {rp.header.*} +func TestResponseRetryHeader(t *testing.T) { + // Bad upstream: returns 200 but with X-Upstream-Retry header + badServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("X-Upstream-Retry", "true") + w.WriteHeader(http.StatusOK) + w.Write([]byte("bad")) + })) + t.Cleanup(badServer.Close) + + // Good upstream: returns 200 without retry header + goodServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + w.Write([]byte("good")) + })) + t.Cleanup(goodServer.Close) + + retryMatch := caddyhttp.MatcherSets{ + caddyhttp.MatcherSet{ + newExpressionMatcher(t, `{http.reverse_proxy.header.X-Upstream-Retry} == "true"`), + }, + } + + // RoundRobin picks index 1 first, then 0 + upstreams := []*Upstream{ + {Host: new(Host), Dial: goodServer.Listener.Addr().String()}, + {Host: new(Host), Dial: badServer.Listener.Addr().String()}, + } + + h := minimalHandlerWithRetryMatch(1, retryMatch, upstreams...) + + req := httptest.NewRequest(http.MethodGet, "http://example.com/", nil) + req = prepareTestRequest(req) + rec := httptest.NewRecorder() + + err := h.ServeHTTP(rec, req, caddyhttp.HandlerFunc(func(w http.ResponseWriter, r *http.Request) error { + return nil + })) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if rec.Code != http.StatusOK { + t.Errorf("status: got %d, want %d", rec.Code, http.StatusOK) + } + if rec.Body.String() != "good" { + t.Errorf("body: got %q, want %q (retried to wrong upstream)", rec.Body.String(), "good") + } +} + +// TestResponseRetryNoMatchNoRetry verifies that when no retry_match entries +// match the response, the original response is returned without retrying +func TestResponseRetryNoMatchNoRetry(t *testing.T) { + var hits atomic.Int32 + + // Server that returns 500 - but retry_match only matches 502/503 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + hits.Add(1) + w.WriteHeader(http.StatusInternalServerError) + })) + t.Cleanup(server.Close) + + retryMatch := caddyhttp.MatcherSets{ + caddyhttp.MatcherSet{ + newExpressionMatcher(t, "{http.reverse_proxy.status_code} in [502, 503]"), + }, + } + + upstreams := []*Upstream{ + {Host: new(Host), Dial: server.Listener.Addr().String()}, + {Host: new(Host), Dial: server.Listener.Addr().String()}, + } + + h := minimalHandlerWithRetryMatch(2, retryMatch, upstreams...) + + req := httptest.NewRequest(http.MethodGet, "http://example.com/", nil) + req = prepareTestRequest(req) + rec := httptest.NewRecorder() + + _ = h.ServeHTTP(rec, req, caddyhttp.HandlerFunc(func(w http.ResponseWriter, r *http.Request) error { + return nil + })) + + // Only one hit - no retry since 500 doesn't match [502, 503] + if hits.Load() != 1 { + t.Errorf("upstream hits: got %d, want 1 (should not have retried)", hits.Load()) + } +} + +// TestResponseRetryExhaustedPreservesStatusCode verifies that when retries +// are exhausted, the actual upstream status code (e.g. 503) is reported +// to the client, not a generic 502 +func TestResponseRetryExhaustedPreservesStatusCode(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusServiceUnavailable) // 503 + })) + t.Cleanup(server.Close) + + retryMatch := caddyhttp.MatcherSets{ + caddyhttp.MatcherSet{ + newExpressionMatcher(t, "{http.reverse_proxy.status_code} == 503"), + }, + } + + upstreams := []*Upstream{ + {Host: new(Host), Dial: server.Listener.Addr().String()}, + {Host: new(Host), Dial: server.Listener.Addr().String()}, + } + + h := minimalHandlerWithRetryMatch(1, retryMatch, upstreams...) + + req := httptest.NewRequest(http.MethodGet, "http://example.com/", nil) + req = prepareTestRequest(req) + rec := httptest.NewRecorder() + + err := h.ServeHTTP(rec, req, caddyhttp.HandlerFunc(func(w http.ResponseWriter, r *http.Request) error { + return nil + })) + + gotStatus := rec.Code + if err != nil { + if herr, ok := err.(caddyhttp.HandlerError); ok { + gotStatus = herr.StatusCode + } + } + + // Must return 503 (actual upstream status), not 502 (generic proxy error) + if gotStatus != http.StatusServiceUnavailable { + t.Errorf("status: got %d, want %d (status code not preserved)", gotStatus, http.StatusServiceUnavailable) + } +} + +// TestResponseRetryHeaderCleanup verifies that stale response header +// placeholders from a previous upstream attempt are cleaned up before the +// next retry evaluation. Without cleanup, a header like X-Retry: true from +// upstream A would leak into the retry match for upstream B even if B does +// not set that header +func TestResponseRetryHeaderCleanup(t *testing.T) { + // First upstream: returns 200 with X-Retry header (triggers retry) + firstServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("X-Retry", "true") + w.WriteHeader(http.StatusOK) + w.Write([]byte("first")) + })) + t.Cleanup(firstServer.Close) + + // Second upstream: returns 200 WITHOUT X-Retry header (should NOT retry) + secondServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + w.Write([]byte("second")) + })) + t.Cleanup(secondServer.Close) + + retryMatch := caddyhttp.MatcherSets{ + caddyhttp.MatcherSet{ + newExpressionMatcher(t, `{http.reverse_proxy.header.X-Retry} == "true"`), + }, + } + + // RoundRobin picks index 1 first, then 0 + upstreams := []*Upstream{ + {Host: new(Host), Dial: secondServer.Listener.Addr().String()}, + {Host: new(Host), Dial: firstServer.Listener.Addr().String()}, + } + + h := minimalHandlerWithRetryMatch(2, retryMatch, upstreams...) + + req := httptest.NewRequest(http.MethodGet, "http://example.com/", nil) + req = prepareTestRequest(req) + rec := httptest.NewRecorder() + + err := h.ServeHTTP(rec, req, caddyhttp.HandlerFunc(func(w http.ResponseWriter, r *http.Request) error { + return nil + })) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + // Should get "second" - the first upstream's X-Retry header must not + // leak into the second upstream's retry evaluation + if rec.Body.String() != "second" { + t.Errorf("body: got %q, want %q (stale header leaked between retries)", rec.Body.String(), "second") + } +} + +// TestRequestOnlyMatcherDoesNotRetryResponses verifies that a pure request +// matcher like method PUT in lb_retry_match does NOT trigger response-based +// retries. Only expression matchers (which can reference response data) +// should trigger response retries +func TestRequestOnlyMatcherDoesNotRetryResponses(t *testing.T) { + var hits atomic.Int32 + + // Server returns 200 OK for all requests + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + hits.Add(1) + w.WriteHeader(http.StatusOK) + w.Write([]byte("ok")) + })) + t.Cleanup(server.Close) + + // method PUT matcher - should NOT trigger response retries + retryMatch := caddyhttp.MatcherSets{ + caddyhttp.MatcherSet{ + caddyhttp.MatchMethod{"PUT"}, + }, + } + + upstreams := []*Upstream{ + {Host: new(Host), Dial: server.Listener.Addr().String()}, + {Host: new(Host), Dial: server.Listener.Addr().String()}, + } + + h := minimalHandlerWithRetryMatch(2, retryMatch, upstreams...) + + req := httptest.NewRequest(http.MethodPut, "http://example.com/", nil) + req = prepareTestRequest(req) + rec := httptest.NewRecorder() + + err := h.ServeHTTP(rec, req, caddyhttp.HandlerFunc(func(w http.ResponseWriter, r *http.Request) error { + return nil + })) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + // Should hit only once - no retry for 200 OK even though method matches + if hits.Load() != 1 { + t.Errorf("upstream hits: got %d, want 1 (should not retry successful responses)", hits.Load()) + } + if rec.Code != http.StatusOK { + t.Errorf("status: got %d, want %d", rec.Code, http.StatusOK) + } +} + +// brokenUpstreamAddr returns the address of a TCP listener that accepts +// connections but immediately closes them, causing a transport error (not +// a dial error). This simulates an upstream that is reachable but broken +func brokenUpstreamAddr(t *testing.T) string { + t.Helper() + ln, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("failed to listen: %v", err) + } + t.Cleanup(func() { ln.Close() }) + go func() { + for { + conn, err := ln.Accept() + if err != nil { + return + } + conn.Close() + } + }() + return ln.Addr().String() +} + +// TestTransportErrorPlaceholder verifies that the is_transport_error +// placeholder is set to true during transport error evaluation in tryAgain() +// and that expression matchers using {rp.is_transport_error} can match it +func TestTransportErrorPlaceholder(t *testing.T) { + broken := brokenUpstreamAddr(t) + + goodServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + w.Write([]byte("ok")) + })) + t.Cleanup(goodServer.Close) + + retryMatch := caddyhttp.MatcherSets{ + caddyhttp.MatcherSet{ + newExpressionMatcher(t, "{http.reverse_proxy.is_transport_error} == true"), + }, + } + + // RoundRobin picks index 1 first (broken), then 0 (good) + upstreams := []*Upstream{ + {Host: new(Host), Dial: goodServer.Listener.Addr().String()}, + {Host: new(Host), Dial: broken}, + } + + h := minimalHandlerWithRetryMatch(1, retryMatch, upstreams...) + + req := httptest.NewRequest(http.MethodPost, "http://example.com/", nil) + req = prepareTestRequest(req) + rec := httptest.NewRecorder() + + err := h.ServeHTTP(rec, req, caddyhttp.HandlerFunc(func(w http.ResponseWriter, r *http.Request) error { + return nil + })) + + gotStatus := rec.Code + if err != nil { + if herr, ok := err.(caddyhttp.HandlerError); ok { + gotStatus = herr.StatusCode + } + } + + // POST transport error should be retried because is_transport_error matched + if gotStatus != http.StatusOK { + t.Errorf("status: got %d, want %d (transport error should have been retried)", gotStatus, http.StatusOK) + } +} + +// TestTransportErrorPlaceholderNotSetForResponses verifies that the +// is_transport_error placeholder is NOT set when evaluating response +// matchers, so {rp.is_transport_error} is false for response retries +func TestTransportErrorPlaceholderNotSetForResponses(t *testing.T) { + var hits atomic.Int32 + + // Server returns 502 - but the matcher only checks is_transport_error + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + hits.Add(1) + w.WriteHeader(http.StatusBadGateway) + })) + t.Cleanup(server.Close) + + // Only matches transport errors, not response errors + retryMatch := caddyhttp.MatcherSets{ + caddyhttp.MatcherSet{ + newExpressionMatcher(t, "{http.reverse_proxy.is_transport_error} == true"), + }, + } + + upstreams := []*Upstream{ + {Host: new(Host), Dial: server.Listener.Addr().String()}, + {Host: new(Host), Dial: server.Listener.Addr().String()}, + } + + h := minimalHandlerWithRetryMatch(2, retryMatch, upstreams...) + + req := httptest.NewRequest(http.MethodGet, "http://example.com/", nil) + req = prepareTestRequest(req) + rec := httptest.NewRecorder() + + _ = h.ServeHTTP(rec, req, caddyhttp.HandlerFunc(func(w http.ResponseWriter, r *http.Request) error { + return nil + })) + + // Should hit only once - is_transport_error is false during response + // evaluation so the 502 is NOT retried + if hits.Load() != 1 { + t.Errorf("upstream hits: got %d, want 1 (is_transport_error should be false for responses)", hits.Load()) + } +} + +// TestRetryMatchAllowsExpressionMixedWithOtherMatchers verifies that +// lb_retry_match accepts a block mixing expression with other matchers +func TestRetryMatchAllowsExpressionMixedWithOtherMatchers(t *testing.T) { + tests := []struct { + name string + input string + }{ + { + name: "expression alone", + input: `reverse_proxy localhost:9080 { + lb_retry_match { + expression ` + "`{rp.status_code} in [502, 503]`" + ` + } + }`, + }, + { + name: "method alone", + input: `reverse_proxy localhost:9080 { + lb_retry_match { + method PUT + } + }`, + }, + { + name: "expression mixed with method", + input: `reverse_proxy localhost:9080 { + lb_retry_match { + method POST + expression ` + "`{rp.status_code} in [502, 503]`" + ` + } + }`, + }, + { + name: "expression mixed with path", + input: `reverse_proxy localhost:9080 { + lb_retry_match { + path /api* + expression ` + "`{rp.status_code} == 502`" + ` + } + }`, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + h := &Handler{} + d := caddyfile.NewTestDispenser(tc.input) + err := h.UnmarshalCaddyfile(d) + if err != nil { + t.Errorf("unexpected error: %v", err) + } + }) + } +} diff --git a/modules/caddyhttp/reverseproxy/reverseproxy.go b/modules/caddyhttp/reverseproxy/reverseproxy.go index 3b9b56a05..52d2b1ab3 100644 --- a/modules/caddyhttp/reverseproxy/reverseproxy.go +++ b/modules/caddyhttp/reverseproxy/reverseproxy.go @@ -670,8 +670,12 @@ func (h *Handler) proxyLoopIteration(r *http.Request, origReq *http.Request, w h return true, succ.error } - // remember this failure (if enabled) - h.countFailure(upstream) + // remember this failure (if enabled); response-based retries + // are not counted as failures since the upstream did respond + // successfully - only the response content triggered a retry + if _, isRetryableResponse := proxyErr.(retryableResponseError); !isRetryableResponse { + h.countFailure(upstream) + } // if we've tried long enough, break if !h.LoadBalancing.tryAgain(h.ctx, start, retries, proxyErr, r, h.logger) { @@ -1055,6 +1059,45 @@ func (h *Handler) reverseProxy(rw http.ResponseWriter, req *http.Request, origRe res.Body, _ = h.bufferedBody(res.Body, h.ResponseBuffers) } + // set response placeholders so they can be used in retry match + // expressions and handle_response routes; clear stale header + // placeholders from a previous attempt first so they don't + // leak into the next retry evaluation + repl.DeleteByPrefix("http.reverse_proxy.header.") + for field, value := range res.Header { + repl.Set("http.reverse_proxy.header."+field, strings.Join(value, ",")) + } + repl.Set("http.reverse_proxy.status_code", res.StatusCode) + repl.Set("http.reverse_proxy.status_text", res.Status) + + // check if the response matches a retry match entry; if so, + // close the body and return a retryable error so the request + // is retried with the next upstream. Only evaluate matcher sets + // that contain at least one expression matcher, since those are + // the ones that can reference response data ({rp.status_code}, + // {rp.header.*}). Pure request-only matchers (method, path, etc.) + // are skipped to avoid retrying every response that matches a + // request condition + if h.LoadBalancing != nil && len(h.LoadBalancing.RetryMatch) > 0 { + for _, matcherSet := range h.LoadBalancing.RetryMatch { + if !matcherSetHasExpressionMatcher(matcherSet) { + continue + } + match, err := matcherSet.MatchWithError(req) + if err != nil { + h.logger.Error("error matching request for retry", zap.Error(err)) + break + } + if match { + res.Body.Close() + return retryableResponseError{ + error: fmt.Errorf("upstream response matched retry_match (status %d)", res.StatusCode), + statusCode: res.StatusCode, + } + } + } + } + // see if any response handler is configured for this response from the backend for i, rh := range h.HandleResponse { if rh.Match != nil && !rh.Match.Match(res.StatusCode, res.Header) { @@ -1074,14 +1117,6 @@ func (h *Handler) reverseProxy(rw http.ResponseWriter, req *http.Request, origRe break } - // set up the replacer so that parts of the original response can be - // used for routing decisions - for field, value := range res.Header { - repl.Set("http.reverse_proxy.header."+field, strings.Join(value, ",")) - } - repl.Set("http.reverse_proxy.status_code", res.StatusCode) - repl.Set("http.reverse_proxy.status_text", res.Status) - if c := logger.Check(zapcore.DebugLevel, "handling response"); c != nil { c.Write(zap.Int("handler", i)) } @@ -1266,18 +1301,29 @@ func (lb LoadBalancing) tryAgain(ctx caddy.Context, start time.Time, retries int // specifically a dialer error, we need to be careful if proxyErr != nil { _, isDialError := proxyErr.(DialError) + _, isRetryableResponse := proxyErr.(retryableResponseError) herr, isHandlerError := proxyErr.(caddyhttp.HandlerError) // if the error occurred after a connection was established, // we have to assume the upstream received the request, and // retries need to be carefully decided, because some requests - // are not idempotent - if !isDialError && (!isHandlerError || !errors.Is(herr, errNoUpstream)) { + // are not idempotent; retryableResponseError is excluded here + // because its retry decision was already made in reverseProxy() + // when the response matchers were evaluated + if !isDialError && !isRetryableResponse && (!isHandlerError || !errors.Is(herr, errNoUpstream)) { if lb.RetryMatch == nil && req.Method != "GET" { // by default, don't retry requests if they aren't GET return false } + // set transport error flag so CEL expressions can use + // {rp.is_transport_error} to decide whether to retry + repl, _ := req.Context().Value(caddy.ReplacerCtxKey).(*caddy.Replacer) + if repl != nil { + repl.Set("http.reverse_proxy.is_transport_error", true) + defer repl.Delete("http.reverse_proxy.is_transport_error") + } + match, err := lb.RetryMatch.AnyMatchWithError(req) if err != nil { logger.Error("error matching request for retry", zap.Error(err)) @@ -1507,6 +1553,12 @@ func removeConnectionHeaders(h http.Header) { // statusError returns an error value that has a status code. func statusError(err error) error { + // if a response-based retry was exhausted, use the actual upstream + // status code instead of a generic 502 + if rre, ok := err.(retryableResponseError); ok { + return caddyhttp.Error(rre.statusCode, err) + } + // errors proxying usually mean there is a problem with the upstream(s) statusCode := http.StatusBadGateway @@ -1558,13 +1610,15 @@ type LoadBalancing struct { // to spin if all backends are down and latency is very low. TryInterval caddy.Duration `json:"try_interval,omitempty"` - // A list of matcher sets that restricts with which requests retries are - // allowed. A request must match any of the given matcher sets in order - // to be retried if the connection to the upstream succeeded but the - // subsequent round-trip failed. If the connection to the upstream failed, - // a retry is always allowed. If unspecified, only GET requests will be - // allowed to be retried. Note that a retry is done with the next available - // host according to the load balancing policy. + // A list of matcher sets that controls retry behavior. Matcher sets + // without expression matchers (e.g. method, path) restrict which + // requests are retried on transport errors - if unspecified, only + // GET requests will be retried. Matcher sets with CEL expression + // matchers are evaluated against upstream responses and can + // reference {rp.status_code}, {rp.header.*}, and + // {rp.is_transport_error}. Dial errors are always retried + // regardless of this setting. Retries use the next available + // upstream per the load balancing policy RetryMatchRaw caddyhttp.RawMatcherSets `json:"retry_match,omitempty" caddy:"namespace=http.matchers"` SelectionPolicy Selector `json:"-"` @@ -1662,10 +1716,34 @@ type RequestHeaderOpsTransport interface { RequestHeaderOps() *headers.HeaderOps } +// matcherSetHasExpressionMatcher reports whether a matcher set contains +// at least one expression matcher. Expression matchers can reference +// response data via placeholders like {rp.status_code}. Matcher sets +// without expression matchers only test request properties and should +// not be evaluated for response-based retry decisions +func matcherSetHasExpressionMatcher(matcherSet caddyhttp.MatcherSet) bool { + for _, m := range matcherSet { + if _, ok := m.(*caddyhttp.MatchExpression); ok { + return true + } + } + return false +} + // roundtripSucceededError is an error type that is returned if the // roundtrip succeeded, but an error occurred after-the-fact. type roundtripSucceededError struct{ error } +// retryableResponseError is returned when the upstream response matched +// a retry_match entry, indicating the request should be retried with the +// next upstream. It preserves the original status code so that if retries +// are exhausted, the actual upstream status is reported instead of a +// generic 502 +type retryableResponseError struct { + error + statusCode int +} + // bodyReadCloser is a reader that, upon closing, will return // its buffer to the pool and close the underlying body reader. type bodyReadCloser struct { diff --git a/replacer.go b/replacer.go index 1a2aa5771..2ab02b602 100644 --- a/replacer.go +++ b/replacer.go @@ -121,6 +121,18 @@ func (r *Replacer) Delete(variable string) { r.mapMutex.Unlock() } +// DeleteByPrefix removes all static variables with +// keys starting with the given prefix +func (r *Replacer) DeleteByPrefix(prefix string) { + r.mapMutex.Lock() + for key := range r.static { + if strings.HasPrefix(key, prefix) { + delete(r.static, key) + } + } + r.mapMutex.Unlock() +} + // fromStatic provides values from r.static. func (r *Replacer) fromStatic(key string) (any, bool) { r.mapMutex.RLock() From 441d5eb0628c4a9fbe74c13eca6ab255056e3e57 Mon Sep 17 00:00:00 2001 From: Matt Holt Date: Thu, 23 Apr 2026 01:29:03 -0600 Subject: [PATCH 07/13] caddyhttp: prefer port 443 in auto-HTTPS and add tests (#7666) --- caddytest/integration/autohttps_test.go | 22 +++++++++++++ modules/caddyhttp/autohttps.go | 38 +++++++++++++++++---- modules/caddyhttp/autohttps_test.go | 44 +++++++++++++++++++++++++ 3 files changed, 97 insertions(+), 7 deletions(-) create mode 100644 modules/caddyhttp/autohttps_test.go diff --git a/caddytest/integration/autohttps_test.go b/caddytest/integration/autohttps_test.go index fdfb5a93e..88a0aee03 100644 --- a/caddytest/integration/autohttps_test.go +++ b/caddytest/integration/autohttps_test.go @@ -55,6 +55,28 @@ func TestAutoHTTPtoHTTPSRedirectsExplicitPortDifferentFromHTTPSPort(t *testing.T tester.AssertRedirect("http://localhost:9080/", "https://localhost:1234/", http.StatusPermanentRedirect) } +func TestAutoHTTPtoHTTPSRedirectsPreferHTTPSPortOverAlternatePort(t *testing.T) { + tester := caddytest.NewTester(t) + tester.InitServer(` + { + skip_install_trust + admin localhost:2999 + http_port 9080 + https_port 9443 + local_certs + } + localhost { + respond "Canonical" + } + + localhost:10443 { + respond "Alternate" + } + `, "caddyfile") + + tester.AssertRedirect("http://localhost:9080/", "https://localhost/", http.StatusPermanentRedirect) +} + func TestAutoHTTPRedirectsWithHTTPListenerFirstInAddresses(t *testing.T) { tester := caddytest.NewTester(t) tester.InitServer(` diff --git a/modules/caddyhttp/autohttps.go b/modules/caddyhttp/autohttps.go index 32e9f106d..4d9759000 100644 --- a/modules/caddyhttp/autohttps.go +++ b/modules/caddyhttp/autohttps.go @@ -258,18 +258,13 @@ func (app *App) automaticHTTPSPhase1(ctx caddy.Context, repl *caddy.Replacer) er // an empty string to indicate a catch-all, which we have to // treat special later if len(serverDomainSet) == 0 { - redirDomains[""] = append(redirDomains[""], addr) + app.recordAutoHTTPSRedirectAddress(redirDomains, "", addr) continue } // ...and associate it with each domain in this server for d := range serverDomainSet { - // if this domain is used on more than one HTTPS-enabled - // port, we'll have to choose one, so prefer the HTTPS port - if _, ok := redirDomains[d]; !ok || - addr.StartPort == uint(app.httpsPort()) { - redirDomains[d] = append(redirDomains[d], addr) - } + app.recordAutoHTTPSRedirectAddress(redirDomains, d, addr) } } } @@ -517,6 +512,35 @@ redirServersLoop: return nil } +// recordAutoHTTPSRedirectAddress stores redirect destinations for one domain +// using a single winning port while keeping all bind addresses on that port. +// +// This is needed to avoid two opposite regressions in auto-HTTPS redirects: +// preserve all listener addresses when a site binds multiple addresses on the +// same HTTPS port, but do not mix in alternate HTTPS ports when the canonical +// app HTTPS port is also available. +func (app *App) recordAutoHTTPSRedirectAddress(redirDomains map[string][]caddy.NetworkAddress, domain string, addr caddy.NetworkAddress) { + existing := redirDomains[domain] + if len(existing) == 0 { + redirDomains[domain] = []caddy.NetworkAddress{addr} + return + } + + existingPort := existing[0].StartPort + if addr.StartPort != existingPort { + if addr.StartPort == uint(app.httpsPort()) && existingPort != uint(app.httpsPort()) { + redirDomains[domain] = []caddy.NetworkAddress{addr} + } + return + } + + if slices.Contains(existing, addr) { + return + } + + redirDomains[domain] = append(existing, addr) +} + func (app *App) makeRedirRoute(redirToPort uint, matcherSet MatcherSet) Route { redirTo := "https://{http.request.host}" diff --git a/modules/caddyhttp/autohttps_test.go b/modules/caddyhttp/autohttps_test.go new file mode 100644 index 000000000..b5cc64d94 --- /dev/null +++ b/modules/caddyhttp/autohttps_test.go @@ -0,0 +1,44 @@ +package caddyhttp + +import ( + "testing" + + "github.com/caddyserver/caddy/v2" +) + +func TestRecordAutoHTTPSRedirectAddressPrefersHTTPSPort(t *testing.T) { + app := &App{HTTPSPort: 443} + redirDomains := make(map[string][]caddy.NetworkAddress) + + app.recordAutoHTTPSRedirectAddress(redirDomains, "example.com", caddy.NetworkAddress{Network: "tcp", StartPort: 2345, EndPort: 2345}) + app.recordAutoHTTPSRedirectAddress(redirDomains, "example.com", caddy.NetworkAddress{Network: "tcp", StartPort: 443, EndPort: 443}) + app.recordAutoHTTPSRedirectAddress(redirDomains, "example.com", caddy.NetworkAddress{Network: "tcp", StartPort: 8443, EndPort: 8443}) + + got := redirDomains["example.com"] + if len(got) != 1 { + t.Fatalf("expected 1 redirect address, got %d: %#v", len(got), got) + } + if got[0].StartPort != 443 { + t.Fatalf("expected redirect to prefer HTTPS port 443, got %#v", got[0]) + } +} + +func TestRecordAutoHTTPSRedirectAddressKeepsAllBindAddressesOnWinningPort(t *testing.T) { + app := &App{HTTPSPort: 443} + redirDomains := make(map[string][]caddy.NetworkAddress) + + app.recordAutoHTTPSRedirectAddress(redirDomains, "example.com", caddy.NetworkAddress{Network: "tcp", Host: "10.0.0.189", StartPort: 8443, EndPort: 8443}) + app.recordAutoHTTPSRedirectAddress(redirDomains, "example.com", caddy.NetworkAddress{Network: "tcp", Host: "10.0.0.189", StartPort: 443, EndPort: 443}) + app.recordAutoHTTPSRedirectAddress(redirDomains, "example.com", caddy.NetworkAddress{Network: "tcp", Host: "2603:c024:8002:9500:9eb:e5d3:3975:d056", StartPort: 443, EndPort: 443}) + + got := redirDomains["example.com"] + if len(got) != 2 { + t.Fatalf("expected 2 redirect addresses for both bind addresses on the winning port, got %d: %#v", len(got), got) + } + if got[0].StartPort != 443 || got[1].StartPort != 443 { + t.Fatalf("expected both redirect addresses to stay on HTTPS port 443, got %#v", got) + } + if got[0].Host != "10.0.0.189" || got[1].Host != "2603:c024:8002:9500:9eb:e5d3:3975:d056" { + t.Fatalf("expected both bind addresses to be preserved, got %#v", got) + } +} From 41aee97386ae8a52231ab8ff7790e49cff802d77 Mon Sep 17 00:00:00 2001 From: Zen Dodd Date: Fri, 24 Apr 2026 05:33:41 +1000 Subject: [PATCH 08/13] core: propagate ECH keys to the QUIC listener (#7670) --- listeners.go | 15 +++++++++++- listeners_test.go | 58 +++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 72 insertions(+), 1 deletion(-) diff --git a/listeners.go b/listeners.go index 84ebaaaba..ace0215b0 100644 --- a/listeners.go +++ b/listeners.go @@ -462,7 +462,10 @@ func (na NetworkAddress) ListenQUIC(ctx context.Context, portOffset uint, config sqs := newSharedQUICState(tlsConf) // http3.ConfigureTLSConfig only uses this field and tls App sets this field as well //nolint:gosec - quicTlsConfig := &tls.Config{GetConfigForClient: sqs.getConfigForClient} + quicTlsConfig := &tls.Config{ + GetConfigForClient: sqs.getConfigForClient, + GetEncryptedClientHelloKeys: sqs.getEncryptedClientHelloKeys, + } // Require clients to verify their source address when we're handling more than 1000 handshakes per second. // TODO: make tunable? limiter := rate.NewLimiter(1000, 1000) @@ -540,6 +543,16 @@ func (sqs *sharedQUICState) getConfigForClient(ch *tls.ClientHelloInfo) (*tls.Co return sqs.activeTlsConf.GetConfigForClient(ch) } +// getEncryptedClientHelloKeys is used as tls.Config's GetEncryptedClientHelloKeys field. +func (sqs *sharedQUICState) getEncryptedClientHelloKeys(ch *tls.ClientHelloInfo) ([]tls.EncryptedClientHelloKey, error) { + sqs.rmu.RLock() + defer sqs.rmu.RUnlock() + if sqs.activeTlsConf.GetEncryptedClientHelloKeys == nil { + return nil, nil + } + return sqs.activeTlsConf.GetEncryptedClientHelloKeys(ch) +} + // addState adds tls.Config and activeRequests to the map if not present and returns the corresponding context and its cancelFunc // so that when cancelled, the active tls.Config will change func (sqs *sharedQUICState) addState(tlsConfig *tls.Config) (context.Context, context.CancelCauseFunc) { diff --git a/listeners_test.go b/listeners_test.go index a4cadd3aa..7bbaca1f9 100644 --- a/listeners_test.go +++ b/listeners_test.go @@ -15,6 +15,7 @@ package caddy import ( + "crypto/tls" "reflect" "testing" @@ -175,6 +176,63 @@ func TestJoinNetworkAddress(t *testing.T) { } } +func TestSharedQUICStateGetEncryptedClientHelloKeys(t *testing.T) { + hello := &tls.ClientHelloInfo{ServerName: "example.com"} + initialKeys := []tls.EncryptedClientHelloKey{{Config: []byte("initial"), PrivateKey: []byte("initial-key")}} + updatedKeys := []tls.EncryptedClientHelloKey{{Config: []byte("updated"), PrivateKey: []byte("updated-key")}} + + initialConfig := &tls.Config{ + GetConfigForClient: func(*tls.ClientHelloInfo) (*tls.Config, error) { + return nil, nil + }, + GetEncryptedClientHelloKeys: func(*tls.ClientHelloInfo) ([]tls.EncryptedClientHelloKey, error) { + return initialKeys, nil + }, + } + + sqs := newSharedQUICState(initialConfig) + + keys, err := sqs.getEncryptedClientHelloKeys(hello) + if err != nil { + t.Fatalf("getting initial ECH keys: %v", err) + } + if !reflect.DeepEqual(keys, initialKeys) { + t.Fatalf("unexpected initial ECH keys: got %#v, want %#v", keys, initialKeys) + } + + updatedConfig := &tls.Config{ + GetConfigForClient: func(*tls.ClientHelloInfo) (*tls.Config, error) { + return nil, nil + }, + GetEncryptedClientHelloKeys: func(*tls.ClientHelloInfo) ([]tls.EncryptedClientHelloKey, error) { + return updatedKeys, nil + }, + } + + _, cancel := sqs.addState(updatedConfig) + sqs.rmu.Lock() + sqs.activeTlsConf = updatedConfig + sqs.rmu.Unlock() + + keys, err = sqs.getEncryptedClientHelloKeys(hello) + if err != nil { + t.Fatalf("getting updated ECH keys: %v", err) + } + if !reflect.DeepEqual(keys, updatedKeys) { + t.Fatalf("unexpected updated ECH keys: got %#v, want %#v", keys, updatedKeys) + } + + cancel(nil) + + keys, err = sqs.getEncryptedClientHelloKeys(hello) + if err != nil { + t.Fatalf("getting restored ECH keys: %v", err) + } + if !reflect.DeepEqual(keys, initialKeys) { + t.Fatalf("unexpected restored ECH keys: got %#v, want %#v", keys, initialKeys) + } +} + func TestParseNetworkAddress(t *testing.T) { for i, tc := range []struct { input string From cf42f615662a529311fb56209a38d6a93d83d6d9 Mon Sep 17 00:00:00 2001 From: Matthew Holt Date: Fri, 24 Apr 2026 09:50:06 -0600 Subject: [PATCH 09/13] Typo fix in security policy --- .github/SECURITY.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/SECURITY.md b/.github/SECURITY.md index 2b72b95b6..52f997149 100644 --- a/.github/SECURITY.md +++ b/.github/SECURITY.md @@ -8,7 +8,7 @@ The Caddy project would like to make sure that it stays on top of all relevant a | Version | Supported | | ----------- | ----------| | 2.latest | ✔️ | -| <= 2.latest | :x: | +| < 2.latest | :x: | ## Acceptable Scope @@ -25,6 +25,8 @@ Client-side exploits are out of scope. In other words, it is not a bug in Caddy Security bugs in code dependencies (including Go's standard library) are out of scope. Instead, if a dependency has patched a relevant security bug, please feel free to open a public issue or pull request to update that dependency in our code. +Many reports are not security bugs and can be addressed by updating the documentation. + We accept security reports and patches, but do not assign CVEs, for code that has not been released with a non-prerelease tag. From 48c08e3890fe507bb64d59ec8004586ced676171 Mon Sep 17 00:00:00 2001 From: Matthew Holt Date: Fri, 24 Apr 2026 11:28:40 -0600 Subject: [PATCH 10/13] admin: Limit config size (by @omercnet) GitHub was giving me errors related to merge status so we are doing this instead --- admin.go | 3 +++ 1 file changed, 3 insertions(+) diff --git a/admin.go b/admin.go index a93595416..766d8506a 100644 --- a/admin.go +++ b/admin.go @@ -1063,6 +1063,9 @@ func handleConfig(w http.ResponseWriter, r *http.Request) error { buf.Reset() defer bufPool.Put(buf) + const maxConfigSize = 100 * 1024 * 1024 // 100 MB + r.Body = http.MaxBytesReader(w, r.Body, maxConfigSize) + _, err := io.Copy(buf, r.Body) if err != nil { return APIError{ From f6ee80be1b1207a5dbb380fce5dad450ceedaf67 Mon Sep 17 00:00:00 2001 From: Matthew Holt Date: Fri, 24 Apr 2026 11:40:54 -0600 Subject: [PATCH 11/13] go.mod: Upgrade dependencies including CertMagic --- go.mod | 18 +++++++++--------- go.sum | 36 ++++++++++++++++++------------------ 2 files changed, 27 insertions(+), 27 deletions(-) diff --git a/go.mod b/go.mod index 8796ad4d8..6e7a81b29 100644 --- a/go.mod +++ b/go.mod @@ -4,12 +4,12 @@ go 1.25.0 require ( github.com/BurntSushi/toml v1.6.0 - github.com/DeRuina/timberjack v1.4.1 + github.com/DeRuina/timberjack v1.4.2 github.com/KimMachineGun/automemlimit v0.7.5 github.com/Masterminds/sprig/v3 v3.3.0 github.com/alecthomas/chroma/v2 v2.23.1 github.com/aryann/difflib v0.0.0-20210328193216-ff5ff6dc229b - github.com/caddyserver/certmagic v0.25.2 + github.com/caddyserver/certmagic v0.25.3 github.com/caddyserver/zerossl v0.1.5 github.com/cloudflare/circl v1.6.3 github.com/dustin/go-humanize v1.0.1 @@ -39,11 +39,11 @@ require ( go.uber.org/automaxprocs v1.6.0 go.uber.org/zap v1.27.1 go.uber.org/zap/exp v0.3.0 - golang.org/x/crypto v0.49.0 + golang.org/x/crypto v0.50.0 golang.org/x/crypto/x509roots/fallback v0.0.0-20260213171211-a408498e5541 - golang.org/x/net v0.52.0 + golang.org/x/net v0.53.0 golang.org/x/sync v0.20.0 - golang.org/x/term v0.41.0 + golang.org/x/term v0.42.0 golang.org/x/time v0.15.0 gopkg.in/yaml.v3 v3.0.1 ) @@ -168,10 +168,10 @@ require ( go.opentelemetry.io/otel/trace v1.43.0 go.opentelemetry.io/proto/otlp v1.10.0 // indirect go.uber.org/multierr v1.11.0 // indirect - golang.org/x/mod v0.33.0 // indirect - golang.org/x/sys v0.42.0 - golang.org/x/text v0.35.0 - golang.org/x/tools v0.42.0 // indirect + golang.org/x/mod v0.35.0 // indirect + golang.org/x/sys v0.43.0 + golang.org/x/text v0.36.0 + golang.org/x/tools v0.44.0 // indirect google.golang.org/grpc v1.80.0 // indirect google.golang.org/protobuf v1.36.11 // indirect howett.net/plist v1.0.0 // indirect diff --git a/go.sum b/go.sum index 48a7d22bd..50fb903b3 100644 --- a/go.sum +++ b/go.sum @@ -28,8 +28,8 @@ github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03 github.com/BurntSushi/toml v1.5.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk= github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= -github.com/DeRuina/timberjack v1.4.1 h1:JftM5HN/ITKehAXjtdbGqN5XZIS1biHm7VSjU0Qbtqg= -github.com/DeRuina/timberjack v1.4.1/go.mod h1:RLoeQrwrCGIEF8gO5nV5b/gMD0QIy7bzQhBUgpp1EqE= +github.com/DeRuina/timberjack v1.4.2 h1:4bKlzhKdsR+2oNkgef9mqb4n11ICow8VK88RfzJPzN8= +github.com/DeRuina/timberjack v1.4.2/go.mod h1:RLoeQrwrCGIEF8gO5nV5b/gMD0QIy7bzQhBUgpp1EqE= github.com/KimMachineGun/automemlimit v0.7.5 h1:RkbaC0MwhjL1ZuBKunGDjE/ggwAX43DwZrJqVwyveTk= github.com/KimMachineGun/automemlimit v0.7.5/go.mod h1:QZxpHaGOQoYvFhv/r4u3U0JTC2ZcOwbSr11UZF46UBM= github.com/Masterminds/goutils v1.1.1 h1:5nUrii3FMTL5diU80unEVvNevw1nH4+ZV4DSLVJLSYI= @@ -85,8 +85,8 @@ github.com/aws/smithy-go v1.24.2 h1:FzA3bu/nt/vDvmnkg+R8Xl46gmzEDam6mZ1hzmwXFng= github.com/aws/smithy-go v1.24.2/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= -github.com/caddyserver/certmagic v0.25.2 h1:D7xcS7ggX/WEY54x0czj7ioTkmDWKIgxtIi2OcQclUc= -github.com/caddyserver/certmagic v0.25.2/go.mod h1:llW/CvsNmza8S6hmsuggsZeiX+uS27dkqY27wDIuBWg= +github.com/caddyserver/certmagic v0.25.3 h1:mGf5ba8F7xA4c5jfDZZbK2buY1VEkbnwpMDixaju94A= +github.com/caddyserver/certmagic v0.25.3/go.mod h1:YVs43D5+H/Dckt4bTga1KSO/xYfFBfVZainGDywYPAA= github.com/caddyserver/zerossl v0.1.5 h1:dkvOjBAEEtY6LIGAHei7sw2UgqSD6TrWweXpV7lvEvE= github.com/caddyserver/zerossl v0.1.5/go.mod h1:CxA0acn7oEGO6//4rtrRjYgEoa4MFw/XofZnrYwGqG4= github.com/ccoveille/go-safecast/v2 v2.0.0 h1:+5eyITXAUj3wMjad6cRVJKGnC7vDS55zk0INzJagub0= @@ -456,8 +456,8 @@ golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliY golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU= golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8= golang.org/x/crypto v0.33.0/go.mod h1:bVdXmD7IV/4GdElGPozy6U7lWdRXA4qyRVGJV57uQ5M= -golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4= -golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA= +golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI= +golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q= golang.org/x/crypto/x509roots/fallback v0.0.0-20260213171211-a408498e5541 h1:FmKxj9ocLKn45jiR2jQMwCVhDvaK7fKQFzfuT9GvyK8= golang.org/x/crypto/x509roots/fallback v0.0.0-20260213171211-a408498e5541/go.mod h1:+UoQFNBq2p2wO+Q6ddVtYc25GZ6VNdOMyyrd4nrqrKs= golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 h1:mgKeJMpvi0yx/sU5GsxQ7p6s2wtOnGAHZWCHUM4KGzY= @@ -467,8 +467,8 @@ golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= -golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8= -golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w= +golang.org/x/mod v0.35.0 h1:Ww1D637e6Pg+Zb2KrWfHQUnH2dQRLBQyAtpr/haaJeM= +golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= @@ -477,8 +477,8 @@ golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk= golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44= golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= -golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0= -golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw= +golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA= +golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs= golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= @@ -506,8 +506,8 @@ golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= -golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI= +golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= @@ -517,8 +517,8 @@ golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU= golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk= golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY= golang.org/x/term v0.29.0/go.mod h1:6bl4lRlvVuDgSf3179VpIxBF0o10JUpXWOnI7nErv7s= -golang.org/x/term v0.41.0 h1:QCgPso/Q3RTJx2Th4bDLqML4W6iJiaXFq2/ftQF13YU= -golang.org/x/term v0.41.0/go.mod h1:3pfBgksrReYfZ5lvYM0kSO0LIkAl4Yl2bXOkKP7Ec2A= +golang.org/x/term v0.42.0 h1:UiKe+zDFmJobeJ5ggPwOshJIVt6/Ft0rcfrXZDLWAWY= +golang.org/x/term v0.42.0/go.mod h1:Dq/D+snpsbazcBG5+F9Q1n2rXV8Ma+71xEjTRufARgY= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= @@ -528,8 +528,8 @@ golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY= -golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8= -golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA= +golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg= +golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164= golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U= golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= @@ -538,8 +538,8 @@ golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58= golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk= -golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k= -golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0= +golang.org/x/tools v0.44.0 h1:UP4ajHPIcuMjT1GqzDWRlalUEoY+uzoZKnhOjbIPD2c= +golang.org/x/tools v0.44.0/go.mod h1:KA0AfVErSdxRZIsOVipbv3rQhVXTnlU6UhKxHd1seDI= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E= From 355c1782137f678897495503dcfe0b9e77997737 Mon Sep 17 00:00:00 2001 From: Francis Lavoie Date: Sat, 25 Apr 2026 03:47:54 -0400 Subject: [PATCH 12/13] chore: Use atomics where appropriate (#7648) * chore: Use atomics where appropriate * Use atomic for shutdownAt --- caddy.go | 6 +++--- listen.go | 20 ++++++++++---------- listen_unix.go | 22 ++++++++++++---------- listeners.go | 10 +++++----- modules/caddyhttp/app.go | 6 +----- modules/caddyhttp/replacer.go | 11 ++++------- modules/caddyhttp/server.go | 5 ++--- usagepool.go | 18 ++++++++++-------- 8 files changed, 47 insertions(+), 51 deletions(-) diff --git a/caddy.go b/caddy.go index 2b4b9087b..b3144299d 100644 --- a/caddy.go +++ b/caddy.go @@ -766,7 +766,7 @@ func Validate(cfg *Config) error { // code is emitted. func exitProcess(ctx context.Context, logger *zap.Logger) { // let the rest of the program know we're quitting; only do it once - if !atomic.CompareAndSwapInt32(exiting, 0, 1) { + if !exiting.CompareAndSwap(false, true) { return } @@ -845,11 +845,11 @@ func exitProcess(ctx context.Context, logger *zap.Logger) { }() } -var exiting = new(int32) // accessed atomically +var exiting atomic.Bool // Exiting returns true if the process is exiting. // EXPERIMENTAL API: subject to change or removal. -func Exiting() bool { return atomic.LoadInt32(exiting) == 1 } +func Exiting() bool { return exiting.Load() } // OnExit registers a callback to invoke during process exit. // This registration is PROCESS-GLOBAL, meaning that each diff --git a/listen.go b/listen.go index fba9c3a6b..03b63c1e2 100644 --- a/listen.go +++ b/listen.go @@ -120,8 +120,8 @@ func listenReusable(ctx context.Context, lnKey string, network, address string, // re-wrapped in a new fakeCloseListener each time the listener // is reused. This type is atomic and values must not be copied. type fakeCloseListener struct { - closed int32 // accessed atomically; belongs to this struct only - *sharedListener // embedded, so we also become a net.Listener + closed atomic.Bool + *sharedListener // embedded, so we also become a net.Listener keepAliveConfig net.KeepAliveConfig } @@ -131,7 +131,7 @@ type canSetKeepAliveConfig interface { func (fcl *fakeCloseListener) Accept() (net.Conn, error) { // if the listener is already "closed", return error - if atomic.LoadInt32(&fcl.closed) == 1 { + if fcl.closed.Load() { return nil, fakeClosedErr(fcl) } @@ -155,7 +155,7 @@ func (fcl *fakeCloseListener) Accept() (net.Conn, error) { // that we set when Close() was called, and return a non-temporary and // non-timeout error value to the caller, masking the "true" error, so // that server loops / goroutines won't retry, linger, and leak - if atomic.LoadInt32(&fcl.closed) == 1 { + if fcl.closed.Load() { // we dereference the sharedListener explicitly even though it's embedded // so that it's clear in the code that side-effects are shared with other // users of this listener, not just our own reference to it; we also don't @@ -175,7 +175,7 @@ func (fcl *fakeCloseListener) Accept() (net.Conn, error) { // underlying listener. The underlying listener is only closed // if the caller is the last known user of the socket. func (fcl *fakeCloseListener) Close() error { - if atomic.CompareAndSwapInt32(&fcl.closed, 0, 1) { + if fcl.closed.CompareAndSwap(false, true) { // There are two ways I know of to get an Accept() // function to return to the server loop that called // it: close the listener, or set a deadline in the @@ -238,13 +238,13 @@ func (sl *sharedListener) Destruct() error { // fakeClosePacketConn is like fakeCloseListener, but for PacketConns, // or more specifically, *net.UDPConn type fakeClosePacketConn struct { - closed int32 // accessed atomically; belongs to this struct only - *sharedPacketConn // embedded, so we also become a net.PacketConn; its key is used in Close + closed atomic.Bool + *sharedPacketConn // embedded, so we also become a net.PacketConn; its key is used in Close } func (fcpc *fakeClosePacketConn) ReadFrom(p []byte) (n int, addr net.Addr, err error) { // if the listener is already "closed", return error - if atomic.LoadInt32(&fcpc.closed) == 1 { + if fcpc.closed.Load() { return 0, nil, &net.OpError{ Op: "readfrom", Net: fcpc.LocalAddr().Network(), @@ -258,7 +258,7 @@ func (fcpc *fakeClosePacketConn) ReadFrom(p []byte) (n int, addr net.Addr, err e if err != nil { // this server was stopped, so clear the deadline and let // any new server continue reading; but we will exit - if atomic.LoadInt32(&fcpc.closed) == 1 { + if fcpc.closed.Load() { if netErr, ok := err.(net.Error); ok && netErr.Timeout() { if err = fcpc.SetReadDeadline(time.Time{}); err != nil { return n, addr, err @@ -273,7 +273,7 @@ func (fcpc *fakeClosePacketConn) ReadFrom(p []byte) (n int, addr net.Addr, err e // Close won't close the underlying socket unless there is no more reference, then listenerPool will close it. func (fcpc *fakeClosePacketConn) Close() error { - if atomic.CompareAndSwapInt32(&fcpc.closed, 0, 1) { + if fcpc.closed.CompareAndSwap(false, true) { _ = fcpc.SetReadDeadline(time.Now()) // unblock ReadFrom() calls to kick old servers out of their loops _, _ = listenerPool.Delete(fcpc.sharedPacketConn.key) } diff --git a/listen_unix.go b/listen_unix.go index d6ae0cb8e..d60f69f3b 100644 --- a/listen_unix.go +++ b/listen_unix.go @@ -63,7 +63,7 @@ func reuseUnixSocket(network, addr string) (any, error) { if err != nil { return nil, err } - atomic.AddInt32(unixSocket.count, 1) + unixSocket.count.Add(1) unixSockets[socketKey] = &unixListener{ln.(*net.UnixListener), socketKey, unixSocket.count} case *unixConn: @@ -71,7 +71,7 @@ func reuseUnixSocket(network, addr string) (any, error) { if err != nil { return nil, err } - atomic.AddInt32(unixSocket.count, 1) + unixSocket.count.Add(1) unixSockets[socketKey] = &unixConn{pc.(*net.UnixConn), socketKey, unixSocket.count} } @@ -165,8 +165,9 @@ func listenReusable(ctx context.Context, lnKey string, network, address string, if !fd { // TODO: Not 100% sure this is necessary, but we do this for net.UnixListener, so... if unix, ok := ln.(*net.UnixConn); ok { - one := int32(1) - ln = &unixConn{unix, lnKey, &one} + cnt := new(atomic.Int32) + cnt.Store(1) + ln = &unixConn{unix, lnKey, cnt} unixSockets[lnKey] = ln.(*unixConn) } } @@ -181,8 +182,9 @@ func listenReusable(ctx context.Context, lnKey string, network, address string, // (we do our own "unlink on close" -- not required, but more tidy) if unix, ok := ln.(*net.UnixListener); ok { unix.SetUnlinkOnClose(false) - one := int32(1) - ln = &unixListener{unix, lnKey, &one} + cnt := new(atomic.Int32) + cnt.Store(1) + ln = &unixListener{unix, lnKey, cnt} unixSockets[lnKey] = ln.(*unixListener) } } @@ -216,11 +218,11 @@ func reusePort(network, address string, conn syscall.RawConn) error { type unixListener struct { *net.UnixListener mapKey string - count *int32 // accessed atomically + count *atomic.Int32 } func (uln *unixListener) Close() error { - newCount := atomic.AddInt32(uln.count, -1) + newCount := uln.count.Add(-1) if newCount == 0 { file, err := uln.File() var name string @@ -242,11 +244,11 @@ func (uln *unixListener) Close() error { type unixConn struct { *net.UnixConn mapKey string - count *int32 // accessed atomically + count *atomic.Int32 } func (uc *unixConn) Close() error { - newCount := atomic.AddInt32(uc.count, -1) + newCount := uc.count.Add(-1) if newCount == 0 { file, err := uc.File() var name string diff --git a/listeners.go b/listeners.go index ace0215b0..6031f98e4 100644 --- a/listeners.go +++ b/listeners.go @@ -624,8 +624,8 @@ func fakeClosedErr(l interface{ Addr() net.Addr }) error { var errFakeClosed = fmt.Errorf("QUIC listener 'closed' 😉") type fakeCloseQuicListener struct { - closed int32 // accessed atomically; belongs to this struct only - *sharedQuicListener // embedded, so we also become a quic.EarlyListener + closed atomic.Int32 + *sharedQuicListener // embedded, so we also become a quic.EarlyListener context context.Context contextCancel context.CancelCauseFunc } @@ -642,16 +642,16 @@ func (fcql *fakeCloseQuicListener) Accept(_ context.Context) (*quic.Conn, error) } // if the listener is "closed", return a fake closed error instead - if atomic.LoadInt32(&fcql.closed) == 1 && errors.Is(err, context.Canceled) { + if fcql.closed.Load() == 1 && errors.Is(err, context.Canceled) { return nil, fakeClosedErr(fcql) } return nil, err } func (fcql *fakeCloseQuicListener) Close() error { - if atomic.CompareAndSwapInt32(&fcql.closed, 0, 1) { + if fcql.closed.CompareAndSwap(0, 1) { fcql.contextCancel(errFakeClosed) - } else if atomic.CompareAndSwapInt32(&fcql.closed, 1, 2) { + } else if fcql.closed.CompareAndSwap(1, 2) { _, _ = listenerPool.Delete(fcql.sharedQuicListener.key) } return nil diff --git a/modules/caddyhttp/app.go b/modules/caddyhttp/app.go index 673c36d77..a3b71836d 100644 --- a/modules/caddyhttp/app.go +++ b/modules/caddyhttp/app.go @@ -219,8 +219,6 @@ func (app *App) Provision(ctx caddy.Context) error { srv.ctx = ctx srv.logger = app.logger.Named("log") srv.errorLogger = app.logger.Named("log.error") - srv.shutdownAtMu = new(sync.RWMutex) - if srv.Metrics != nil { srv.logger.Warn("per-server 'metrics' is deprecated; use 'metrics' in the root 'http' app instead") app.Metrics = cmp.Or(app.Metrics, &Metrics{ @@ -694,9 +692,7 @@ func (app *App) Stop() error { for _, addr := range na.Expand() { if caddy.ListenerUsage(addr.Network, addr.JoinHostPort(0)) < 2 { app.logger.Debug("listener closing and shutdown delay is configured", zap.String("address", addr.String())) - server.shutdownAtMu.Lock() - server.shutdownAt = scheduledTime - server.shutdownAtMu.Unlock() + server.shutdownAt.Store(&scheduledTime) delay = true } else { app.logger.Debug("shutdown delay configured but listener will remain open", zap.String("address", addr.String())) diff --git a/modules/caddyhttp/replacer.go b/modules/caddyhttp/replacer.go index e7974a561..623a6ef4b 100644 --- a/modules/caddyhttp/replacer.go +++ b/modules/caddyhttp/replacer.go @@ -387,17 +387,14 @@ func addHTTPVarsToReplacer(repl *caddy.Replacer, req *http.Request, w http.Respo switch key { case "http.shutting_down": server := req.Context().Value(ServerCtxKey).(*Server) - server.shutdownAtMu.RLock() - defer server.shutdownAtMu.RUnlock() - return !server.shutdownAt.IsZero(), true + return server.shutdownAt.Load() != nil, true case "http.time_until_shutdown": server := req.Context().Value(ServerCtxKey).(*Server) - server.shutdownAtMu.RLock() - defer server.shutdownAtMu.RUnlock() - if server.shutdownAt.IsZero() { + t := server.shutdownAt.Load() + if t == nil { return nil, true } - return time.Until(server.shutdownAt), true + return time.Until(*t), true } return nil, false diff --git a/modules/caddyhttp/server.go b/modules/caddyhttp/server.go index 41a8e55b0..3005bc273 100644 --- a/modules/caddyhttp/server.go +++ b/modules/caddyhttp/server.go @@ -28,7 +28,7 @@ import ( "runtime" "slices" "strings" - "sync" + "sync/atomic" "time" "github.com/caddyserver/certmagic" @@ -291,8 +291,7 @@ type Server struct { trustedProxies IPRangeSource - shutdownAt time.Time - shutdownAtMu *sync.RWMutex + shutdownAt atomic.Pointer[time.Time] // registered callback functions connStateFuncs []func(net.Conn, http.ConnState) diff --git a/usagepool.go b/usagepool.go index a6466b9b1..6b7a3c25e 100644 --- a/usagepool.go +++ b/usagepool.go @@ -79,14 +79,15 @@ func (up *UsagePool) LoadOrNew(key any, construct Constructor) (value any, loade up.Lock() upv, loaded = up.pool[key] if loaded { - atomic.AddInt32(&upv.refs, 1) + upv.refs.Add(1) up.Unlock() upv.RLock() value = upv.value err = upv.err upv.RUnlock() } else { - upv = &usagePoolVal{refs: 1} + upv = &usagePoolVal{} + upv.refs.Store(1) upv.Lock() up.pool[key] = upv up.Unlock() @@ -118,7 +119,7 @@ func (up *UsagePool) LoadOrStore(key, val any) (value any, loaded bool) { up.Lock() upv, loaded = up.pool[key] if loaded { - atomic.AddInt32(&upv.refs, 1) + upv.refs.Add(1) up.Unlock() upv.Lock() if upv.err == nil { @@ -129,7 +130,8 @@ func (up *UsagePool) LoadOrStore(key, val any) (value any, loaded bool) { } upv.Unlock() } else { - upv = &usagePoolVal{refs: 1, value: val} + upv = &usagePoolVal{value: val} + upv.refs.Store(1) up.pool[key] = upv up.Unlock() value = val @@ -173,7 +175,7 @@ func (up *UsagePool) Delete(key any) (deleted bool, err error) { up.Unlock() return false, nil } - refs := atomic.AddInt32(&upv.refs, -1) + refs := upv.refs.Add(-1) if refs == 0 { delete(up.pool, key) up.Unlock() @@ -188,7 +190,7 @@ func (up *UsagePool) Delete(key any) (deleted bool, err error) { up.Unlock() if refs < 0 { panic(fmt.Sprintf("deleted more than stored: %#v (usage: %d)", - upv.value, upv.refs)) + upv.value, upv.refs.Load())) } } return deleted, err @@ -203,7 +205,7 @@ func (up *UsagePool) References(key any) (int, bool) { if loaded { // I wonder if it'd be safer to read this value during // our lock on the UsagePool... guess we'll see... - refs := atomic.LoadInt32(&upv.refs) + refs := upv.refs.Load() return int(refs), true } return 0, false @@ -220,7 +222,7 @@ type Destructor interface { } type usagePoolVal struct { - refs int32 // accessed atomically; must be 64-bit aligned for 32-bit systems + refs atomic.Int32 value any err error sync.RWMutex From 2a3ed96f8cbf0c72ce4621de84939e8828726d59 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?K=C3=A9vin=20Dunglas?= Date: Sat, 25 Apr 2026 12:52:08 +0200 Subject: [PATCH 13/13] metrics: Implement pushing via OLTP (#7664) --- caddyconfig/httpcaddyfile/options.go | 2 + .../metrics_otlp.caddyfiletest | 35 ++++++++ go.mod | 4 +- modules/caddyhttp/app.go | 9 ++ modules/caddyhttp/metrics.go | 88 ++++++++++++++++++- modules/caddyhttp/metrics_test.go | 50 +++++++++++ modules/caddyhttp/tracing/tracer.go | 3 +- 7 files changed, 183 insertions(+), 8 deletions(-) create mode 100644 caddytest/integration/caddyfile_adapt/metrics_otlp.caddyfiletest diff --git a/caddyconfig/httpcaddyfile/options.go b/caddyconfig/httpcaddyfile/options.go index ffe43ff7e..0b4ee5402 100644 --- a/caddyconfig/httpcaddyfile/options.go +++ b/caddyconfig/httpcaddyfile/options.go @@ -484,6 +484,8 @@ func unmarshalCaddyfileMetricsOptions(d *caddyfile.Dispenser) (any, error) { metrics.PerHost = true case "observe_catchall_hosts": metrics.ObserveCatchallHosts = true + case "otlp": + metrics.OTLP = true default: return nil, d.Errf("unrecognized servers option '%s'", d.Val()) } diff --git a/caddytest/integration/caddyfile_adapt/metrics_otlp.caddyfiletest b/caddytest/integration/caddyfile_adapt/metrics_otlp.caddyfiletest new file mode 100644 index 000000000..551c2f2ec --- /dev/null +++ b/caddytest/integration/caddyfile_adapt/metrics_otlp.caddyfiletest @@ -0,0 +1,35 @@ +{ + metrics { + otlp + } +} +:80 { + respond "Hello" +} +---------- +{ + "apps": { + "http": { + "servers": { + "srv0": { + "listen": [ + ":80" + ], + "routes": [ + { + "handle": [ + { + "body": "Hello", + "handler": "static_response" + } + ] + } + ] + } + }, + "metrics": { + "otlp": true + } + } + } +} diff --git a/go.mod b/go.mod index 6e7a81b29..93b73f3f6 100644 --- a/go.mod +++ b/go.mod @@ -30,11 +30,13 @@ require ( github.com/tailscale/tscert v0.0.0-20251216020129-aea342f6d747 github.com/yuin/goldmark v1.8.2 github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc + go.opentelemetry.io/contrib/bridges/prometheus v0.68.0 go.opentelemetry.io/contrib/exporters/autoexport v0.65.0 go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.65.0 go.opentelemetry.io/contrib/propagators/autoprop v0.65.0 go.opentelemetry.io/otel v1.43.0 go.opentelemetry.io/otel/sdk v1.43.0 + go.opentelemetry.io/otel/sdk/metric v1.43.0 go.step.sm/crypto v0.77.1 go.uber.org/automaxprocs v1.6.0 go.uber.org/zap v1.27.1 @@ -87,7 +89,6 @@ require ( github.com/x448/float16 v0.8.4 // indirect github.com/zeebo/blake3 v0.2.4 // indirect go.opentelemetry.io/auto/sdk v1.2.1 // indirect - go.opentelemetry.io/contrib/bridges/prometheus v0.68.0 // indirect go.opentelemetry.io/contrib/propagators/aws v1.43.0 // indirect go.opentelemetry.io/contrib/propagators/b3 v1.43.0 // indirect go.opentelemetry.io/contrib/propagators/jaeger v1.43.0 // indirect @@ -104,7 +105,6 @@ require ( go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.43.0 // indirect go.opentelemetry.io/otel/log v0.19.0 // indirect go.opentelemetry.io/otel/sdk/log v0.19.0 // indirect - go.opentelemetry.io/otel/sdk/metric v1.43.0 // indirect go.yaml.in/yaml/v2 v2.4.4 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 // indirect diff --git a/modules/caddyhttp/app.go b/modules/caddyhttp/app.go index a3b71836d..571ac496e 100644 --- a/modules/caddyhttp/app.go +++ b/modules/caddyhttp/app.go @@ -208,6 +208,9 @@ func (app *App) Provision(ctx caddy.Context) error { app.Metrics.httpMetrics = &httpMetrics{} // Scan config for allowed hosts to prevent cardinality explosion app.Metrics.scanConfigForHosts(app) + if err := app.Metrics.provisionOTLP(ctx); err != nil { + return err + } } // prepare each server oldContext := ctx.Context @@ -817,6 +820,12 @@ func (app *App) Stop() error { } } + // flush and shut down the OTLP metrics exporter (if configured) so any + // last data point reaches the collector before the process exits + if err := app.Metrics.shutdown(ctx); err != nil { + app.logger.Error("shutting down OTLP metrics", zap.Error(err)) + } + app.stopped = true return nil } diff --git a/modules/caddyhttp/metrics.go b/modules/caddyhttp/metrics.go index b212bbfb8..8d20e01b6 100644 --- a/modules/caddyhttp/metrics.go +++ b/modules/caddyhttp/metrics.go @@ -3,6 +3,7 @@ package caddyhttp import ( "context" "errors" + "fmt" "net/http" "strings" "sync" @@ -10,9 +11,14 @@ import ( "github.com/prometheus/client_golang/prometheus" "github.com/prometheus/client_golang/prometheus/promauto" + otelprom "go.opentelemetry.io/contrib/bridges/prometheus" + "go.opentelemetry.io/contrib/exporters/autoexport" + sdkmetric "go.opentelemetry.io/otel/sdk/metric" + "go.opentelemetry.io/otel/sdk/resource" + semconv "go.opentelemetry.io/otel/semconv/v1.26.0" "github.com/caddyserver/caddy/v2" - "github.com/caddyserver/caddy/v2/internal/metrics" + caddymetrics "github.com/caddyserver/caddy/v2/internal/metrics" ) // Metrics configures metrics observations. @@ -67,10 +73,20 @@ type Metrics struct { // for production environments exposed to the internet). ObserveCatchallHosts bool `json:"observe_catchall_hosts,omitempty"` + // Enable pushing metrics via OTLP in addition to the existing Prometheus + // scrape endpoints. When set, a PeriodicReader is attached to the shared + // Prometheus registry (via a Prometheus -> OpenTelemetry bridge), and the + // exporter is autoconfigured from the standard OTEL_* environment + // variables (OTEL_EXPORTER_OTLP_ENDPOINT, OTEL_EXPORTER_OTLP_PROTOCOL, + // OTEL_METRICS_EXPORTER, ...). Set OTEL_METRICS_EXPORTER=none or simply + // keep this field false to disable OTLP export. + OTLP bool `json:"otlp,omitempty"` + init sync.Once httpMetrics *httpMetrics allowedHosts map[string]struct{} hasHTTPSServer bool + meterProvider *sdkmetric.MeterProvider } type httpMetrics struct { @@ -147,6 +163,70 @@ func initHTTPMetrics(ctx caddy.Context, metrics *Metrics) { }, httpLabels) } +// provisionOTLP wires a MeterProvider that periodically reads the process-wide +// Prometheus registry and pushes the result via OTLP. The exporter and reader +// are autoconfigured from the standard OTEL_* environment variables, matching +// the ergonomics of the existing `tracing` directive. It is a no-op when +// m.OTLP is false, and honors OTEL_METRICS_EXPORTER=none (autoexport +// short-circuits to a no-op reader in that case). +func (m *Metrics) provisionOTLP(ctx caddy.Context) error { + if !m.OTLP { + return nil + } + + // Register a Prometheus -> OpenTelemetry bridge against the process-wide + // Prometheus registry as the *default* source the NewMetricReader below + // will read from. + // + // NB: despite the "With*" naming, autoexport.WithFallbackMetricProducer is + // a package-level setter (it returns nothing) — it mutates autoexport's + // internal producer registry and takes effect on the very next call to + // NewMetricReader. It is NOT a MetricOption and must not be passed as one. + // Users can still override the source by setting OTEL_METRICS_PRODUCERS. + reg := ctx.GetMetricsRegistry() + autoexport.WithFallbackMetricProducer(func(context.Context) (sdkmetric.Producer, error) { + return otelprom.NewMetricProducer(otelprom.WithGatherer(reg)), nil + }) + + reader, err := autoexport.NewMetricReader(ctx) + if err != nil { + return fmt.Errorf("creating OTLP metric reader: %w", err) + } + + version, _ := caddy.Version() + res, err := resource.Merge(resource.Default(), resource.NewSchemaless( + semconv.WebEngineName(ServerHeader), + semconv.WebEngineVersion(version), + )) + if err != nil { + return fmt.Errorf("building OTLP metrics resource: %w", err) + } + + m.meterProvider = sdkmetric.NewMeterProvider( + sdkmetric.WithResource(res), + sdkmetric.WithReader(reader), + ) + + return nil +} + +// shutdown flushes and tears down the OTLP MeterProvider if one was provisioned. +// Both ForceFlush and Shutdown are always attempted so that a flush failure +// does not prevent the reader goroutines from being stopped; errors from both +// are returned joined. +func (m *Metrics) shutdown(ctx context.Context) error { + if m == nil || m.meterProvider == nil { + return nil + } + + // ForceFlush gives the final collection a chance to reach the collector + // before the reader goroutine is stopped by Shutdown. + return errors.Join( + m.meterProvider.ForceFlush(ctx), + m.meterProvider.Shutdown(ctx), + ) +} + // scanConfigForHosts scans the HTTP app configuration to build a set of allowed hosts // for metrics collection, similar to how auto-HTTPS scans for domain names. func (m *Metrics) scanConfigForHosts(app *App) { @@ -234,7 +314,7 @@ func newMetricsInstrumentedRoute(ctx caddy.Context, handler string, next Handler func (h *metricsInstrumentedRoute) ServeHTTP(w http.ResponseWriter, r *http.Request) error { server := serverNameFromContext(r.Context()) labels := prometheus.Labels{"server": server, "handler": h.handler} - method := metrics.SanitizeMethod(r.Method) + method := caddymetrics.SanitizeMethod(r.Method) // the "code" value is set later, but initialized here to eliminate the possibility // of a panic statusLabels := prometheus.Labels{"server": server, "handler": h.handler, "method": method, "code": ""} @@ -264,7 +344,7 @@ func (h *metricsInstrumentedRoute) ServeHTTP(w http.ResponseWriter, r *http.Requ // being called when the headers are written. // Effectively the same behaviour as promhttp.InstrumentHandlerTimeToWriteHeader. writeHeaderRecorder := ShouldBufferFunc(func(status int, header http.Header) bool { - statusLabels["code"] = metrics.SanitizeCode(status) + statusLabels["code"] = caddymetrics.SanitizeCode(status) ttfb := time.Since(start).Seconds() h.metrics.httpMetrics.responseDuration.With(statusLabels).Observe(ttfb) return false @@ -280,7 +360,7 @@ func (h *metricsInstrumentedRoute) ServeHTTP(w http.ResponseWriter, r *http.Requ if statusLabels["code"] == "" { // we still sanitize it, even though it's likely to be 0. A 200 is // returned on fallthrough so we want to reflect that. - statusLabels["code"] = metrics.SanitizeCode(status) + statusLabels["code"] = caddymetrics.SanitizeCode(status) } h.metrics.httpMetrics.requestDuration.With(statusLabels).Observe(dur) diff --git a/modules/caddyhttp/metrics_test.go b/modules/caddyhttp/metrics_test.go index 987b3f342..d75b3cae1 100644 --- a/modules/caddyhttp/metrics_test.go +++ b/modules/caddyhttp/metrics_test.go @@ -523,6 +523,56 @@ func TestMetricsInstrumentedRoute(t *testing.T) { } } +func TestMetricsProvisionOTLPDisabled(t *testing.T) { + ctx, _ := caddy.NewContext(caddy.Context{Context: context.Background()}) + + m := &Metrics{OTLP: false} + + if err := m.provisionOTLP(ctx); err != nil { + t.Fatalf("provisionOTLP returned unexpected error: %v", err) + } + if m.meterProvider != nil { + t.Fatalf("meterProvider should remain nil when OTLP is disabled") + } + + // shutdown must be safe on a never-provisioned Metrics. + if err := m.shutdown(context.Background()); err != nil { + t.Fatalf("shutdown returned unexpected error: %v", err) + } +} + +func TestMetricsProvisionOTLPNoopExporter(t *testing.T) { + // OTEL_METRICS_EXPORTER=none makes autoexport return its built-in + // no-op reader, which avoids any network I/O while still exercising + // the full provisionOTLP -> shutdown lifecycle. + t.Setenv("OTEL_METRICS_EXPORTER", "none") + + ctx, _ := caddy.NewContext(caddy.Context{Context: context.Background()}) + + m := &Metrics{OTLP: true} + + if err := m.provisionOTLP(ctx); err != nil { + t.Fatalf("provisionOTLP returned unexpected error: %v", err) + } + if m.meterProvider == nil { + t.Fatalf("provisionOTLP did not create a MeterProvider") + } + + if err := m.shutdown(context.Background()); err != nil { + t.Fatalf("shutdown returned unexpected error: %v", err) + } +} + +// shutdown on a nil receiver is a convenience so App.Stop can call it +// without guarding against app.Metrics being unset. +func TestMetricsShutdownNilReceiver(t *testing.T) { + var m *Metrics + + if err := m.shutdown(context.Background()); err != nil { + t.Fatalf("shutdown on nil Metrics returned unexpected error: %v", err) + } +} + func BenchmarkMetricsInstrumentedRoute(b *testing.B) { ctx, _ := caddy.NewContext(caddy.Context{Context: context.Background()}) m := &Metrics{ diff --git a/modules/caddyhttp/tracing/tracer.go b/modules/caddyhttp/tracing/tracer.go index bb0f81fc3..5d71059ed 100644 --- a/modules/caddyhttp/tracing/tracer.go +++ b/modules/caddyhttp/tracing/tracer.go @@ -21,7 +21,6 @@ import ( ) const ( - webEngineName = "Caddy" defaultSpanName = "handler" nextCallCtxKey caddy.CtxKey = "nextCall" ) @@ -58,7 +57,7 @@ func newOpenTelemetryWrapper( } version, _ := caddy.Version() - res, err := ot.newResource(webEngineName, version) + res, err := ot.newResource(caddyhttp.ServerHeader, version) if err != nil { return ot, fmt.Errorf("creating resource error: %w", err) }