Compare commits

..

28 Commits

Author SHA1 Message Date
copilot-swe-agent[bot] 1ca4406a36 Merge origin/master into rewrite-modify-query
Co-authored-by: francislavoie <2111701+francislavoie@users.noreply.github.com>
2026-05-08 18:18:51 +00:00
Matthew Holt 9c78b97f9e fastcgi: Fix lint
Tests / test (s390x on IBM Z) (push) Has been skipped
Tests / goreleaser-check (push) Has been skipped
Cross-Build / build (~1.26.0, 1.26, dragonfly) (push) Failing after 42s
Cross-Build / build (~1.26.0, 1.26, aix) (push) Failing after 51s
Tests / test (./cmd/caddy/caddy, ~1.26.0, ubuntu-latest, 0, 1.26, linux) (push) Failing after 1m28s
Cross-Build / build (~1.26.0, 1.26, freebsd) (push) Successful in 2m42s
Cross-Build / build (~1.26.0, 1.26, netbsd) (push) Successful in 1m24s
Cross-Build / build (~1.26.0, 1.26, darwin) (push) Successful in 2m59s
Cross-Build / build (~1.26.0, 1.26, illumos) (push) Successful in 2m21s
Cross-Build / build (~1.26.0, 1.26, linux) (push) Successful in 2m32s
Cross-Build / build (~1.26.0, 1.26, solaris) (push) Successful in 1m24s
Lint / dependency-review (push) Failing after 24s
Cross-Build / build (~1.26.0, 1.26, windows) (push) Successful in 1m58s
Cross-Build / build (~1.26.0, 1.26, openbsd) (push) Successful in 2m15s
OpenSSF Scorecard supply-chain security / Scorecard analysis (push) Failing after 31s
Lint / govulncheck (push) Successful in 1m55s
Lint / lint (ubuntu-latest, linux) (push) Successful in 2m41s
Tests / test (./cmd/caddy/caddy, ~1.26.0, macos-14, 0, 1.26, mac) (push) Has been cancelled
Tests / test (./cmd/caddy/caddy.exe, ~1.26.0, windows-latest, True, 1.26, windows) (push) Has been cancelled
Lint / lint (macos-14, mac) (push) Has been cancelled
Lint / lint (windows-latest, windows) (push) Has been cancelled
2026-05-08 10:46:28 -06:00
Kévin Dunglas fb324331f4 Merge commit from fork
Tests / test (s390x on IBM Z) (push) Has been skipped
Tests / goreleaser-check (push) Has been skipped
Tests / test (./cmd/caddy/caddy, ~1.26.0, ubuntu-latest, 0, 1.26, linux) (push) Failing after 1m15s
Cross-Build / build (~1.26.0, 1.26, aix) (push) Failing after 1m39s
Cross-Build / build (~1.26.0, 1.26, illumos) (push) Successful in 1m25s
Cross-Build / build (~1.26.0, 1.26, darwin) (push) Successful in 3m42s
Cross-Build / build (~1.26.0, 1.26, freebsd) (push) Successful in 3m42s
Cross-Build / build (~1.26.0, 1.26, dragonfly) (push) Successful in 3m45s
Cross-Build / build (~1.26.0, 1.26, linux) (push) Successful in 2m26s
Cross-Build / build (~1.26.0, 1.26, netbsd) (push) Successful in 1m25s
Lint / govulncheck (push) Successful in 1m14s
Lint / dependency-review (push) Failing after 24s
Cross-Build / build (~1.26.0, 1.26, openbsd) (push) Successful in 2m11s
Cross-Build / build (~1.26.0, 1.26, solaris) (push) Successful in 2m48s
Cross-Build / build (~1.26.0, 1.26, windows) (push) Successful in 2m48s
Lint / lint (ubuntu-latest, linux) (push) Failing after 2m36s
OpenSSF Scorecard supply-chain security / Scorecard analysis (push) Failing after 26s
Tests / test (./cmd/caddy/caddy, ~1.26.0, macos-14, 0, 1.26, mac) (push) Has been cancelled
Tests / test (./cmd/caddy/caddy.exe, ~1.26.0, windows-latest, True, 1.26, windows) (push) Has been cancelled
Lint / lint (macos-14, mac) (push) Has been cancelled
Lint / lint (windows-latest, windows) (push) Has been cancelled
Both fallbacks in splitPos relied on golang.org/x/text/search with
search.IgnoreCase, which performs Unicode equivalence matching far beyond
ASCII case folding. Combined with the validated-ASCII guarantee on every
SplitPath entry, that fallback turned non-PHP filenames into PHP scripts:

- when the inner loop hit a non-ASCII byte and the IndexString fallback
  returned -1, the loop broke without resetting match=false, so a stale
  match=true caused a non-existent .php to be reported (PoC:
  "/name.<U+00A1>.txt").
- search.IgnoreCase folded fullwidth, mathematical and circled letters
  onto ASCII, so "/shell.<math sans-serif php>",
  "/shell.<fullwidth p>hp", "/shell.<circled php>" were all detected as
  ".php" files.

Replace the fallback with strict byte-level ASCII case-insensitive
matching: any byte >= utf8.RuneSelf in the path can never be part of a
match, since SplitPath entries are validated ASCII-only and lower-cased
in Provision(). This keeps the hot path branch-light and removes the
x/text/search dependency from the main module.

