mirror of
https://github.com/caddyserver/caddy.git
synced 2026-04-26 18:59:50 -04:00
Merge branch 'master' into feat-https-rr-alpn
This commit is contained in:
commit
fd34f9cc81
4
.github/SECURITY.md
vendored
4
.github/SECURITY.md
vendored
@ -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.
|
||||
|
||||
|
||||
|
||||
217
AGENTS.md
Normal file
217
AGENTS.md
Normal file
@ -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
|
||||
7
admin.go
7
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(
|
||||
@ -1061,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{
|
||||
|
||||
@ -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)
|
||||
|
||||
6
caddy.go
6
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
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
|
||||
@ -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)}
|
||||
}
|
||||
|
||||
@ -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())
|
||||
}
|
||||
|
||||
@ -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(`
|
||||
|
||||
@ -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
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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")
|
||||
}
|
||||
|
||||
7
caddytest/integration/testdata/issue_7557_invalid_subdirective_snippet.conf
vendored
Normal file
7
caddytest/integration/testdata/issue_7557_invalid_subdirective_snippet.conf
vendored
Normal file
@ -0,0 +1,7 @@
|
||||
# Used by import_block_snippet_invalid_subdirective.caddyfiletest
|
||||
|
||||
(test) {
|
||||
reverse_proxy {
|
||||
{block}
|
||||
}
|
||||
}
|
||||
32
go.mod
32
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
|
||||
@ -30,17 +30,19 @@ 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/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.step.sm/crypto v0.77.2
|
||||
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
|
||||
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/crypto/x509roots/fallback v0.0.0-20260213171211-a408498e5541
|
||||
golang.org/x/net v0.53.0
|
||||
golang.org/x/sync v0.20.0
|
||||
golang.org/x/term v0.42.0
|
||||
@ -68,9 +70,9 @@ 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.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
|
||||
@ -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,14 +105,13 @@ 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
|
||||
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/mod v0.35.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/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
|
||||
|
||||
64
go.sum
64
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=
|
||||
@ -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=
|
||||
@ -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=
|
||||
@ -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=
|
||||
@ -458,8 +458,8 @@ golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v
|
||||
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/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.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=
|
||||
@ -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.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=
|
||||
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=
|
||||
|
||||
54
internal/logmarshalers.go
Normal file
54
internal/logmarshalers.go
Normal file
@ -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)
|
||||
)
|
||||
20
listen.go
20
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)
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
25
listeners.go
25
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) {
|
||||
@ -611,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
|
||||
}
|
||||
@ -629,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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -68,6 +68,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
|
||||
@ -97,11 +98,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
|
||||
@ -202,6 +207,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
|
||||
@ -213,8 +221,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{
|
||||
@ -659,9 +665,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()))
|
||||
@ -786,6 +790,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
|
||||
}
|
||||
|
||||
@ -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}"
|
||||
|
||||
|
||||
@ -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)
|
||||
)
|
||||
|
||||
@ -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()...)
|
||||
}
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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{
|
||||
|
||||
@ -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
|
||||
|
||||
@ -67,7 +67,7 @@ func parseCaddyfile(h httpcaddyfile.Helper) (caddyhttp.MiddlewareHandler, error)
|
||||
// lb_retries <retries>
|
||||
// lb_try_duration <duration>
|
||||
// lb_try_interval <interval>
|
||||
// lb_retry_match <request-matcher>
|
||||
// lb_retry_match <matcher>
|
||||
//
|
||||
// # active health checking
|
||||
// health_uri <uri>
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
12
replacer.go
12
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()
|
||||
|
||||
18
usagepool.go
18
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
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user