Reported against FrankenPHP as GHSA-3g8v-8r37-cgjm and
GHSA-v4h7-cj44-8fc8. The vulnerable function in this module was adapted
from the same FrankenPHP code.
2026-05-07 13:59:42 -06:00
tomholford 0780d4489c httpcaddyfile: accept duration strings for log sampling interval (#7694)
Co-authored-by: tomholford <tomholford@users.noreply.github.com>
2026-05-07 18:32:20 +00:00
Zen Dodd d2172bea61 chore: Fix golangci-lint 2.12.1 findings (#7690)
Tests / test (s390x on IBM Z) (push) Has been skipped
Tests / goreleaser-check (push) Has been skipped
Cross-Build / build (~1.26.0, 1.26, darwin) (push) Successful in 1m25s
Cross-Build / build (~1.26.0, 1.26, freebsd) (push) Failing after 1m41s
Cross-Build / build (~1.26.0, 1.26, illumos) (push) Successful in 1m24s
Cross-Build / build (~1.26.0, 1.26, dragonfly) (push) Successful in 3m23s
Cross-Build / build (~1.26.0, 1.26, aix) (push) Successful in 3m43s
Tests / test (./cmd/caddy/caddy, ~1.26.0, ubuntu-latest, 0, 1.26, linux) (push) Failing after 3m46s
Cross-Build / build (~1.26.0, 1.26, linux) (push) Successful in 2m18s
Cross-Build / build (~1.26.0, 1.26, netbsd) (push) Successful in 1m24s
Cross-Build / build (~1.26.0, 1.26, openbsd) (push) Successful in 1m41s
Lint / govulncheck (push) Successful in 1m14s
Cross-Build / build (~1.26.0, 1.26, solaris) (push) Successful in 2m36s
Cross-Build / build (~1.26.0, 1.26, windows) (push) Successful in 2m36s
Lint / dependency-review (push) Failing after 1m14s
Lint / lint (ubuntu-latest, linux) (push) Successful in 2m43s
OpenSSF Scorecard supply-chain security / Scorecard analysis (push) Failing after 7m3s
Tests / test (./cmd/caddy/caddy, ~1.26.0, macos-14, 0, 1.26, mac) (push) Has been cancelled
Tests / test (./cmd/caddy/caddy.exe, ~1.26.0, windows-latest, True, 1.26, windows) (push) Has been cancelled
Lint / lint (macos-14, mac) (push) Has been cancelled
Lint / lint (windows-latest, windows) (push) Has been cancelled
2026-05-07 03:40:26 -04:00
Zen Dodd c7c9f3108a caddyauth: Revert "set user placeholders before auth rejection (#7685)" (#7688)
Tests / test (s390x on IBM Z) (push) Has been skipped
Tests / goreleaser-check (push) Has been skipped
Cross-Build / build (~1.26.0, 1.26, darwin) (push) Successful in 1m27s
Cross-Build / build (~1.26.0, 1.26, freebsd) (push) Failing after 1m42s
Cross-Build / build (~1.26.0, 1.26, illumos) (push) Successful in 1m23s
Cross-Build / build (~1.26.0, 1.26, dragonfly) (push) Successful in 3m33s
Cross-Build / build (~1.26.0, 1.26, aix) (push) Successful in 3m38s
Tests / test (./cmd/caddy/caddy, ~1.26.0, ubuntu-latest, 0, 1.26, linux) (push) Failing after 3m42s
Cross-Build / build (~1.26.0, 1.26, linux) (push) Successful in 2m13s
Cross-Build / build (~1.26.0, 1.26, netbsd) (push) Successful in 1m23s
Lint / govulncheck (push) Successful in 1m41s
Cross-Build / build (~1.26.0, 1.26, openbsd) (push) Successful in 2m22s
Lint / dependency-review (push) Failing after 24s
Lint / lint (ubuntu-latest, linux) (push) Failing after 2m37s
Cross-Build / build (~1.26.0, 1.26, solaris) (push) Successful in 3m6s
Cross-Build / build (~1.26.0, 1.26, windows) (push) Successful in 3m7s
OpenSSF Scorecard supply-chain security / Scorecard analysis (push) Failing after 6m14s
Tests / test (./cmd/caddy/caddy, ~1.26.0, macos-14, 0, 1.26, mac) (push) Has been cancelled
Tests / test (./cmd/caddy/caddy.exe, ~1.26.0, windows-latest, True, 1.26, windows) (push) Has been cancelled
Lint / lint (macos-14, mac) (push) Has been cancelled
Lint / lint (windows-latest, windows) (push) Has been cancelled
This reverts commit 7e77eec0ae.
2026-05-05 09:12:46 -06:00
Rayan Salhab 7e77eec0ae caddyauth: set user placeholders before auth rejection (#7685)
Tests / test (s390x on IBM Z) (push) Has been skipped
Tests / goreleaser-check (push) Has been skipped
Tests / test (./cmd/caddy/caddy, ~1.26.0, ubuntu-latest, 0, 1.26, linux) (push) Failing after 1m19s
Cross-Build / build (~1.26.0, 1.26, aix) (push) Failing after 2m12s
Cross-Build / build (~1.26.0, 1.26, dragonfly) (push) Successful in 1m32s
Cross-Build / build (~1.26.0, 1.26, illumos) (push) Successful in 1m22s
Cross-Build / build (~1.26.0, 1.26, darwin) (push) Successful in 4m16s
Cross-Build / build (~1.26.0, 1.26, freebsd) (push) Successful in 2m32s
Cross-Build / build (~1.26.0, 1.26, linux) (push) Successful in 1m23s
Cross-Build / build (~1.26.0, 1.26, netbsd) (push) Successful in 2m11s
Cross-Build / build (~1.26.0, 1.26, solaris) (push) Successful in 1m23s
Cross-Build / build (~1.26.0, 1.26, openbsd) (push) Successful in 2m37s
Lint / lint (ubuntu-latest, linux) (push) Failing after 1m45s
Cross-Build / build (~1.26.0, 1.26, windows) (push) Successful in 2m27s
Lint / dependency-review (push) Failing after 56s
Lint / govulncheck (push) Successful in 2m26s
OpenSSF Scorecard supply-chain security / Scorecard analysis (push) Failing after 6m31s
Tests / test (./cmd/caddy/caddy, ~1.26.0, macos-14, 0, 1.26, mac) (push) Has been cancelled
Tests / test (./cmd/caddy/caddy.exe, ~1.26.0, windows-latest, True, 1.26, windows) (push) Has been cancelled
Lint / lint (macos-14, mac) (push) Has been cancelled
Lint / lint (windows-latest, windows) (push) Has been cancelled
* caddyauth: set user placeholders before auth rejection
* docs: update auth placeholder comment
2026-05-03 13:40:11 +10:00
Felix Eckhofer ef496e58ef caddytls: Expand ACME credentials (#7554)
Tests / test (s390x on IBM Z) (push) Has been skipped
Tests / goreleaser-check (push) Has been skipped
Cross-Build / build (~1.26.0, 1.26, aix) (push) Failing after 1m41s
Cross-Build / build (~1.26.0, 1.26, dragonfly) (push) Failing after 1m44s
Tests / test (./cmd/caddy/caddy, ~1.26.0, ubuntu-latest, 0, 1.26, linux) (push) Failing after 2m45s
Cross-Build / build (~1.26.0, 1.26, freebsd) (push) Successful in 4m1s
Cross-Build / build (~1.26.0, 1.26, netbsd) (push) Successful in 1m26s
Cross-Build / build (~1.26.0, 1.26, linux) (push) Successful in 2m38s
Cross-Build / build (~1.26.0, 1.26, solaris) (push) Successful in 1m28s
Cross-Build / build (~1.26.0, 1.26, darwin) (push) Successful in 6m2s
Cross-Build / build (~1.26.0, 1.26, illumos) (push) Successful in 4m33s
Cross-Build / build (~1.26.0, 1.26, openbsd) (push) Successful in 2m31s
Cross-Build / build (~1.26.0, 1.26, windows) (push) Successful in 2m39s
Lint / dependency-review (push) Failing after 1m22s
Lint / lint (ubuntu-latest, linux) (push) Failing after 2m13s
Lint / govulncheck (push) Successful in 2m53s
OpenSSF Scorecard supply-chain security / Scorecard analysis (push) Failing after 6m41s
Tests / test (./cmd/caddy/caddy, ~1.26.0, macos-14, 0, 1.26, mac) (push) Has been cancelled
Tests / test (./cmd/caddy/caddy.exe, ~1.26.0, windows-latest, True, 1.26, windows) (push) Has been cancelled
Lint / lint (macos-14, mac) (push) Has been cancelled
Lint / lint (windows-latest, windows) (push) Has been cancelled
* caddytls: Expand ACME credentials

This allows using global placeholders such as {file./run/secrets/key_id}
when setting up the tls configuration.

* chore(formatting): gofmt on acmeissuer_test
2026-05-03 07:13:57 +10:00
Amemoyoi 18ab0f955f admin: reject non-canonical config array indices (#7592)
Tests / test (s390x on IBM Z) (push) Has been skipped
Tests / goreleaser-check (push) Has been skipped
Cross-Build / build (~1.26.0, 1.26, darwin) (push) Successful in 1m31s
Cross-Build / build (~1.26.0, 1.26, aix) (push) Failing after 1m47s
Cross-Build / build (~1.26.0, 1.26, illumos) (push) Successful in 1m33s
Cross-Build / build (~1.26.0, 1.26, dragonfly) (push) Successful in 3m31s
Tests / test (./cmd/caddy/caddy, ~1.26.0, ubuntu-latest, 0, 1.26, linux) (push) Failing after 3m35s
Cross-Build / build (~1.26.0, 1.26, freebsd) (push) Successful in 3m34s
Cross-Build / build (~1.26.0, 1.26, linux) (push) Successful in 2m20s
Cross-Build / build (~1.26.0, 1.26, netbsd) (push) Successful in 1m28s
Cross-Build / build (~1.26.0, 1.26, windows) (push) Successful in 1m50s
Lint / govulncheck (push) Successful in 1m17s
Cross-Build / build (~1.26.0, 1.26, openbsd) (push) Successful in 2m37s
Cross-Build / build (~1.26.0, 1.26, solaris) (push) Successful in 2m36s
Lint / dependency-review (push) Failing after 1m16s
Lint / lint (ubuntu-latest, linux) (push) Successful in 2m46s
OpenSSF Scorecard supply-chain security / Scorecard analysis (push) Failing after 7m10s
Tests / test (./cmd/caddy/caddy, ~1.26.0, macos-14, 0, 1.26, mac) (push) Has been cancelled
Tests / test (./cmd/caddy/caddy.exe, ~1.26.0, windows-latest, True, 1.26, windows) (push) Has been cancelled
Lint / lint (macos-14, mac) (push) Has been cancelled
Lint / lint (windows-latest, windows) (push) Has been cancelled
* admin: reject non-canonical config array indices

* admin: expand canonical array index test coverage

* Update admin.go

Co-authored-by: Matt Holt <mholt@users.noreply.github.com>

* Update admin.go

Co-authored-by: Matt Holt <mholt@users.noreply.github.com>

* admin: improve canonical array index test diagnostics

---------

Co-authored-by: Matt Holt <mholt@users.noreply.github.com>
2026-04-30 09:39:57 -06:00
mfrischknecht 6a64bb2ce5 listeners: clean up stale Unix socket files on Windows (#7676)
Tests / test (s390x on IBM Z) (push) Has been skipped
Tests / goreleaser-check (push) Has been skipped
Cross-Build / build (~1.26.0, 1.26, darwin) (push) Successful in 1m35s
Cross-Build / build (~1.26.0, 1.26, dragonfly) (push) Failing after 1m52s
Cross-Build / build (~1.26.0, 1.26, freebsd) (push) Successful in 3m33s
Cross-Build / build (~1.26.0, 1.26, aix) (push) Successful in 3m49s
Tests / test (./cmd/caddy/caddy, ~1.26.0, ubuntu-latest, 0, 1.26, linux) (push) Failing after 4m0s
Cross-Build / build (~1.26.0, 1.26, linux) (push) Successful in 2m6s
Cross-Build / build (~1.26.0, 1.26, illumos) (push) Failing after 2m36s
Lint / lint (ubuntu-latest, linux) (push) Failing after 2m5s
Cross-Build / build (~1.26.0, 1.26, netbsd) (push) Successful in 2m41s
Cross-Build / build (~1.26.0, 1.26, openbsd) (push) Successful in 2m31s
Cross-Build / build (~1.26.0, 1.26, windows) (push) Successful in 2m26s
Cross-Build / build (~1.26.0, 1.26, solaris) (push) Successful in 2m31s
Lint / dependency-review (push) Failing after 1m2s
Lint / govulncheck (push) Failing after 2m35s
OpenSSF Scorecard supply-chain security / Scorecard analysis (push) Failing after 6m54s
Tests / test (./cmd/caddy/caddy, ~1.26.0, macos-14, 0, 1.26, mac) (push) Has been cancelled
Tests / test (./cmd/caddy/caddy.exe, ~1.26.0, windows-latest, True, 1.26, windows) (push) Has been cancelled
Lint / lint (macos-14, mac) (push) Has been cancelled
Lint / lint (windows-latest, windows) (push) Has been cancelled
* Delete old unix domain socket files on Windows

While Windows doesn't have the need to reuse a socket file descriptor
by dup()ing it on config reloads, there still is a valid need for an
equivalent to the `syscall.Unlink()` call in listen_unix.go (also in
`reuseUnixSocket`).

If a previous Caddy instance didn't terminate properly, the chances it
will leave behind a socket file are very high, breaking all subsequent
starting attempts. Other than for regular files, Windows seemingly has
no way for a process to flag a UNIX domain socket file with `FILE_DELETE_ON_CLOSE`,
which means this scenario can never be avoided entirely (e.g. in the case
of crashes).

For the long comment on `isAbstractUnixSocket`: the logic itself is likely
of dubious value, but I thought it better to explicitly reference the
issue, as I have just spent half an hour searching the web to figure out
whether abstract names will work or not on Windows. At least, the logic
as-is should now do the sensible thing if these are ever implemented
properly (and it matches what the Golang standard library does internally).

* Add a dial attempt to check for active server processes

As @steadytao pointed out (thanks!), the previous code didn't have
solid proof that an existing unix socket file had really been orphaned,
as it's also possible that there's another server process (still running).

This would still give the Windows implementation parity with the unix
one (as that one also unlinks the socket file without further checks),
but I've performed a couple of small tests and found this way of handling
socket files still problematic at least problematic if Caddy is used as
a reverse proxy in real world scenarios.

In tests with a simple Caddyfile that only declares an admin socket,
starting two caddy instances with the same Caddyfile works and behaves
like one would expect: the second instance removes the first instance's
socket file and "wins" the race.

When Caddy is used as a reverse proxy, though, what'll happen is more
complicated: While the second instance wins the race for the admin
socket, as long as the Caddyfile specifies a TCP downstream socket,
the second process will not be able to take this one over from the first
(also to be expected, that's how socket binding usually works).

This results in a rather broken state: The first process still holds on
to its TCP listening sockets, the second process fails to start because
of the error in its listening attempt, leaving an orphaned admin socket
file in the file system. Afterwards, the second process won't be running
and the first _will_ be running but unable to be controlled because its
admin socket has been replaced. This leaves the system in another state
that is bad from an ops perspective.

With this new change, we try first to connect to any unix socket that
isn't already covered by our current process (with a very low timeout)
and can easily decide if the socket is still in use by another process:

- If the connection is accepted, there's obviously a server process.

- If Windows returns WSACONNREFUSED [^1], there is either no active
  server process for the socket file anymore, or the socket file does
  not exist.

- Any other errors are likely a sign that there still is a server process
  (e.g. a timeout would indicate that it's just slow in accepting new
  connection attempts).

[^1]: https://learn.microsoft.com/en-us/windows/win32/winsock/windows-sockets-error-codes-2#wsaeconnrefused

* chore: tidy Windows unix socket reuse helper

---------

Co-authored-by: Zen Dodd <mail@steadytao.com>
2026-04-29 21:52:04 +10:00
Matt Holt 4d6945769d reverseproxy: Add ability to clear dynamic upstreams cache during retries (#7662)
Tests / test (./cmd/caddy/caddy, ~1.26.0, ubuntu-latest, 0, 1.26, linux) (push) Failing after 2m41s
Tests / test (s390x on IBM Z) (push) Has been skipped
Tests / goreleaser-check (push) Has been skipped
Cross-Build / build (~1.26.0, 1.26, aix) (push) Successful in 1m27s
Cross-Build / build (~1.26.0, 1.26, darwin) (push) Successful in 1m27s
Cross-Build / build (~1.26.0, 1.26, dragonfly) (push) Successful in 1m24s
Cross-Build / build (~1.26.0, 1.26, freebsd) (push) Successful in 1m21s
Cross-Build / build (~1.26.0, 1.26, illumos) (push) Successful in 1m25s
Cross-Build / build (~1.26.0, 1.26, linux) (push) Successful in 1m26s
Cross-Build / build (~1.26.0, 1.26, netbsd) (push) Successful in 1m43s
Cross-Build / build (~1.26.0, 1.26, openbsd) (push) Successful in 1m27s
Cross-Build / build (~1.26.0, 1.26, solaris) (push) Successful in 1m26s
Cross-Build / build (~1.26.0, 1.26, windows) (push) Successful in 1m27s
Lint / lint (ubuntu-latest, linux) (push) Successful in 2m27s
Lint / govulncheck (push) Successful in 1m49s
Lint / dependency-review (push) Failing after 1m3s
OpenSSF Scorecard supply-chain security / Scorecard analysis (push) Failing after 7m12s
Tests / test (./cmd/caddy/caddy, ~1.26.0, macos-14, 0, 1.26, mac) (push) Has been cancelled
Tests / test (./cmd/caddy/caddy.exe, ~1.26.0, windows-latest, True, 1.26, windows) (push) Has been cancelled
Lint / lint (macos-14, mac) (push) Has been cancelled
Lint / lint (windows-latest, windows) (push) Has been cancelled
* reverseproxy: Add ability to clear dynamic upstreams cache during retries

This is an optional interface for dynamic upstream modules to implement if they cache results.

TODO: More documentation; this is an experiment.

* Add some godoc

* Export interface; update godoc
2026-04-28 09:16:18 -06:00
Amemoyoi 2d33271482 admin: require path segment boundary in remote access control (#7673)
Tests / test (./cmd/caddy/caddy, ~1.26.0, ubuntu-latest, 0, 1.26, linux) (push) Failing after 1m21s
Tests / test (s390x on IBM Z) (push) Has been skipped
Tests / goreleaser-check (push) Has been skipped
Cross-Build / build (~1.26.0, 1.26, aix) (push) Successful in 1m24s
Cross-Build / build (~1.26.0, 1.26, darwin) (push) Successful in 1m28s
Cross-Build / build (~1.26.0, 1.26, dragonfly) (push) Successful in 1m26s
Cross-Build / build (~1.26.0, 1.26, freebsd) (push) Successful in 1m26s
Cross-Build / build (~1.26.0, 1.26, illumos) (push) Successful in 1m25s
Cross-Build / build (~1.26.0, 1.26, linux) (push) Successful in 1m28s
Cross-Build / build (~1.26.0, 1.26, netbsd) (push) Successful in 1m27s
Cross-Build / build (~1.26.0, 1.26, openbsd) (push) Successful in 1m21s
Cross-Build / build (~1.26.0, 1.26, solaris) (push) Successful in 1m26s
Cross-Build / build (~1.26.0, 1.26, windows) (push) Successful in 1m26s
Lint / lint (ubuntu-latest, linux) (push) Successful in 1m59s
Lint / govulncheck (push) Successful in 1m16s
Lint / dependency-review (push) Failing after 24s
OpenSSF Scorecard supply-chain security / Scorecard analysis (push) Failing after 27s
Tests / test (./cmd/caddy/caddy, ~1.26.0, macos-14, 0, 1.26, mac) (push) Has been cancelled
Tests / test (./cmd/caddy/caddy.exe, ~1.26.0, windows-latest, True, 1.26, windows) (push) Has been cancelled
Lint / lint (macos-14, mac) (push) Has been cancelled
Lint / lint (windows-latest, windows) (push) Has been cancelled
2026-04-27 08:43:39 -06:00
dependabot[bot] c653e7d61a build(deps): bump github.com/jackc/pgx/v5 from 5.9.0 to 5.9.2 (#7668)
Tests / test (./cmd/caddy/caddy, ~1.26.0, ubuntu-latest, 0, 1.26, linux) (push) Failing after 1m21s
Tests / test (s390x on IBM Z) (push) Has been skipped
Tests / goreleaser-check (push) Has been skipped
Cross-Build / build (~1.26.0, 1.26, aix) (push) Successful in 1m26s
Cross-Build / build (~1.26.0, 1.26, darwin) (push) Successful in 1m28s
Cross-Build / build (~1.26.0, 1.26, dragonfly) (push) Successful in 1m31s
Cross-Build / build (~1.26.0, 1.26, freebsd) (push) Successful in 1m26s
Cross-Build / build (~1.26.0, 1.26, illumos) (push) Successful in 1m24s
Cross-Build / build (~1.26.0, 1.26, linux) (push) Successful in 1m25s
Cross-Build / build (~1.26.0, 1.26, netbsd) (push) Successful in 1m28s
Cross-Build / build (~1.26.0, 1.26, openbsd) (push) Successful in 1m26s
Cross-Build / build (~1.26.0, 1.26, solaris) (push) Successful in 1m26s
Cross-Build / build (~1.26.0, 1.26, windows) (push) Successful in 1m25s
Lint / lint (ubuntu-latest, linux) (push) Successful in 1m59s
Lint / govulncheck (push) Successful in 1m12s
Lint / dependency-review (push) Failing after 22s
OpenSSF Scorecard supply-chain security / Scorecard analysis (push) Failing after 28s
Tests / test (./cmd/caddy/caddy, ~1.26.0, macos-14, 0, 1.26, mac) (push) Has been cancelled
Tests / test (./cmd/caddy/caddy.exe, ~1.26.0, windows-latest, True, 1.26, windows) (push) Has been cancelled
Lint / lint (macos-14, mac) (push) Has been cancelled
Lint / lint (windows-latest, windows) (push) Has been cancelled
Bumps [github.com/jackc/pgx/v5](https://github.com/jackc/pgx) from 5.9.0 to 5.9.2.
- [Changelog](https://github.com/jackc/pgx/blob/master/CHANGELOG.md)
- [Commits](https://github.com/jackc/pgx/compare/v5.9.0...v5.9.2)

---
updated-dependencies:
- dependency-name: github.com/jackc/pgx/v5
  dependency-version: 5.9.2
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-26 23:51:26 +10:00
Zen Dodd c1918ff1ad httpcaddyfile: inherit global ACME issuer settings in tls shortcuts (#7617) 2026-04-26 23:39:57 +10:00
Zen Dodd fdbef2a6ef logging: add regression coverage for rotated file mode (#7620) 2026-04-26 23:30:44 +10:00
Kévin Dunglas 2a3ed96f8c metrics: Implement pushing via OLTP (#7664)
Tests / test (./cmd/caddy/caddy, ~1.26.0, ubuntu-latest, 0, 1.26, linux) (push) Failing after 1m23s
Tests / test (s390x on IBM Z) (push) Has been skipped
Tests / goreleaser-check (push) Has been skipped
Cross-Build / build (~1.26.0, 1.26, aix) (push) Successful in 1m30s
Cross-Build / build (~1.26.0, 1.26, darwin) (push) Successful in 1m25s
Cross-Build / build (~1.26.0, 1.26, dragonfly) (push) Successful in 1m23s
Cross-Build / build (~1.26.0, 1.26, freebsd) (push) Successful in 1m26s
Cross-Build / build (~1.26.0, 1.26, illumos) (push) Successful in 1m25s
Cross-Build / build (~1.26.0, 1.26, linux) (push) Successful in 1m26s
Cross-Build / build (~1.26.0, 1.26, netbsd) (push) Successful in 1m23s
Cross-Build / build (~1.26.0, 1.26, openbsd) (push) Successful in 1m24s
Cross-Build / build (~1.26.0, 1.26, solaris) (push) Successful in 1m24s
Cross-Build / build (~1.26.0, 1.26, windows) (push) Successful in 1m26s
Lint / lint (ubuntu-latest, linux) (push) Successful in 1m58s
Lint / govulncheck (push) Successful in 1m13s
Lint / dependency-review (push) Failing after 24s
OpenSSF Scorecard supply-chain security / Scorecard analysis (push) Failing after 29s
Tests / test (./cmd/caddy/caddy, ~1.26.0, macos-14, 0, 1.26, mac) (push) Has been cancelled
Tests / test (./cmd/caddy/caddy.exe, ~1.26.0, windows-latest, True, 1.26, windows) (push) Has been cancelled
Lint / lint (macos-14, mac) (push) Has been cancelled
Lint / lint (windows-latest, windows) (push) Has been cancelled
2026-04-25 06:52:08 -04:00
Francis Lavoie 355c178213 chore: Use atomics where appropriate (#7648)
* chore: Use atomics where appropriate

* Use atomic for shutdownAt
2026-04-25 03:47:54 -04:00
Matthew Holt f6ee80be1b go.mod: Upgrade dependencies including CertMagic
Tests / test (./cmd/caddy/caddy, ~1.26.0, ubuntu-latest, 0, 1.26, linux) (push) Failing after 2m32s
Tests / test (s390x on IBM Z) (push) Has been skipped
Tests / goreleaser-check (push) Has been skipped
Cross-Build / build (~1.26.0, 1.26, aix) (push) Successful in 1m24s
Cross-Build / build (~1.26.0, 1.26, darwin) (push) Successful in 1m28s
Cross-Build / build (~1.26.0, 1.26, dragonfly) (push) Successful in 1m26s
Cross-Build / build (~1.26.0, 1.26, freebsd) (push) Successful in 1m25s
Cross-Build / build (~1.26.0, 1.26, illumos) (push) Successful in 1m22s
Cross-Build / build (~1.26.0, 1.26, linux) (push) Successful in 1m26s
Cross-Build / build (~1.26.0, 1.26, netbsd) (push) Successful in 1m26s
Cross-Build / build (~1.26.0, 1.26, openbsd) (push) Successful in 1m26s
Cross-Build / build (~1.26.0, 1.26, solaris) (push) Successful in 1m23s
Cross-Build / build (~1.26.0, 1.26, windows) (push) Successful in 1m24s
Lint / lint (ubuntu-latest, linux) (push) Successful in 2m23s
Lint / govulncheck (push) Successful in 1m43s
Lint / dependency-review (push) Failing after 1m5s
OpenSSF Scorecard supply-chain security / Scorecard analysis (push) Failing after 7m6s
Tests / test (./cmd/caddy/caddy, ~1.26.0, macos-14, 0, 1.26, mac) (push) Has been cancelled
Tests / test (./cmd/caddy/caddy.exe, ~1.26.0, windows-latest, True, 1.26, windows) (push) Has been cancelled
Lint / lint (macos-14, mac) (push) Has been cancelled
Lint / lint (windows-latest, windows) (push) Has been cancelled
2026-04-24 11:40:54 -06:00
Matthew Holt 48c08e3890 admin: Limit config size (by @omercnet)
GitHub was giving me errors related to merge status so we are doing this instead
2026-04-24 11:28:40 -06:00
Matthew Holt cf42f61566 Typo fix in security policy 2026-04-24 09:50:06 -06:00
Zen Dodd 41aee97386 core: propagate ECH keys to the QUIC listener (#7670)
Tests / test (./cmd/caddy/caddy, ~1.26.0, ubuntu-latest, 0, 1.26, linux) (push) Failing after 1m19s
Tests / test (s390x on IBM Z) (push) Has been skipped
Tests / goreleaser-check (push) Has been skipped
Cross-Build / build (~1.26.0, 1.26, aix) (push) Successful in 1m27s
Cross-Build / build (~1.26.0, 1.26, darwin) (push) Successful in 1m25s
Cross-Build / build (~1.26.0, 1.26, dragonfly) (push) Successful in 1m26s
Cross-Build / build (~1.26.0, 1.26, freebsd) (push) Successful in 1m23s
Cross-Build / build (~1.26.0, 1.26, illumos) (push) Successful in 1m25s
Cross-Build / build (~1.26.0, 1.26, linux) (push) Successful in 1m22s
Cross-Build / build (~1.26.0, 1.26, netbsd) (push) Successful in 1m23s
Cross-Build / build (~1.26.0, 1.26, openbsd) (push) Successful in 1m22s
Cross-Build / build (~1.26.0, 1.26, solaris) (push) Successful in 1m24s
Cross-Build / build (~1.26.0, 1.26, windows) (push) Successful in 1m34s
Lint / lint (ubuntu-latest, linux) (push) Successful in 2m2s
Lint / govulncheck (push) Successful in 1m20s
Lint / dependency-review (push) Failing after 22s
OpenSSF Scorecard supply-chain security / Scorecard analysis (push) Failing after 24s
Tests / test (./cmd/caddy/caddy, ~1.26.0, macos-14, 0, 1.26, mac) (push) Has been cancelled
Tests / test (./cmd/caddy/caddy.exe, ~1.26.0, windows-latest, True, 1.26, windows) (push) Has been cancelled
Lint / lint (macos-14, mac) (push) Has been cancelled
Lint / lint (windows-latest, windows) (push) Has been cancelled
2026-04-23 13:33:41 -06:00
Matt Holt 441d5eb062 caddyhttp: prefer port 443 in auto-HTTPS and add tests (#7666)
Tests / test (./cmd/caddy/caddy, ~1.26.0, ubuntu-latest, 0, 1.26, linux) (push) Failing after 1m17s
Tests / test (s390x on IBM Z) (push) Has been skipped
Tests / goreleaser-check (push) Has been skipped
Cross-Build / build (~1.26.0, 1.26, aix) (push) Successful in 1m24s
Cross-Build / build (~1.26.0, 1.26, darwin) (push) Successful in 1m27s
Cross-Build / build (~1.26.0, 1.26, dragonfly) (push) Successful in 1m23s
Cross-Build / build (~1.26.0, 1.26, freebsd) (push) Successful in 1m22s
Cross-Build / build (~1.26.0, 1.26, illumos) (push) Successful in 1m21s
Cross-Build / build (~1.26.0, 1.26, linux) (push) Successful in 1m22s
Cross-Build / build (~1.26.0, 1.26, netbsd) (push) Successful in 1m22s
Cross-Build / build (~1.26.0, 1.26, openbsd) (push) Successful in 1m27s
Cross-Build / build (~1.26.0, 1.26, solaris) (push) Successful in 1m25s
Cross-Build / build (~1.26.0, 1.26, windows) (push) Successful in 1m27s
Lint / lint (ubuntu-latest, linux) (push) Successful in 1m59s
Lint / govulncheck (push) Successful in 1m46s
Lint / dependency-review (push) Failing after 24s
OpenSSF Scorecard supply-chain security / Scorecard analysis (push) Failing after 6m45s
Tests / test (./cmd/caddy/caddy, ~1.26.0, macos-14, 0, 1.26, mac) (push) Has been cancelled
Tests / test (./cmd/caddy/caddy.exe, ~1.26.0, windows-latest, True, 1.26, windows) (push) Has been cancelled
Lint / lint (macos-14, mac) (push) Has been cancelled
Lint / lint (windows-latest, windows) (push) Has been cancelled
2026-04-23 17:29:03 +10:00
Daniil Sivak aed1af5976 reverseproxy: add lb_retry_match condition on response status (#7569)
Tests / test (./cmd/caddy/caddy, ~1.26.0, ubuntu-latest, 0, 1.26, linux) (push) Failing after 15m2s
Tests / test (s390x on IBM Z) (push) Has been skipped
Tests / goreleaser-check (push) Has been skipped
Cross-Build / build (~1.26.0, 1.26, aix) (push) Failing after 11m36s
Cross-Build / build (~1.26.0, 1.26, darwin) (push) Successful in 1m32s
Cross-Build / build (~1.26.0, 1.26, dragonfly) (push) Failing after 2m3s
Cross-Build / build (~1.26.0, 1.26, freebsd) (push) Failing after 2m2s
Cross-Build / build (~1.26.0, 1.26, illumos) (push) Failing after 11m12s
Cross-Build / build (~1.26.0, 1.26, linux) (push) Successful in 6m45s
Cross-Build / build (~1.26.0, 1.26, netbsd) (push) Failing after 2m3s
Cross-Build / build (~1.26.0, 1.26, openbsd) (push) Successful in 1m57s
Cross-Build / build (~1.26.0, 1.26, solaris) (push) Failing after 56s
Cross-Build / build (~1.26.0, 1.26, windows) (push) Failing after 51s
Lint / lint (ubuntu-latest, linux) (push) Failing after 1m24s
Lint / govulncheck (push) Failing after 1m51s
Lint / dependency-review (push) Failing after 3m55s
OpenSSF Scorecard supply-chain security / Scorecard analysis (push) Failing after 20s
Tests / test (./cmd/caddy/caddy, ~1.26.0, macos-14, 0, 1.26, mac) (push) Has been cancelled
Tests / test (./cmd/caddy/caddy.exe, ~1.26.0, windows-latest, True, 1.26, windows) (push) Has been cancelled
Lint / lint (macos-14, mac) (push) Has been cancelled
Lint / lint (windows-latest, windows) (push) Has been cancelled
2026-04-21 14:59:31 -04:00
Zen Dodd 4430756d5c admin: Redact sensitive request headers in API logs (#7578)
Tests / test (./cmd/caddy/caddy, ~1.26.0, ubuntu-latest, 0, 1.26, linux) (push) Failing after 1m51s
Tests / test (s390x on IBM Z) (push) Has been skipped
Tests / goreleaser-check (push) Has been skipped
Cross-Build / build (~1.26.0, 1.26, aix) (push) Successful in 1m41s
Cross-Build / build (~1.26.0, 1.26, darwin) (push) Successful in 1m37s
Cross-Build / build (~1.26.0, 1.26, dragonfly) (push) Successful in 1m30s
Cross-Build / build (~1.26.0, 1.26, freebsd) (push) Successful in 1m44s
Cross-Build / build (~1.26.0, 1.26, illumos) (push) Successful in 1m36s
Cross-Build / build (~1.26.0, 1.26, linux) (push) Successful in 1m36s
Cross-Build / build (~1.26.0, 1.26, netbsd) (push) Successful in 1m31s
Cross-Build / build (~1.26.0, 1.26, openbsd) (push) Successful in 1m42s
Cross-Build / build (~1.26.0, 1.26, solaris) (push) Successful in 1m47s
Cross-Build / build (~1.26.0, 1.26, windows) (push) Successful in 1m56s
Lint / lint (ubuntu-latest, linux) (push) Successful in 2m49s
Lint / govulncheck (push) Successful in 2m5s
Lint / dependency-review (push) Failing after 1m0s
OpenSSF Scorecard supply-chain security / Scorecard analysis (push) Failing after 42s
Tests / test (./cmd/caddy/caddy, ~1.26.0, macos-14, 0, 1.26, mac) (push) Has been cancelled
Tests / test (./cmd/caddy/caddy.exe, ~1.26.0, windows-latest, True, 1.26, windows) (push) Has been cancelled
Lint / lint (macos-14, mac) (push) Has been cancelled
Lint / lint (windows-latest, windows) (push) Has been cancelled
* admin: Redact sensitive request headers in API logs

* Fix govulncheck and typed atomic lint failures

* Sync Go module metadata after dependency downgrade
2026-04-17 14:56:42 -06:00
dependabot[bot] af89c5ab02 build(deps): bump github.com/jackc/pgx/v5 from 5.8.0 to 5.9.0 (#7655)
Bumps [github.com/jackc/pgx/v5](https://github.com/jackc/pgx) from 5.8.0 to 5.9.0.
- [Changelog](https://github.com/jackc/pgx/blob/master/CHANGELOG.md)
- [Commits](https://github.com/jackc/pgx/compare/v5.8.0...v5.9.0)

---
updated-dependencies:
- dependency-name: github.com/jackc/pgx/v5
  dependency-version: 5.9.0
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-17 14:50:53 -06:00
Mohammed Al Sahaf bd9f145321 chore: add AGENTS.md (#7652)
* chore: add `AGENTS.md`

Signed-off-by: Mohammed Al Sahaf <msaa1990@gmail.com>

* Apply suggestions from code review

Co-authored-by: Francis Lavoie <lavofr@gmail.com>
Co-authored-by: Matt Holt <mholt@users.noreply.github.com>

* review feedback

Signed-off-by: Mohammed Al Sahaf <msaa1990@gmail.com>

---------

Signed-off-by: Mohammed Al Sahaf <msaa1990@gmail.com>
Co-authored-by: Francis Lavoie <lavofr@gmail.com>
Co-authored-by: Matt Holt <mholt@users.noreply.github.com>
2026-04-17 14:49:58 -06:00
Steffen Busch 24bebd0a07 caddyhttp: Document missing placeholders for escaped URI and prefixed query (#7659) 2026-04-17 16:13:15 -04:00
Francis Lavoie 2ad19885b5 rewrite: Add option to force modifying the query 2026-03-01 16:11:03 -05:00
60 changed files with 2893 additions and 395 deletions
+3 -1
View File
@@ -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
View 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**: 12 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
+39 -6
View File
@@ -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
@@ -210,8 +212,8 @@ type AdminAccess struct {
// AdminPermissions specifies what kinds of requests are allowed
// to be made to the admin endpoint.
type AdminPermissions struct {
// The API paths allowed. Paths are simple prefix matches.
// Any subpath of the specified paths will be allowed.
// The API paths allowed. A request path must either equal an
// allowed path or be a subpath with a path-segment boundary.
Paths []string `json:"paths,omitempty"`
// The HTTP methods allowed for the given paths.
@@ -716,7 +718,7 @@ func (remote RemoteAdmin) enforceAccessControls(r *http.Request) error {
// verify path
pathFound := accessPerm.Paths == nil
for _, allowedPath := range accessPerm.Paths {
if strings.HasPrefix(r.URL.Path, allowedPath) {
if adminPathAllowed(r.URL.Path, allowedPath) {
pathFound = true
break
}
@@ -745,6 +747,19 @@ func (remote RemoteAdmin) enforceAccessControls(r *http.Request) error {
}
}
func adminPathAllowed(reqPath, allowedPath string) bool {
if allowedPath == "" || allowedPath == "/" {
return strings.HasPrefix(reqPath, allowedPath)
}
if reqPath == allowedPath {
return true
}
if strings.HasSuffix(allowedPath, "/") {
return strings.HasPrefix(reqPath, allowedPath)
}
return strings.HasPrefix(reqPath, allowedPath+"/")
}
func stopAdminServer(srv *http.Server) error {
if srv == nil {
return fmt.Errorf("no admin server")
@@ -800,7 +815,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 +1076,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{
@@ -1143,6 +1161,20 @@ func handleStop(w http.ResponseWriter, r *http.Request) error {
return nil
}
func parseCanonicalArrayIndex(idx string) (int, error) {
if idx == "" {
return 0, fmt.Errorf("empty index")
}
i, err := strconv.Atoi(idx)
if err != nil {
return 0, err
}
if strconv.Itoa(i) != idx {
return 0, fmt.Errorf("non-canonical array index")
}
return i, nil
}
// unsyncedConfigAccess traverses into the current config and performs
// the operation at path according to method, using body and out as
// needed. This is a low-level, unsynchronized function; most callers
@@ -1204,11 +1236,12 @@ traverseLoop:
var idx int
if method != http.MethodPost {
idxStr := parts[len(parts)-1]
idx, err = strconv.Atoi(idxStr)
idx, err = parseCanonicalArrayIndex(idxStr)
if err != nil {
return fmt.Errorf("[%s] invalid array index '%s': %v",
path, idxStr, err)
}
if idx < 0 || (method != http.MethodPut && idx >= len(arr)) || idx > len(arr) {
return fmt.Errorf("[%s] array index out of bounds: %s", path, idxStr)
}
@@ -1308,7 +1341,7 @@ traverseLoop:
}
case []any:
partInt, err := strconv.Atoi(part)
partInt, err := parseCanonicalArrayIndex(part)
if err != nil {
return fmt.Errorf("[/%s] invalid array index '%s': %v",
strings.Join(parts[:i+1], "/"), part, err)
+195
View File
@@ -15,9 +15,13 @@
package caddy
import (
"bytes"
"context"
"crypto"
"crypto/tls"
"crypto/x509"
"encoding/json"
"errors"
"fmt"
"maps"
"net/http"
@@ -31,6 +35,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(`{
@@ -51,6 +57,13 @@ var testCfg = []byte(`{
}
`)
type testAdminPublicKey string
func (k testAdminPublicKey) Equal(x crypto.PublicKey) bool {
other, ok := x.(testAdminPublicKey)
return ok && k == other
}
func TestUnsyncedConfigAccess(t *testing.T) {
// each test is performed in sequence, so
// each change builds on the previous ones;
@@ -242,6 +255,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)
@@ -604,6 +662,99 @@ func TestAllowedOriginsUnixSocket(t *testing.T) {
}
}
func TestRemoteAdminAccessControlPathSegmentMatching(t *testing.T) {
const authorizedKey testAdminPublicKey = "authorized"
peerCert := &x509.Certificate{PublicKey: authorizedKey}
tests := []struct {
name string
allowedPath string
requestPath string
wantErr bool
}{
{
name: "exact path",
allowedPath: "/pki/ca/prod",
requestPath: "/pki/ca/prod",
wantErr: false,
},
{
name: "subpath",
allowedPath: "/pki/ca/prod",
requestPath: "/pki/ca/prod/certificates",
wantErr: false,
},
{
name: "trailing slash subpath",
allowedPath: "/pki/ca/prod/",
requestPath: "/pki/ca/prod/certificates",
wantErr: false,
},
{
name: "sibling with shared prefix",
allowedPath: "/pki/ca/prod",
requestPath: "/pki/ca/prod-backup",
wantErr: true,
},
{
name: "same segment plus digit",
allowedPath: "/pki/ca/prod",
requestPath: "/pki/ca/prod1",
wantErr: true,
},
{
name: "root path",
allowedPath: "/",
requestPath: "/pki/ca/prod",
wantErr: false,
},
}
for i, test := range tests {
t.Run(test.name, func(t *testing.T) {
remote := RemoteAdmin{
AccessControl: []*AdminAccess{
{
Permissions: []AdminPermissions{
{
Methods: []string{http.MethodGet},
Paths: []string{test.allowedPath},
},
},
publicKeys: []crypto.PublicKey{authorizedKey},
},
},
}
req := httptest.NewRequest(http.MethodGet, "https://localhost:2021"+test.requestPath, nil)
req.TLS = &tls.ConnectionState{
VerifiedChains: [][]*x509.Certificate{{peerCert}},
}
err := remote.enforceAccessControls(req)
if test.wantErr {
if err == nil {
t.Errorf("test %d (%s): allowed path %q, request path %q: expected forbidden error, got nil", i, test.name, test.allowedPath, test.requestPath)
return
}
var apiErr APIError
if !errors.As(err, &apiErr) {
t.Errorf("test %d (%s): allowed path %q, request path %q: expected APIError with HTTP status %d, got %T: %v", i, test.name, test.allowedPath, test.requestPath, http.StatusForbidden, err, err)
return
}
if apiErr.HTTPStatus != http.StatusForbidden {
t.Errorf("test %d (%s): allowed path %q, request path %q: expected HTTP status %d, got %d", i, test.name, test.allowedPath, test.requestPath, http.StatusForbidden, apiErr.HTTPStatus)
}
return
}
if err != nil {
t.Errorf("test %d (%s): allowed path %q, request path %q: expected no error, got %v", i, test.name, test.allowedPath, test.requestPath, err)
}
})
}
}
func TestReplaceRemoteAdminServer(t *testing.T) {
const testCert = `MIIDCTCCAfGgAwIBAgIUXsqJ1mY8pKlHQtI3HJ23x2eZPqwwDQYJKoZIhvcNAQEL
BQAwFDESMBAGA1UEAwwJbG9jYWxob3N0MB4XDTIzMDEwMTAwMDAwMFoXDTI0MDEw
@@ -956,3 +1107,47 @@ MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQDRS0LmTwUT0iwP
})
}
}
func TestUnsyncedConfigAccessCanonicalArrayIndices(t *testing.T) {
rawCfg = map[string]any{
rawConfigKey: map[string]any{
"list": []any{"zero", "one", "two", "three", "four", "five", "six", "seven", "eight", "nine", "ten"},
},
}
tests := []struct {
name string
path string
wantOutput string
wantErr bool
}{
{name: "allow zero", path: "/" + rawConfigKey + "/list/0", wantOutput: "\"zero\"\n"},
{name: "allow one", path: "/" + rawConfigKey + "/list/1", wantOutput: "\"one\"\n"},
{name: "allow ten", path: "/" + rawConfigKey + "/list/10", wantOutput: "\"ten\"\n"},
{name: "reject leading zero", path: "/" + rawConfigKey + "/list/01", wantErr: true},
{name: "reject multiple leading zeros", path: "/" + rawConfigKey + "/list/002", wantErr: true},
{name: "reject plus sign", path: "/" + rawConfigKey + "/list/+1", wantErr: true},
{name: "reject negative zero", path: "/" + rawConfigKey + "/list/-0", wantErr: true},
}
for i, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
var gotOutput bytes.Buffer
err := unsyncedConfigAccess(http.MethodGet, tc.path, nil, &gotOutput)
if tc.wantErr {
if err == nil {
t.Errorf("test %d (%s): input path %q: expected error, got nil with output %q", i, tc.name, tc.path, gotOutput.String())
}
return
}
if err != nil {
t.Errorf("test %d (%s): input path %q: expected no error with output %q, got error %v with output %q", i, tc.name, tc.path, tc.wantOutput, err, gotOutput.String())
}
if gotOutput.String() != tc.wantOutput {
t.Errorf("test %d (%s): input path %q: expected output %q, got %q", i, tc.name, tc.path, tc.wantOutput, gotOutput.String())
}
})
}
}
+3 -3
View File
@@ -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
+5 -20
View File
@@ -550,26 +550,11 @@ func parseTLS(h Helper) ([]ConfigValue, error) {
}
case acmeIssuer != nil:
// implicit ACME issuers (from various subdirectives) - use defaults; there might be more than one
defaultIssuers := caddytls.DefaultIssuers(acmeIssuer.Email)
// if an ACME CA endpoint was set, the user expects to use that specific one,
// not any others that may be defaults, so replace all defaults with that ACME CA
if acmeIssuer.CA != "" {
defaultIssuers = []certmagic.Issuer{acmeIssuer}
}
// implicit ACME issuers (from various subdirectives) should inherit from
// any globally-configured ACME issuer templates, then apply the local
// shortcut settings as overrides.
defaultIssuers := implicitACMEIssuers(h, acmeIssuer)
for _, issuer := range defaultIssuers {
// apply settings from the implicitly-configured ACMEIssuer to any
// default ACMEIssuers, but preserve each default issuer's CA endpoint,
// because, for example, if you configure the DNS challenge, it should
// apply to any of the default ACMEIssuers, but you don't want to trample
// out their unique CA endpoints
if iss, ok := issuer.(*caddytls.ACMEIssuer); ok && iss != nil {
acmeCopy := *acmeIssuer
acmeCopy.CA = iss.CA
issuer = &acmeCopy
}
configVals = append(configVals, ConfigValue{
Class: "tls.cert_issuer",
Value: issuer,
@@ -1068,7 +1053,7 @@ func parseLogHelper(h Helper, globalLogNames map[string]struct{}) ([]ConfigValue
if !d.NextArg() {
return nil, d.ArgErr()
}
interval, err := time.ParseDuration(d.Val() + "ns")
interval, err := caddy.ParseDuration(d.Val())
if err != nil {
return nil, d.Errf("failed to parse interval: %v", err)
}
+2 -2
View File
@@ -66,14 +66,14 @@ func TestLogDirectiveSyntax(t *testing.T) {
input: `:8080 {
log {
sampling {
interval 2
interval 2s
first 3
thereafter 4
}
}
}
`,
output: `{"logging":{"logs":{"default":{"exclude":["http.log.access.log0"]},"log0":{"sampling":{"interval":2,"first":3,"thereafter":4},"include":["http.log.access.log0"]}}},"apps":{"http":{"servers":{"srv0":{"listen":[":8080"],"logs":{"default_logger_name":"log0"}}}}}}`,
output: `{"logging":{"logs":{"default":{"exclude":["http.log.access.log0"]},"log0":{"sampling":{"interval":2000000000,"first":3,"thereafter":4},"include":["http.log.access.log0"]}}},"apps":{"http":{"servers":{"srv0":{"listen":[":8080"],"logs":{"default_logger_name":"log0"}}}}}}`,
expectError: false,
},
} {
+2
View File
@@ -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())
}
+125
View File
@@ -3,7 +3,9 @@ package httpcaddyfile
import (
"encoding/json"
"testing"
"time"
"github.com/caddyserver/caddy/v2"
"github.com/caddyserver/caddy/v2/caddyconfig/caddyfile"
"github.com/caddyserver/caddy/v2/modules/caddytls"
_ "github.com/caddyserver/caddy/v2/modules/logging"
@@ -166,3 +168,126 @@ func TestGlobalResolversOption(t *testing.T) {
})
}
}
func TestGlobalCertIssuerAppliesToImplicitACMEIssuer(t *testing.T) {
adapter := caddyfile.Adapter{
ServerType: ServerType{},
}
input := `{
cert_issuer acme {
disable_tlsalpn_challenge
}
}
report.company.intern {
tls {
ca https://deglacme01.company.intern/acme/acme/directory
ca_root /etc/certs/company_root2.crt
}
respond "ok"
}`
out, _, err := adapter.Adapt([]byte(input), nil)
if err != nil {
t.Fatalf("adapting caddyfile: %v", err)
}
var config struct {
Apps struct {
TLS *caddytls.TLS `json:"tls"`
} `json:"apps"`
}
if err := json.Unmarshal(out, &config); err != nil {
t.Fatalf("unmarshaling adapted config: %v", err)
}
if config.Apps.TLS == nil || config.Apps.TLS.Automation == nil {
t.Fatal("expected tls automation config")
}
var subjectPolicy *caddytls.AutomationPolicy
for _, ap := range config.Apps.TLS.Automation.Policies {
if len(ap.SubjectsRaw) == 1 && ap.SubjectsRaw[0] == "report.company.intern" {
subjectPolicy = ap
break
}
}
if subjectPolicy == nil {
t.Fatal("expected subject-specific automation policy")
}
if len(subjectPolicy.IssuersRaw) != 1 {
t.Fatalf("expected one issuer for subject-specific policy, got %d", len(subjectPolicy.IssuersRaw))
}
var issuer caddytls.ACMEIssuer
if err := json.Unmarshal(subjectPolicy.IssuersRaw[0], &issuer); err != nil {
t.Fatalf("unmarshaling issuer: %v", err)
}
if issuer.CA != "https://deglacme01.company.intern/acme/acme/directory" {
t.Fatalf("expected custom ACME CA, got %q", issuer.CA)
}
if len(issuer.TrustedRootsPEMFiles) != 1 || issuer.TrustedRootsPEMFiles[0] != "/etc/certs/company_root2.crt" {
t.Fatalf("expected trusted roots to include site CA root, got %v", issuer.TrustedRootsPEMFiles)
}
if issuer.Challenges == nil || issuer.Challenges.TLSALPN == nil || !issuer.Challenges.TLSALPN.Disabled {
t.Fatalf("expected tls-alpn challenge to be disabled, got %#v", issuer.Challenges)
}
}
func TestMergeACMEIssuers(t *testing.T) {
base := &caddytls.ACMEIssuer{
Email: "ops@example.com",
Challenges: &caddytls.ChallengesConfig{
HTTP: &caddytls.HTTPChallengeConfig{
AlternatePort: 8080,
},
TLSALPN: &caddytls.TLSALPNChallengeConfig{
Disabled: true,
AlternatePort: 8443,
},
DNS: &caddytls.DNSChallengeConfig{
Resolvers: []string{"1.1.1.1"},
OverrideDomain: "_acme-challenge.example.net",
},
},
TrustedRootsPEMFiles: []string{"global.pem"},
}
overrides := &caddytls.ACMEIssuer{
CA: "https://deglacme01.company.intern/acme/acme/directory",
Challenges: &caddytls.ChallengesConfig{
HTTP: &caddytls.HTTPChallengeConfig{
Disabled: true,
},
DNS: &caddytls.DNSChallengeConfig{
PropagationTimeout: caddy.Duration(time.Minute),
},
},
TrustedRootsPEMFiles: []string{"site.pem"},
}
merged := mergeACMEIssuers(base, overrides)
if merged.CA != overrides.CA {
t.Fatalf("expected merged CA %q, got %q", overrides.CA, merged.CA)
}
if merged.Email != base.Email {
t.Fatalf("expected merged email %q, got %q", base.Email, merged.Email)
}
if len(merged.TrustedRootsPEMFiles) != 2 || merged.TrustedRootsPEMFiles[0] != "global.pem" || merged.TrustedRootsPEMFiles[1] != "site.pem" {
t.Fatalf("expected merged roots [global.pem site.pem], got %v", merged.TrustedRootsPEMFiles)
}
if merged.Challenges == nil || merged.Challenges.HTTP == nil || !merged.Challenges.HTTP.Disabled || merged.Challenges.HTTP.AlternatePort != 8080 {
t.Fatalf("expected merged HTTP challenge config to preserve alternate port and apply disable flag, got %#v", merged.Challenges)
}
if merged.Challenges.TLSALPN == nil || !merged.Challenges.TLSALPN.Disabled || merged.Challenges.TLSALPN.AlternatePort != 8443 {
t.Fatalf("expected merged TLS-ALPN challenge config to preserve global settings, got %#v", merged.Challenges)
}
if merged.Challenges.DNS == nil || merged.Challenges.DNS.PropagationTimeout != caddy.Duration(time.Minute) || len(merged.Challenges.DNS.Resolvers) != 1 || merged.Challenges.DNS.Resolvers[0] != "1.1.1.1" || merged.Challenges.DNS.OverrideDomain != "_acme-challenge.example.net" {
t.Fatalf("expected merged DNS challenge config to preserve global values and apply overrides, got %#v", merged.Challenges)
}
if base.CA != "" {
t.Fatalf("expected base issuer to remain unchanged, got CA %q", base.CA)
}
if len(base.TrustedRootsPEMFiles) != 1 || base.TrustedRootsPEMFiles[0] != "global.pem" {
t.Fatalf("expected base roots to remain unchanged, got %v", base.TrustedRootsPEMFiles)
}
}
+283
View File
@@ -612,6 +612,289 @@ func fillInGlobalACMEDefaults(issuer certmagic.Issuer, options map[string]any) e
return nil
}
// implicitACMEIssuers returns the issuers to use for ACME-related tls
// shortcuts such as ca, ca_root, and dns. If any global cert_issuer options
// configure ACME issuers, those become the templates for the local shortcut
// configuration; otherwise, default ACME issuers are used.
func implicitACMEIssuers(h Helper, acmeIssuer *caddytls.ACMEIssuer) []certmagic.Issuer {
globalIssuers, _ := h.Option("cert_issuer").([]certmagic.Issuer)
var implicitIssuers []certmagic.Issuer
for _, issuer := range globalIssuers {
acmeWrapper, ok := issuer.(acmeCapable)
if !ok {
continue
}
baseIssuer := acmeWrapper.GetACMEIssuer()
if baseIssuer == nil {
continue
}
implicitIssuers = append(implicitIssuers, mergeACMEIssuers(baseIssuer, acmeIssuer))
}
if len(implicitIssuers) > 0 {
return implicitIssuers
}
// If an ACME CA endpoint was set locally, the user expects to use only that
// CA rather than the usual default fallback issuers.
defaultIssuers := caddytls.DefaultIssuers(acmeIssuer.Email)
if acmeIssuer.CA != "" {
defaultIssuers = []certmagic.Issuer{new(caddytls.ACMEIssuer)}
}
implicitIssuers = make([]certmagic.Issuer, 0, len(defaultIssuers))
for _, issuer := range defaultIssuers {
acmeWrapper, ok := issuer.(acmeCapable)
if !ok {
implicitIssuers = append(implicitIssuers, issuer)
continue
}
baseIssuer := acmeWrapper.GetACMEIssuer()
if baseIssuer == nil {
implicitIssuers = append(implicitIssuers, issuer)
continue
}
implicitIssuers = append(implicitIssuers, mergeACMEIssuers(baseIssuer, acmeIssuer))
}
return implicitIssuers
}
func mergeACMEIssuers(base, overrides *caddytls.ACMEIssuer) *caddytls.ACMEIssuer {
if base == nil {
return cloneACMEIssuer(overrides)
}
merged := cloneACMEIssuer(base)
if overrides == nil {
return merged
}
if overrides.CA != "" {
merged.CA = overrides.CA
}
if overrides.TestCA != "" {
merged.TestCA = overrides.TestCA
}
if overrides.Email != "" {
merged.Email = overrides.Email
}
if overrides.Profile != "" {
merged.Profile = overrides.Profile
}
if overrides.AccountKey != "" {
merged.AccountKey = overrides.AccountKey
}
if overrides.ExternalAccount != nil {
merged.ExternalAccount = cloneACMEEAB(overrides.ExternalAccount)
}
if overrides.ACMETimeout != 0 {
merged.ACMETimeout = overrides.ACMETimeout
}
if len(overrides.TrustedRootsPEMFiles) > 0 {
merged.TrustedRootsPEMFiles = appendUniqueStrings(merged.TrustedRootsPEMFiles, overrides.TrustedRootsPEMFiles...)
}
if overrides.PreferredChains != nil {
merged.PreferredChains = cloneChainPreference(overrides.PreferredChains)
}
if overrides.CertificateLifetime != 0 {
merged.CertificateLifetime = overrides.CertificateLifetime
}
if len(overrides.NetworkProxyRaw) > 0 {
merged.NetworkProxyRaw = slices.Clone(overrides.NetworkProxyRaw)
}
merged.Challenges = mergeChallengesConfig(merged.Challenges, overrides.Challenges)
return merged
}
func mergeChallengesConfig(base, overrides *caddytls.ChallengesConfig) *caddytls.ChallengesConfig {
if base == nil {
return cloneChallengesConfig(overrides)
}
merged := cloneChallengesConfig(base)
if overrides == nil {
return merged
}
merged.HTTP = mergeHTTPChallengeConfig(merged.HTTP, overrides.HTTP)
merged.TLSALPN = mergeTLSALPNChallengeConfig(merged.TLSALPN, overrides.TLSALPN)
merged.DNS = mergeDNSChallengeConfig(merged.DNS, overrides.DNS)
if overrides.BindHost != "" {
merged.BindHost = overrides.BindHost
}
if overrides.Distributed != nil {
value := *overrides.Distributed
merged.Distributed = &value
}
return merged
}
func mergeHTTPChallengeConfig(base, overrides *caddytls.HTTPChallengeConfig) *caddytls.HTTPChallengeConfig {
if base == nil {
return cloneHTTPChallengeConfig(overrides)
}
merged := cloneHTTPChallengeConfig(base)
if overrides == nil {
return merged
}
if overrides.Disabled {
merged.Disabled = true
}
if overrides.AlternatePort != 0 {
merged.AlternatePort = overrides.AlternatePort
}
return merged
}
func mergeTLSALPNChallengeConfig(base, overrides *caddytls.TLSALPNChallengeConfig) *caddytls.TLSALPNChallengeConfig {
if base == nil {
return cloneTLSALPNChallengeConfig(overrides)
}
merged := cloneTLSALPNChallengeConfig(base)
if overrides == nil {
return merged
}
if overrides.Disabled {
merged.Disabled = true
}
if overrides.AlternatePort != 0 {
merged.AlternatePort = overrides.AlternatePort
}
return merged
}
func mergeDNSChallengeConfig(base, overrides *caddytls.DNSChallengeConfig) *caddytls.DNSChallengeConfig {
if base == nil {
return cloneDNSChallengeConfig(overrides)
}
merged := cloneDNSChallengeConfig(base)
if overrides == nil {
return merged
}
if len(overrides.ProviderRaw) > 0 {
merged.ProviderRaw = slices.Clone(overrides.ProviderRaw)
}
if overrides.PropagationDelay != 0 {
merged.PropagationDelay = overrides.PropagationDelay
}
if overrides.PropagationTimeout != 0 {
merged.PropagationTimeout = overrides.PropagationTimeout
}
if overrides.Resolvers != nil {
merged.Resolvers = slices.Clone(overrides.Resolvers)
}
if overrides.OverrideDomain != "" {
merged.OverrideDomain = overrides.OverrideDomain
}
if overrides.TTL != 0 {
merged.TTL = overrides.TTL
}
return merged
}
func cloneACMEIssuer(iss *caddytls.ACMEIssuer) *caddytls.ACMEIssuer {
if iss == nil {
return nil
}
cloned := *iss
cloned.Challenges = cloneChallengesConfig(iss.Challenges)
cloned.ExternalAccount = cloneACMEEAB(iss.ExternalAccount)
cloned.TrustedRootsPEMFiles = slices.Clone(iss.TrustedRootsPEMFiles)
cloned.PreferredChains = cloneChainPreference(iss.PreferredChains)
cloned.NetworkProxyRaw = slices.Clone(iss.NetworkProxyRaw)
return &cloned
}
func cloneChallengesConfig(cfg *caddytls.ChallengesConfig) *caddytls.ChallengesConfig {
if cfg == nil {
return nil
}
cloned := *cfg
cloned.HTTP = cloneHTTPChallengeConfig(cfg.HTTP)
cloned.TLSALPN = cloneTLSALPNChallengeConfig(cfg.TLSALPN)
cloned.DNS = cloneDNSChallengeConfig(cfg.DNS)
if cfg.Distributed != nil {
value := *cfg.Distributed
cloned.Distributed = &value
}
return &cloned
}
func cloneHTTPChallengeConfig(cfg *caddytls.HTTPChallengeConfig) *caddytls.HTTPChallengeConfig {
if cfg == nil {
return nil
}
cloned := *cfg
return &cloned
}
func cloneTLSALPNChallengeConfig(cfg *caddytls.TLSALPNChallengeConfig) *caddytls.TLSALPNChallengeConfig {
if cfg == nil {
return nil
}
cloned := *cfg
return &cloned
}
func cloneDNSChallengeConfig(cfg *caddytls.DNSChallengeConfig) *caddytls.DNSChallengeConfig {
if cfg == nil {
return nil
}
cloned := *cfg
cloned.ProviderRaw = slices.Clone(cfg.ProviderRaw)
cloned.Resolvers = slices.Clone(cfg.Resolvers)
return &cloned
}
func cloneACMEEAB(eab *acme.EAB) *acme.EAB {
if eab == nil {
return nil
}
cloned := *eab
return &cloned
}
func cloneChainPreference(pref *caddytls.ChainPreference) *caddytls.ChainPreference {
if pref == nil {
return nil
}
cloned := *pref
cloned.RootCommonName = slices.Clone(pref.RootCommonName)
cloned.AnyCommonName = slices.Clone(pref.AnyCommonName)
if pref.Smallest != nil {
value := *pref.Smallest
cloned.Smallest = &value
}
return &cloned
}
func appendUniqueStrings(existing []string, additions ...string) []string {
for _, value := range additions {
if !slices.Contains(existing, value) {
existing = append(existing, value)
}
}
return existing
}
// newBaseAutomationPolicy returns a new TLS automation policy that gets
// its values from the global options map. It should be used as the base
// for any other automation policies. A nil policy (and no error) will be
+22
View File
@@ -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(`
@@ -1,7 +1,7 @@
{
log {
sampling {
interval 300
interval 5m
first 50
thereafter 40
}
@@ -13,7 +13,7 @@
"logs": {
"default": {
"sampling": {
"interval": 300,
"interval": 300000000000,
"first": 50,
"thereafter": 40
}
@@ -1,7 +1,7 @@
:80 {
log {
sampling {
interval 300
interval 5m
first 50
thereafter 40
}
@@ -18,7 +18,7 @@
},
"log0": {
"sampling": {
"interval": 300,
"interval": 300000000000,
"first": 50,
"thereafter": 40
},
@@ -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
}
}
}
}
@@ -11,7 +11,9 @@ reverse_proxy 127.0.0.1:65535 {
@accel header X-Accel-Redirect *
handle_response @accel {
respond "Header X-Accel-Redirect!"
rewrite * {rp.header.X-Accel-Redirect} {
force_modify_query
}
}
@another {
@@ -104,10 +106,12 @@ reverse_proxy 127.0.0.1:65535 {
},
"routes": [
{
"group": "group0",
"handle": [
{
"body": "Header X-Accel-Redirect!",
"handler": "static_response"
"force_modify_query": true,
"handler": "rewrite",
"uri": "{http.reverse_proxy.header.X-Accel-Redirect}"
}
]
}
@@ -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"
}
]
}
]
}
]
}
}
}
}
}
+202 -102
View File
@@ -1,17 +1,13 @@
package integration
import (
"bufio"
"crypto/sha1"
"encoding/base64"
"fmt"
"io"
"net"
"net/http"
"net/textproto"
"os"
"runtime"
"strings"
"sync/atomic"
"testing"
"github.com/caddyserver/caddy/v2/caddytest"
@@ -568,128 +564,232 @@ func TestReverseProxyHealthCheckUnixSocketWithoutPort(t *testing.T) {
tester.AssertGetResponse("http://localhost:9080/", 200, "Hello, World!")
}
func TestReverseProxyWebSocketUpgradeUnixSocket(t *testing.T) {
if runtime.GOOS == "windows" {
t.SkipNow()
}
f, err := os.CreateTemp("", "*.sock")
if err != nil {
t.Fatalf("failed to create temporary socket file: %v", err)
}
_ = os.Remove(f.Name())
socketName := f.Name()
backend := http.Server{
Handler: http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
if req.URL.Path != "/ws" {
http.NotFound(w, req)
return
}
if !strings.EqualFold(req.Header.Get("Upgrade"), "websocket") ||
!strings.Contains(strings.ToLower(req.Header.Get("Connection")), "upgrade") {
http.Error(w, "missing websocket upgrade headers", http.StatusBadRequest)
return
}
wsKey := req.Header.Get("Sec-WebSocket-Key")
if wsKey == "" {
http.Error(w, "missing Sec-WebSocket-Key", http.StatusBadRequest)
return
}
hj, ok := w.(http.Hijacker)
if !ok {
http.Error(w, "hijacker not supported", http.StatusInternalServerError)
return
}
conn, brw, err := hj.Hijack()
if err != nil {
return
}
defer conn.Close()
_, _ = brw.WriteString("HTTP/1.1 101 Switching Protocols\r\n")
_, _ = brw.WriteString("Upgrade: websocket\r\n")
_, _ = brw.WriteString("Connection: Upgrade\r\n")
_, _ = brw.WriteString("Sec-WebSocket-Accept: " + computeWebSocketAccept(wsKey) + "\r\n")
_, _ = brw.WriteString("\r\n")
_ = brw.Flush()
// 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)
}),
}
unixListener, err := net.Listen("unix", socketName)
badLn, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("failed to listen on unix socket: %v", err)
t.Fatalf("failed to listen: %v", err)
}
go backend.Serve(unixListener)
t.Cleanup(func() {
_ = backend.Close()
_ = unixListener.Close()
_ = os.Remove(socketName)
})
runtime.Gosched()
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
http_port 9080
https_port 9443
grace_period 1ns
}
http://localhost:9080 {
reverse_proxy unix/%s
reverse_proxy %s %s {
lb_policy round_robin
lb_retries 1
lb_retry_match {
expression `+"`{rp.status_code} in [502, 503]`"+`
}
}
}
`, socketName), "caddyfile")
`, goodLn.Addr().String(), badLn.Addr().String()), "caddyfile")
conn, err := net.Dial("tcp", "127.0.0.1:9080")
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 dial caddy listener: %v", err)
t.Fatalf("failed to listen: %v", err)
}
defer conn.Close()
go badSrv.Serve(badLn)
t.Cleanup(func() { badSrv.Close(); badLn.Close() })
wsKey := "dGhlIHNhbXBsZSBub25jZQ=="
request := strings.Join([]string{
"GET /ws HTTP/1.1",
"Host: localhost:9080",
"Connection: Upgrade",
"Upgrade: websocket",
"Sec-WebSocket-Version: 13",
"Sec-WebSocket-Key: " + wsKey,
"",
"",
}, "\r\n")
if _, err := io.WriteString(conn, request); err != nil {
t.Fatalf("failed to send websocket handshake request: %v", err)
// Good upstream: returns 200 without retry header
goodSrv := &http.Server{
Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte("good"))
}),
}
tpr := textproto.NewReader(bufio.NewReader(conn))
statusLine, err := tpr.ReadLine()
goodLn, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("failed reading handshake status line: %v", err)
}
if !strings.Contains(statusLine, "101") || !strings.Contains(strings.ToLower(statusLine), "switching protocols") {
t.Fatalf("unexpected status line: %q", statusLine)
t.Fatalf("failed to listen: %v", err)
}
go goodSrv.Serve(goodLn)
t.Cleanup(func() { goodSrv.Close(); goodLn.Close() })
headers, err := tpr.ReadMIMEHeader()
if err != nil {
t.Fatalf("failed reading handshake headers: %v", err)
tester := caddytest.NewTester(t)
tester.InitServer(fmt.Sprintf(`
{
skip_install_trust
admin localhost:2999
http_port 9080
https_port 9443
grace_period 1ns
}
if !strings.EqualFold(headers.Get("Upgrade"), "websocket") {
t.Fatalf("unexpected Upgrade header: %q", headers.Get("Upgrade"))
http://localhost:9080 {
reverse_proxy %s %s {
lb_policy round_robin
lb_retries 1
lb_retry_match {
expression `+"`{rp.header.X-Upstream-Retry} == \"true\"`"+`
}
}
}
if !strings.Contains(strings.ToLower(headers.Get("Connection")), "upgrade") {
t.Fatalf("unexpected Connection header: %q", headers.Get("Connection"))
`, 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())
}
}
func computeWebSocketAccept(wsKey string) string {
h := sha1.Sum([]byte(wsKey + "258EAFA5-E914-47DA-95CA-C5AB0DC85B11"))
return base64.StdEncoding.EncodeToString(h[:])
// 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")
}
+1 -1
View File
@@ -234,7 +234,7 @@ func getModules() (standard, nonstandard, unknown []moduleInfo, err error) {
// not sure why), and since New() should return a pointer
// value, we need to dereference it first
iface := any(modInfo.New())
if rv := reflect.ValueOf(iface); rv.Kind() == reflect.Ptr {
if rv := reflect.ValueOf(iface); rv.Kind() == reflect.Pointer {
iface = reflect.New(reflect.TypeOf(iface).Elem()).Elem().Interface()
}
modPkgPath := reflect.TypeOf(iface).PkgPath()
+1 -1
View File
@@ -378,7 +378,7 @@ func (ctx Context) LoadModuleByID(id string, rawMsg json.RawMessage) (any, error
// value must be a pointer for unmarshaling into concrete type, even if
// the module's concrete type is a slice or map; New() *should* return
// a pointer, otherwise unmarshaling errors or panics will occur
if rv := reflect.ValueOf(val); rv.Kind() != reflect.Ptr {
if rv := reflect.ValueOf(val); rv.Kind() != reflect.Pointer {
log.Printf("[WARNING] ModuleInfo.New() for module '%s' did not return a pointer,"+
" so we are using reflection to make a pointer instead; please fix this by"+
" using new(Type) or &Type notation in your module's New() function.", id)
+17 -17
View File
@@ -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.2 // 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/text v0.36.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
+32 -32
View File
@@ -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.2 h1:3ZhOzMWnR4yJ+RW1XImIPsD1aNSz4T4fyP7zlQb56hw=
github.com/jackc/pgx/v5 v5.9.2/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
View 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)
)
+10 -14
View File
@@ -30,10 +30,6 @@ import (
"go.uber.org/zap"
)
func reuseUnixSocket(_, _ string) (any, error) {
return nil, nil
}
func listenReusable(ctx context.Context, lnKey string, network, address string, config net.ListenConfig) (any, error) {
var socketFile *os.File
@@ -120,8 +116,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 +127,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 +151,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 +171,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 +234,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 +254,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 +269,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)
}
+21
View File
@@ -0,0 +1,21 @@
// Copyright 2015 Matthew Holt and The Caddy Authors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//go:build (!unix || solaris) && !windows
package caddy
func reuseUnixSocket(_, _ string) (any, error) {
return nil, nil
}
+89
View File
@@ -0,0 +1,89 @@
// Copyright 2015 Matthew Holt and The Caddy Authors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//go:build windows
package caddy
import (
"errors"
"fmt"
"io/fs"
"net"
"os"
"strings"
"syscall"
"time"
)
var errUnixSocketAlreadyInUse = errors.New("unix socket is already in use by another process")
func reuseUnixSocket(network, addr string) (any, error) {
if !IsUnixNetwork(network) {
return nil, nil
}
// Note: This is here mainly for proper compatibility, because Unix sockets with abstract names are in an interesting limbo state on Windows:
// Go already translates `@` characters to `\0` for Windows: https://github.com/golang/go/blob/65d5c5f6dd8aa7b221cff6ec3f5101ea2e5f3efa/src/syscall/syscall_windows.go#L910
// ...but there still is an open issue about the fact that this is not properly supported: https://github.com/microsoft/WSL/issues/4240#issuecomment-620805115
// The main issue is that the original announcement proclaimed support for this feature, but it was (apparently) never implemented: https://devblogs.microsoft.com/commandline/af_unix-comes-to-windows/
isAbstractUnixSocket := strings.HasPrefix(addr, "@")
if isAbstractUnixSocket {
// Abstract Unix sockets do not require us to remove stale socket files.
return nil, nil
}
// On Windows, we're using the `fakeCloseListener` wrappers around a single, ever-living listener.
// So, if there's an active listener entry in the pool, we're the current owner of the Unix socket file.
_, socketBelongsToCurrentProcess := listenerPool.References(listenerKey(network, addr))
if socketBelongsToCurrentProcess {
// Reuse/cleanup is entirely handled by the refcounting mechanism in `listenerPool`.
return nil, nil
}
// If the socket file does not exist or has no backing server process, this will fail instantly.
connection, err := net.DialTimeout("unix", addr, 10*time.Millisecond)
if err == nil {
connection.Close()
return nil, fmt.Errorf("cannot reuse socket %v: %w", addr, errUnixSocketAlreadyInUse)
}
// Windows returns this error code both if the socket file does not exist and if it isn't backed by a server process anymore.
// See: https://learn.microsoft.com/en-us/windows/win32/winsock/windows-sockets-error-codes-2#wsaeconnrefused
const WSAECONNREFUSED syscall.Errno = 10061
var errno syscall.Errno
hasNoListeningServerProcess := errors.As(err, &errno) && errno == WSAECONNREFUSED
if !hasNoListeningServerProcess {
return nil, fmt.Errorf("cannot reuse socket %v: %w", addr, errUnixSocketAlreadyInUse)
}
// If the socket file exists, it hasn't been created by our process, and it seemingly
// isn't backed by a server process anymore. Try to delete it so we can bind to it later.
err = os.Remove(addr)
if err == nil {
return nil, nil
} else if errors.Is(err, fs.ErrNotExist) {
// Either the file didn't exist in the first place, or it was deleted before we were able to.
return nil, nil
} else {
// We failed to delete the file. Likely, it belongs to another (active) process.
return nil, err
}
}
+12 -10
View File
@@ -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
+19 -6
View File
@@ -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
+58
View File
@@ -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
+15 -5
View File
@@ -69,6 +69,7 @@ func init() {
// `{http.request.orig_uri.path.dir}` | The request's original directory
// `{http.request.orig_uri.path.file}` | The request's original filename
// `{http.request.orig_uri.query}` | The request's original query string (without `?`)
// `{http.request.orig_uri.prefixed_query}` | The request's original query string with a `?` prefix, if non-empty
// `{http.request.port}` | The port part of the request's Host header
// `{http.request.proto}` | The protocol of the request
// `{http.request.local.host}` | The host (IP) part of the local address the connection arrived on
@@ -98,11 +99,15 @@ func init() {
// `{http.request.tls.client.san.ips.*}` | SAN IP addresses (index optional)
// `{http.request.tls.client.san.uris.*}` | SAN URIs (index optional)
// `{http.request.uri}` | The full request URI
// `{http.request.uri_escaped}` | The full request URI with query-style URL encoding applied (using url.QueryEscape)
// `{http.request.uri.path}` | The path component of the request URI
// `{http.request.uri.path_escaped}` | The path component of the request URI with query-style URL encoding applied (using url.QueryEscape)
// `{http.request.uri.path.*}` | Parts of the path, split by `/` (0-based from left)
// `{http.request.uri.path.dir}` | The directory, excluding leaf filename
// `{http.request.uri.path.file}` | The filename of the path, excluding directory
// `{http.request.uri.query}` | The query string (without `?`)
// `{http.request.uri.query_escaped}` | The query string with query-style URL encoding applied (using url.QueryEscape)
// `{http.request.uri.prefixed_query}` | The query string with a `?` prefix, if non-empty
// `{http.request.uri.query.*}` | Individual query string value
// `{http.response.header.*}` | Specific response header field
// `{http.vars.*}` | Custom variables in the HTTP handler chain
@@ -203,6 +208,9 @@ func (app *App) Provision(ctx caddy.Context) error {
app.Metrics.httpMetrics = &httpMetrics{}
// Scan config for allowed hosts to prevent cardinality explosion
app.Metrics.scanConfigForHosts(app)
if err := app.Metrics.provisionOTLP(ctx); err != nil {
return err
}
}
// prepare each server
oldContext := ctx.Context
@@ -214,8 +222,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{
@@ -689,9 +695,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()))
@@ -816,6 +820,12 @@ func (app *App) Stop() error {
}
}
// flush and shut down the OTLP metrics exporter (if configured) so any
// last data point reaches the collector before the process exits
if err := app.Metrics.shutdown(ctx); err != nil {
app.logger.Error("shutting down OTLP metrics", zap.Error(err))
}
app.stopped = true
return nil
}
+31 -7
View File
@@ -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}"
+44
View File
@@ -0,0 +1,44 @@
package caddyhttp
import (
"testing"
"github.com/caddyserver/caddy/v2"
)
func TestRecordAutoHTTPSRedirectAddressPrefersHTTPSPort(t *testing.T) {
app := &App{HTTPSPort: 443}
redirDomains := make(map[string][]caddy.NetworkAddress)
app.recordAutoHTTPSRedirectAddress(redirDomains, "example.com", caddy.NetworkAddress{Network: "tcp", StartPort: 2345, EndPort: 2345})
app.recordAutoHTTPSRedirectAddress(redirDomains, "example.com", caddy.NetworkAddress{Network: "tcp", StartPort: 443, EndPort: 443})
app.recordAutoHTTPSRedirectAddress(redirDomains, "example.com", caddy.NetworkAddress{Network: "tcp", StartPort: 8443, EndPort: 8443})
got := redirDomains["example.com"]
if len(got) != 1 {
t.Fatalf("expected 1 redirect address, got %d: %#v", len(got), got)
}
if got[0].StartPort != 443 {
t.Fatalf("expected redirect to prefer HTTPS port 443, got %#v", got[0])
}
}
func TestRecordAutoHTTPSRedirectAddressKeepsAllBindAddressesOnWinningPort(t *testing.T) {
app := &App{HTTPSPort: 443}
redirDomains := make(map[string][]caddy.NetworkAddress)
app.recordAutoHTTPSRedirectAddress(redirDomains, "example.com", caddy.NetworkAddress{Network: "tcp", Host: "10.0.0.189", StartPort: 8443, EndPort: 8443})
app.recordAutoHTTPSRedirectAddress(redirDomains, "example.com", caddy.NetworkAddress{Network: "tcp", Host: "10.0.0.189", StartPort: 443, EndPort: 443})
app.recordAutoHTTPSRedirectAddress(redirDomains, "example.com", caddy.NetworkAddress{Network: "tcp", Host: "2603:c024:8002:9500:9eb:e5d3:3975:d056", StartPort: 443, EndPort: 443})
got := redirDomains["example.com"]
if len(got) != 2 {
t.Fatalf("expected 2 redirect addresses for both bind addresses on the winning port, got %d: %#v", len(got), got)
}
if got[0].StartPort != 443 || got[1].StartPort != 443 {
t.Fatalf("expected both redirect addresses to stay on HTTPS port 443, got %#v", got)
}
if got[0].Host != "10.0.0.189" || got[1].Host != "2603:c024:8002:9500:9eb:e5d3:3975:d056" {
t.Fatalf("expected both bind addresses to be preserved, got %#v", got)
}
}
+14 -2
View File
@@ -281,7 +281,13 @@ func (fsrv *FileServer) browseApplyQueryParams(w http.ResponseWriter, r *http.Re
sortParam = sortCookie.Value
}
case sortByName, sortByNameDirFirst, sortBySize, sortByTime:
http.SetCookie(w, &http.Cookie{Name: "sort", Value: sortParam, Secure: r.TLS != nil})
http.SetCookie(w, &http.Cookie{ //nolint:gosec // Secure depends on whether the request itself used TLS
Name: "sort",
Value: sortParam,
Secure: r.TLS != nil,
HttpOnly: true,
SameSite: http.SameSiteLaxMode,
})
}
// then figure out the order
@@ -292,7 +298,13 @@ func (fsrv *FileServer) browseApplyQueryParams(w http.ResponseWriter, r *http.Re
orderParam = orderCookie.Value
}
case sortOrderAsc, sortOrderDesc:
http.SetCookie(w, &http.Cookie{Name: "order", Value: orderParam, Secure: r.TLS != nil})
http.SetCookie(w, &http.Cookie{ //nolint:gosec // Secure depends on whether the request itself used TLS
Name: "order",
Value: orderParam,
Secure: r.TLS != nil,
HttpOnly: true,
SameSite: http.SameSiteLaxMode,
})
}
// finally, apply the sorting and limiting
+1 -1
View File
@@ -785,7 +785,7 @@ func redirect(w http.ResponseWriter, r *http.Request, toPath string) error {
if r.URL.RawQuery != "" {
toPath += "?" + r.URL.RawQuery
}
http.Redirect(w, r, toPath, http.StatusPermanentRedirect)
http.Redirect(w, r, toPath, http.StatusPermanentRedirect) //nolint:gosec // toPath is a same-origin path and leading // is stripped above
return nil
}
+6 -41
View File
@@ -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)
)
+8
View File
@@ -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()...)
}
+84 -4
View File
@@ -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)
+50
View File
@@ -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{
+4 -7
View File
@@ -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
+1 -1
View File
@@ -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>
@@ -28,8 +28,6 @@ import (
"go.uber.org/zap"
"go.uber.org/zap/zapcore"
"golang.org/x/text/language"
"golang.org/x/text/search"
"github.com/caddyserver/caddy/v2"
"github.com/caddyserver/caddy/v2/modules/caddyhttp"
@@ -418,14 +416,19 @@ func (t Transport) buildEnv(r *http.Request) (envVars, error) {
return env, nil
}
var splitSearchNonASCII = search.New(language.Und, search.IgnoreCase)
// splitPos returns the index where path should
// be split based on t.SplitPath.
//
// example: if splitPath is [".php"]
// "/path/to/script.php/some/path": ("/path/to/script.php", "/some/path")
//
// Matching is strictly ASCII case-insensitive. Bytes >= utf8.RuneSelf in path
// never match any split entry: split strings are validated ASCII-only and
// lower-cased in Provision(), so any Unicode equivalence (e.g. fullwidth or
// mathematical letters folding to ASCII) would let an attacker upload a file
// whose name contains such code points and have it served as PHP. See
// FrankenPHP advisories GHSA-3g8v-8r37-cgjm and GHSA-v4h7-cj44-8fc8.
//
// Adapted from FrankenPHP's code (copyright 2026 Kévin Dunglas, MIT license)
func (t Transport) splitPos(path string) int {
// TODO: from v1...
@@ -438,31 +441,18 @@ func (t Transport) splitPos(path string) int {
pathLen := len(path)
// We are sure that split strings are all ASCII-only and lower-case because of validation and normalization in Provision().
for _, split := range t.SplitPath {
splitLen := len(split)
if splitLen == 0 || splitLen > pathLen {
continue
}
for i := range pathLen {
if path[i] >= utf8.RuneSelf {
if _, end := splitSearchNonASCII.IndexString(path, split); end > -1 {
return end
}
break
}
if i+splitLen > pathLen {
continue
}
for i := 0; i <= pathLen-splitLen; i++ {
match := true
for j := range splitLen {
c := path[i+j]
if c >= utf8.RuneSelf {
if _, end := splitSearchNonASCII.IndexString(path, split); end > -1 {
return end
}
match = false
break
}
@@ -191,6 +191,65 @@ func TestSplitPos(t *testing.T) {
splitPath: []string{".php"},
wantPos: 9,
},
// Regression tests adapted from FrankenPHP advisories
// GHSA-3g8v-8r37-cgjm and GHSA-v4h7-cj44-8fc8: search.IgnoreCase
// matched Unicode equivalents of ASCII letters as ".php", and an
// inner non-ASCII byte path could leave the match flag stale.
{
name: "non-ascii byte after dot must not match",
path: "/PoC-match-unset.¡.txt",
splitPath: []string{".php"},
wantPos: -1,
},
{
name: "non-ascii byte mid-extension must not match",
path: "/script.p\xc2\xa1p",
splitPath: []string{".php"},
wantPos: -1,
},
{
name: "small full stop ﹒ in extension must not match",
path: "/shell﹒php",
splitPath: []string{".php"},
wantPos: -1,
},
{
name: "fullwidth full stop in extension must not match",
path: "/shellphp",
splitPath: []string{".php"},
wantPos: -1,
},
{
name: "fullwidth p in extension must not match",
path: "/shell.hp",
splitPath: []string{".php"},
wantPos: -1,
},
{
name: "circled php must not match",
path: "/shell.ⓟⓗⓟ",
splitPath: []string{".php"},
wantPos: -1,
},
{
name: "mathematical sans-serif bold php must not match",
path: "/shell.\U0001D5FD\U0001D5F5\U0001D5FD",
splitPath: []string{".php"},
wantPos: -1,
},
{
name: "mathematical script php must not match",
path: "/shell.\U0001D4C5\U0001D4BD\U0001D4C5",
splitPath: []string{".php"},
wantPos: -1,
},
{
name: "circled php with later real php still picks the real one",
path: "/shell.ⓟⓗⓟ.anything-after-payload.php",
splitPath: []string{".php"},
// "/shell." (7) + "ⓟⓗⓟ" (3*3 bytes) + ".anything-after-payload.php" (27) = 43
wantPos: 43,
},
}
for _, tt := range tests {
@@ -244,3 +303,31 @@ func TestSplitPosUnicodeSecurityRegression(t *testing.T) {
assert.Equal(t, ".txt.php", pathInfo, "path info should be the remainder after first .php")
}
}
// TestSplitPosSecurityRegressionUnicodeBypass guards against the FrankenPHP
// advisories GHSA-3g8v-8r37-cgjm (uninitialized match flag on inner non-ASCII
// byte) and GHSA-v4h7-cj44-8fc8 (Unicode equivalence via search.IgnoreCase
// folding fullwidth/mathematical/circled letters onto ASCII). Every payload
// below produced a false positive in the vulnerable implementation; none
// must match here.
func TestSplitPosSecurityRegressionUnicodeBypass(t *testing.T) {
t.Parallel()
tr := Transport{SplitPath: []string{".php"}}
payloads := []string{
"/PoC-match-unset.¡.txt", // GHSA-3g8v: stale match=true on IndexString fallback
"/shell﹒php", // U+FE52 small full stop
"/shellphp", // U+FF0E fullwidth full stop
"/shell.hp", // U+FF50 fullwidth p
"/shell.pp", // U+FF48 fullwidth h
"/shell.ph", // U+FF50 fullwidth p (trailing)
"/shell.\U0001D5C1\U0001D5B5\U0001D5C1", // mathematical sans-serif p/h
"/shell.\U0001D5FD\U0001D5F5\U0001D5FD", // mathematical sans-serif bold p/h
"/shell.\U0001D4C5\U0001D4BD\U0001D4C5", // mathematical script p/h
"/shell.ⓟⓗⓟ", // circled latin small
}
for _, p := range payloads {
assert.Equalf(t, -1, tr.splitPos(p), "payload %q must not be detected as .php", p)
}
}
+1 -2
View File
@@ -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)
}
})
}
}
+126 -19
View File
@@ -574,6 +574,17 @@ func (h *Handler) proxyLoopIteration(r *http.Request, origReq *http.Request, w h
// get the updated list of upstreams
upstreams := h.Upstreams
if h.DynamicUpstreams != nil {
if retries > 0 {
// after a failure (and thus during a retry), give dynamic upstream modules an opportunity
// to purge their relevant cache entries so we don't keep retrying bad upstreams
if cachingDynamicUpstreams, ok := h.DynamicUpstreams.(CachingUpstreamSource); ok {
if err := cachingDynamicUpstreams.ResetCache(r); err != nil {
if c := h.logger.Check(zapcore.ErrorLevel, "failed clearing dynamic upstream source's cache"); c != nil {
c.Write(zap.Error(err))
}
}
}
}
dUpstreams, err := h.DynamicUpstreams.GetUpstreams(r)
if err != nil {
if c := h.logger.Check(zapcore.ErrorLevel, "failed getting dynamic upstreams; falling back to static upstreams"); c != nil {
@@ -670,8 +681,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 +1070,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 +1128,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 +1312,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 +1564,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 +1621,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:"-"`
@@ -1586,10 +1651,28 @@ type Selector interface {
// may be called during each retry, multiple times per request, and as
// such, needs to be instantaneous. The returned slice will not be
// modified.
//
// For upstream sources that cache results, implement the
// [CachingUpstreamSource] interface for optimal performance.
type UpstreamSource interface {
GetUpstreams(*http.Request) ([]*Upstream, error)
}
// CachingUpstreamSource is an upstream source that caches its upstreams.
// The relevant cache entry can be cleared/reset for a given request during
// retries if a request fails. This can help ensure that failing backends
// are not retried.
//
// EXPERIMENTAL: Subject to change.
type CachingUpstreamSource interface {
UpstreamSource
// ResetCache clears any cache entry related to the given request.
// The next time GetUpstreams is called, it should have new upstream
// information for the given request.
ResetCache(*http.Request) error
}
// Hop-by-hop headers. These are removed when sent to the backend.
// As of RFC 7230, hop-by-hop headers are required to appear in the
// Connection header field. These are the headers defined by the
@@ -1662,10 +1745,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 {
@@ -664,10 +664,12 @@ func (s CookieHashSelection) Select(pool UpstreamPool, req *http.Request, w http
return upstream
}
cookie := &http.Cookie{
Name: s.Name,
Value: sha,
Path: "/",
Secure: false,
Name: s.Name,
Value: sha,
Path: "/",
Secure: false,
HttpOnly: true,
SameSite: http.SameSiteLaxMode,
}
isProxyHttps := false
if trusted, ok := caddyhttp.GetVar(req.Context(), caddyhttp.TrustedProxyVarKey).(bool); ok && trusted {
+17 -4
View File
@@ -119,6 +119,18 @@ func (su *SRVUpstreams) Provision(ctx caddy.Context) error {
return nil
}
func (su *SRVUpstreams) ResetCache(r *http.Request) error {
srvsMu.Lock()
if r == nil {
srvs = make(map[string]srvLookup)
} else {
suAddr, _, _, _ := su.expandedAddr(r)
delete(srvs, suAddr)
}
srvsMu.Unlock()
return nil
}
func (su SRVUpstreams) GetUpstreams(r *http.Request) ([]*Upstream, error) {
suAddr, service, proto, name := su.expandedAddr(r)
@@ -554,8 +566,9 @@ var (
// Interface guards
var (
_ caddy.Provisioner = (*SRVUpstreams)(nil)
_ UpstreamSource = (*SRVUpstreams)(nil)
_ caddy.Provisioner = (*AUpstreams)(nil)
_ UpstreamSource = (*AUpstreams)(nil)
_ caddy.Provisioner = (*SRVUpstreams)(nil)
_ UpstreamSource = (*SRVUpstreams)(nil)
_ CachingUpstreamSource = (*SRVUpstreams)(nil)
_ caddy.Provisioner = (*AUpstreams)(nil)
_ UpstreamSource = (*AUpstreams)(nil)
)
+28 -3
View File
@@ -34,7 +34,9 @@ func init() {
// parseCaddyfileRewrite sets up a basic rewrite handler from Caddyfile tokens. Syntax:
//
// rewrite [<matcher>] <to>
// rewrite [<matcher>] <to> {
// force_modify_query
// }
//
// Only URI components which are given in <to> will be set in the resulting URI.
// See the docs for the rewrite handler for more information.
@@ -50,12 +52,30 @@ func parseCaddyfileRewrite(h httpcaddyfile.Helper) ([]httpcaddyfile.ConfigValue,
return nil, h.Errf("too many arguments; should only be a matcher and a URI")
}
parseBlock := func(rewr *Rewrite) error {
for nesting := h.Nesting(); h.NextBlock(nesting); {
switch h.Val() {
case "force_modify_query":
rewr.ForceModifyQuery = true
default:
return h.Errf("unknown subdirective: %s", h.Val())
}
}
return nil
}
// with only one arg, assume it's a rewrite URI with no matcher token
if argsCount == 1 {
if !h.NextArg() {
return nil, h.ArgErr()
}
return h.NewRoute(nil, Rewrite{URI: h.Val()}), nil
rewr := Rewrite{URI: h.Val()}
err := parseBlock(&rewr)
if err != nil {
return nil, err
}
return h.NewRoute(nil, rewr), nil
}
// parse the matcher token into a matcher set
@@ -66,7 +86,12 @@ func parseCaddyfileRewrite(h httpcaddyfile.Helper) ([]httpcaddyfile.ConfigValue,
h.Next() // consume directive name again, matcher parsing does a reset
h.Next() // advance to the rewrite URI
return h.NewRoute(userMatcherSet, Rewrite{URI: h.Val()}), nil
rewr := Rewrite{URI: h.Val()}
err = parseBlock(&rewr)
if err != nil {
return nil, err
}
return h.NewRoute(userMatcherSet, rewr), nil
}
// parseCaddyfileMethod sets up a basic method rewrite handler from Caddyfile tokens. Syntax:
+18 -2
View File
@@ -92,6 +92,17 @@ type Rewrite struct {
// Mutates the query string of the URI.
Query *queryOps `json:"query,omitempty"`
// If true, the rewrite will be forced to also apply to the
// query part of the URL. This is only needed if the configured
// URI does not include a '?' character which is normally used
// to determine whether the query should be modified. In other
// words, this allows rewriting both the path and query when
// using a placeholder as the replacement value, whereas otherwise
// only the path would be rewritten because the placeholder itself
// does not contain a '?' character. Only use this if the placeholder
// is trusted to not be vulnerable to query injections.
ForceModifyQuery bool `json:"force_modify_query,omitempty"`
logger *zap.Logger
}
@@ -226,10 +237,15 @@ func (rewr Rewrite) Rewrite(r *http.Request, repl *caddy.Replacer) bool {
// recompute; new path contains a query string
var injectedQuery string
newPath, injectedQuery = before, after
// don't overwrite explicitly-configured query string
if query == "" {
// don't overwrite explicitly-configured query string,
// unless configured explicitly to do so
if query == "" || rewr.ForceModifyQuery {
query = injectedQuery
}
if rewr.ForceModifyQuery {
qsStart = 0
}
}
if query != "" {
+20
View File
@@ -225,6 +225,23 @@ func TestRewrite(t *testing.T) {
input: newRequest(t, "GET", "/foo#fragFirst?c=d"),
expect: newRequest(t, "GET", "/bar#fragFirst?c=d"),
},
{
rule: Rewrite{URI: "{test.path_and_query}"},
input: newRequest(t, "GET", "/"),
expect: newRequest(t, "GET", "/foo"),
},
{
// TODO: This might be an incorrect result, since it also replaces
// the path with empty string when that might not be the intent.
rule: Rewrite{URI: "{test.query}", ForceModifyQuery: true},
input: newRequest(t, "GET", "/foo"),
expect: newRequest(t, "GET", "?bar=1"),
},
{
rule: Rewrite{URI: "{test.path_and_query}", ForceModifyQuery: true},
input: newRequest(t, "GET", "/"),
expect: newRequest(t, "GET", "/foo?bar=1"),
},
{
rule: Rewrite{URI: "/api/admin/panel"},
input: newRequest(t, "GET", "/api/admin%2Fpanel"),
@@ -364,6 +381,9 @@ func TestRewrite(t *testing.T) {
repl.Set("http.request.uri", tc.input.RequestURI)
repl.Set("http.request.uri.path", tc.input.URL.Path)
repl.Set("http.request.uri.query", tc.input.URL.RawQuery)
repl.Set("test.path", "/foo")
repl.Set("test.query", "?bar=1")
repl.Set("test.path_and_query", "/foo?bar=1")
// we can't directly call Provision() without a valid caddy.Context
// (TODO: fix that) so here we ad-hoc compile the regex
+5 -4
View File
@@ -18,6 +18,7 @@ import (
"encoding/json"
"fmt"
"net/http"
"slices"
"strings"
"github.com/caddyserver/caddy/v2"
@@ -241,8 +242,8 @@ func (routes RouteList) Compile(next Handler) Handler {
mid = append(mid, wrapRoute(route))
}
stack := next
for i := len(mid) - 1; i >= 0; i-- {
stack = mid[i](stack)
for _, middleware := range slices.Backward(mid) {
stack = middleware(stack)
}
return stack
}
@@ -305,8 +306,8 @@ func wrapRoute(route Route) Middleware {
}
// compile this route's handler stack
for i := len(route.middleware) - 1; i >= 0; i-- {
nextCopy = route.middleware[i](nextCopy)
for _, middleware := range slices.Backward(route.middleware) {
nextCopy = middleware(nextCopy)
}
// Apply metrics instrumentation once for the entire route,
+5 -6
View File
@@ -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)
@@ -1086,11 +1085,11 @@ func strictUntrustedClientIp(r *http.Request, headers []string, trusted []netip.
for _, headerName := range headers {
parts := strings.Split(strings.Join(r.Header.Values(headerName), ","), ",")
for i := len(parts) - 1; i >= 0; i-- {
for _, part := range slices.Backward(parts) {
// Some proxies may retain the port number, so split if possible
host, _, err := net.SplitHostPort(parts[i])
host, _, err := net.SplitHostPort(part)
if err != nil {
host = parts[i]
host = part
}
// Remove any zone identifier from the IP address
+1 -2
View File
@@ -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)
}
+36
View File
@@ -140,6 +140,42 @@ func (iss *ACMEIssuer) Provision(ctx caddy.Context) error {
iss.Email = email
}
// expand CA endpoint, if non-empty
if iss.CA != "" {
ca, err := repl.ReplaceOrErr(iss.CA, true, true)
if err != nil {
return fmt.Errorf("expanding CA endpoint '%s': %v", iss.CA, err)
}
iss.CA = ca
}
// expand TestCA endpoint, if non-empty
if iss.TestCA != "" {
testca, err := repl.ReplaceOrErr(iss.TestCA, true, true)
if err != nil {
return fmt.Errorf("expanding TestCA endpoint '%s': %v", iss.TestCA, err)
}
iss.TestCA = testca
}
// expand EAB credentials, if non-empty
if iss.ExternalAccount != nil {
if iss.ExternalAccount.KeyID != "" {
keyID, err := repl.ReplaceOrErr(iss.ExternalAccount.KeyID, true, true)
if err != nil {
return fmt.Errorf("expanding EAB key ID '%s': %v", iss.ExternalAccount.KeyID, err)
}
iss.ExternalAccount.KeyID = keyID
}
if iss.ExternalAccount.MACKey != "" {
macKey, err := repl.ReplaceOrErr(iss.ExternalAccount.MACKey, true, true)
if err != nil {
return fmt.Errorf("expanding EAB MAC key (redacted): %v", err)
}
iss.ExternalAccount.MACKey = macKey
}
}
// expand account key, if non-empty
if iss.AccountKey != "" {
accountKey, err := repl.ReplaceOrErr(iss.AccountKey, true, true)
+43
View File
@@ -0,0 +1,43 @@
package caddytls
import (
"github.com/caddyserver/caddy/v2"
"github.com/mholt/acmez/v3/acme"
"testing"
)
func TestACMEIssuerExpandPlaceholders(t *testing.T) {
t.Setenv("CADDY_TEST_CA_URL", "https://acme.example.com/directory")
t.Setenv("CADDY_TEST_TEST_CA_URL", "https://acme2.example.com/directory")
t.Setenv("CADDY_TEST_EAB_KEY_ID", "example-key-id")
t.Setenv("CADDY_TEST_EAB_MAC_KEY", "example-mac-key")
caddyCtx, cancel := caddy.NewContext(caddy.Context{Context: t.Context()})
defer cancel()
iss := &ACMEIssuer{
CA: "{env.CADDY_TEST_CA_URL}",
TestCA: "{env.CADDY_TEST_TEST_CA_URL}",
ExternalAccount: &acme.EAB{
KeyID: "{env.CADDY_TEST_EAB_KEY_ID}",
MACKey: "{env.CADDY_TEST_EAB_MAC_KEY}",
},
}
if err := iss.Provision(caddyCtx); err != nil {
t.Fatalf("Provision() returned unexpected error: %v", err)
}
if want := "https://acme.example.com/directory"; iss.CA != want {
t.Errorf("CA: got %q, want %q", iss.CA, want)
}
if want := "https://acme2.example.com/directory"; iss.TestCA != want {
t.Errorf("TestCA: got %q, want %q", iss.TestCA, want)
}
if want := "example-key-id"; iss.ExternalAccount.KeyID != want {
t.Errorf("ExternalAccount.KeyID: got %q, want %q", iss.ExternalAccount.KeyID, want)
}
if want := "example-mac-key"; iss.ExternalAccount.MACKey != want {
t.Errorf("ExternalAccount.MACKey: got %q, want %q", iss.ExternalAccount.MACKey, want)
}
}
+41
View File
@@ -174,6 +174,47 @@ func TestFileRotationPreserveMode(t *testing.T) {
}
}
func TestFileRotationPreserveModeWithUmask(t *testing.T) {
m := syscall.Umask(0o022)
defer syscall.Umask(m)
dir, err := os.MkdirTemp("", "caddytest")
if err != nil {
t.Fatalf("failed to create tempdir: %v", err)
}
defer os.RemoveAll(dir)
fpath := path.Join(dir, "test.log")
roll := true
mode := fileMode(0o660)
fw := FileWriter{
Filename: fpath,
Mode: mode,
Roll: &roll,
RollSizeMB: 1,
}
logger, err := fw.OpenWriter()
if err != nil {
t.Fatalf("failed to create file: %v", err)
}
defer logger.Close()
b := make([]byte, 1024*1024-1000)
logger.Write(b)
logger.Write(b[0:2000])
st, err := os.Stat(fpath)
if err != nil {
t.Fatalf("failed to check file permissions: %v", err)
}
if got := st.Mode().Perm(); got != os.FileMode(mode) {
t.Errorf("file mode after rotation is %v, want %v", got, mode)
}
}
func TestFileModeConfig(t *testing.T) {
tests := []struct {
name string
+13 -13
View File
@@ -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)
}
+16 -16
View File
@@ -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
View File
@@ -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()
+10 -8
View File
@@ -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