Compare commits

...

42 Commits

Author SHA1 Message Date
Matthew Holt ffb6ab0644 Revert cosign (see #7536)
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 1m28s
Cross-Build / build (~1.26.0, 1.26, darwin) (push) Successful in 1m26s
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 1m31s
Cross-Build / build (~1.26.0, 1.26, illumos) (push) Successful in 1m27s
Cross-Build / build (~1.26.0, 1.26, linux) (push) Successful in 1m24s
Cross-Build / build (~1.26.0, 1.26, netbsd) (push) Successful in 1m21s
Cross-Build / build (~1.26.0, 1.26, openbsd) (push) Successful in 1m37s
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 1m29s
Lint / lint (ubuntu-latest, linux) (push) Successful in 2m2s
Lint / govulncheck (push) Successful in 1m26s
Lint / dependency-review (push) Failing after 27s
OpenSSF Scorecard supply-chain security / Scorecard analysis (push) Failing after 21s
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-03-05 08:41:54 -07:00
dependabot[bot] 9371ee67c6 build(deps): bump the actions-deps group across 1 directory with 12 updates (#7536)
Tests / test (./cmd/caddy/caddy, ~1.26.0, ubuntu-latest, 0, 1.26, linux) (push) Failing after 2m39s
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 1m28s
Cross-Build / build (~1.26.0, 1.26, darwin) (push) Successful in 1m24s
Cross-Build / build (~1.26.0, 1.26, dragonfly) (push) Successful in 1m22s
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 1m23s
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 1m28s
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 1m27s
Cross-Build / build (~1.26.0, 1.26, windows) (push) Successful in 1m27s
Lint / lint (ubuntu-latest, linux) (push) Successful in 2m34s
Lint / govulncheck (push) Successful in 1m26s
Lint / dependency-review (push) Failing after 1m2s
OpenSSF Scorecard supply-chain security / Scorecard analysis (push) Failing after 5m36s
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 the actions-deps group with 12 updates in the / directory:

| Package | From | To |
| --- | --- | --- |
| [actions/checkout](https://github.com/actions/checkout) | `5.0.0` | `6.0.2` |
| [github/ai-moderator](https://github.com/github/ai-moderator) | `1.1.2` | `1.1.4` |
| [step-security/harden-runner](https://github.com/step-security/harden-runner) | `2.13.1` | `2.15.0` |
| [actions/setup-go](https://github.com/actions/setup-go) | `6.0.0` | `6.3.0` |
| [actions/upload-artifact](https://github.com/actions/upload-artifact) | `4.6.2` | `7.0.0` |
| [goreleaser/goreleaser-action](https://github.com/goreleaser/goreleaser-action) | `6.4.0` | `7.0.0` |
| [golangci/golangci-lint-action](https://github.com/golangci/golangci-lint-action) | `8.0.0` | `9.2.0` |
| [actions/dependency-review-action](https://github.com/actions/dependency-review-action) | `4.8.0` | `4.8.3` |
| [sigstore/cosign-installer](https://github.com/sigstore/cosign-installer) | `3.10.0` | `4.0.0` |
| [anchore/sbom-action](https://github.com/anchore/sbom-action) | `0.20.6` | `0.23.0` |
| [peter-evans/repository-dispatch](https://github.com/peter-evans/repository-dispatch) | `4.0.0` | `4.0.1` |
| [github/codeql-action](https://github.com/github/codeql-action) | `3.30.5` | `4.32.4` |



Updates `actions/checkout` from 5.0.0 to 6.0.2
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/checkout/compare/08c6903cd8c0fde910a37f88322edcfb5dd907a8...de0fac2e4500dabe0009e67214ff5f5447ce83dd)

Updates `github/ai-moderator` from 1.1.2 to 1.1.4
- [Release notes](https://github.com/github/ai-moderator/releases)
- [Commits](https://github.com/github/ai-moderator/compare/6bcdb2a79c2e564db8d76d7d4439d91a044c4eb6...81159c370785e295c97461ade67d7c33576e9319)

Updates `step-security/harden-runner` from 2.13.1 to 2.15.0
- [Release notes](https://github.com/step-security/harden-runner/releases)
- [Commits](https://github.com/step-security/harden-runner/compare/f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a...a90bcbc6539c36a85cdfeb73f7e2f433735f215b)

Updates `actions/setup-go` from 6.0.0 to 6.3.0
- [Release notes](https://github.com/actions/setup-go/releases)
- [Commits](https://github.com/actions/setup-go/compare/44694675825211faa026b3c33043df3e48a5fa00...4b73464bb391d4059bd26b0524d20df3927bd417)

Updates `actions/upload-artifact` from 4.6.2 to 7.0.0
- [Release notes](https://github.com/actions/upload-artifact/releases)
- [Commits](https://github.com/actions/upload-artifact/compare/ea165f8d65b6e75b540449e92b4886f43607fa02...bbbca2ddaa5d8feaa63e36b76fdaad77386f024f)

Updates `goreleaser/goreleaser-action` from 6.4.0 to 7.0.0
- [Release notes](https://github.com/goreleaser/goreleaser-action/releases)
- [Commits](https://github.com/goreleaser/goreleaser-action/compare/e435ccd777264be153ace6237001ef4d979d3a7a...ec59f474b9834571250b370d4735c50f8e2d1e29)

Updates `golangci/golangci-lint-action` from 8.0.0 to 9.2.0
- [Release notes](https://github.com/golangci/golangci-lint-action/releases)
- [Commits](https://github.com/golangci/golangci-lint-action/compare/4afd733a84b1f43292c63897423277bb7f4313a9...1e7e51e771db61008b38414a730f564565cf7c20)

Updates `actions/dependency-review-action` from 4.8.0 to 4.8.3
- [Release notes](https://github.com/actions/dependency-review-action/releases)
- [Commits](https://github.com/actions/dependency-review-action/compare/56339e523c0409420f6c2c9a2f4292bbb3c07dd3...05fe4576374b728f0c523d6a13d64c25081e0803)

Updates `sigstore/cosign-installer` from 3.10.0 to 4.0.0
- [Release notes](https://github.com/sigstore/cosign-installer/releases)
- [Commits](https://github.com/sigstore/cosign-installer/compare/d7543c93d881b35a8faa02e8e3605f69b7a1ce62...faadad0cce49287aee09b3a48701e75088a2c6ad)

Updates `anchore/sbom-action` from 0.20.6 to 0.23.0
- [Release notes](https://github.com/anchore/sbom-action/releases)
- [Changelog](https://github.com/anchore/sbom-action/blob/main/RELEASE.md)
- [Commits](https://github.com/anchore/sbom-action/compare/f8bdd1d8ac5e901a77a92f111440fdb1b593736b...17ae1740179002c89186b61233e0f892c3118b11)

Updates `peter-evans/repository-dispatch` from 4.0.0 to 4.0.1
- [Release notes](https://github.com/peter-evans/repository-dispatch/releases)
- [Commits](https://github.com/peter-evans/repository-dispatch/compare/5fc4efd1a4797ddb68ffd0714a238564e4cc0e6f...28959ce8df70de7be546dd1250a005dd32156697)

Updates `github/codeql-action` from 3.30.5 to 4.32.4
- [Release notes](https://github.com/github/codeql-action/releases)
- [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md)
- [Commits](https://github.com/github/codeql-action/compare/3599b3baa15b485a2e49ef411a7a4bb2452e7f93...89a39a4e59826350b863aa6b6252a07ad50cf83e)

---
updated-dependencies:
- dependency-name: actions/checkout
  dependency-version: 6.0.2
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: actions-deps
- dependency-name: github/ai-moderator
  dependency-version: 1.1.4
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: actions-deps
- dependency-name: step-security/harden-runner
  dependency-version: 2.15.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: actions-deps
- dependency-name: actions/setup-go
  dependency-version: 6.3.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: actions-deps
- dependency-name: actions/upload-artifact
  dependency-version: 7.0.0
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: actions-deps
- dependency-name: goreleaser/goreleaser-action
  dependency-version: 7.0.0
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: actions-deps
- dependency-name: golangci/golangci-lint-action
  dependency-version: 9.2.0
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: actions-deps
- dependency-name: actions/dependency-review-action
  dependency-version: 4.8.3
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: actions-deps
- dependency-name: sigstore/cosign-installer
  dependency-version: 4.0.0
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: actions-deps
- dependency-name: anchore/sbom-action
  dependency-version: 0.23.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: actions-deps
- dependency-name: peter-evans/repository-dispatch
  dependency-version: 4.0.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: actions-deps
- dependency-name: github/codeql-action
  dependency-version: 4.32.4
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: actions-deps
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-04 21:29:44 -07:00
dependabot[bot] 5d20adc7a9 build(deps): bump github.com/smallstep/certificates (#7535)
Bumps the all-updates group with 1 update: [github.com/smallstep/certificates](https://github.com/smallstep/certificates).


Updates `github.com/smallstep/certificates` from 0.30.0-rc2.0.20260211214201-20608299c29c to 0.30.0-rc3
- [Release notes](https://github.com/smallstep/certificates/releases)
- [Changelog](https://github.com/smallstep/certificates/blob/master/CHANGELOG.md)
- [Commits](https://github.com/smallstep/certificates/commits/v0.30.0-rc3)

---
updated-dependencies:
- dependency-name: github.com/smallstep/certificates
  dependency-version: 0.30.0-rc3
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: all-updates
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-04 21:26:18 -07:00
Francis Lavoie 6e5e08cf58 Wire up Cause for most context cancels (#7538) 2026-03-04 17:14:52 -07:00
Matthew Holt fbfb8fc517 rewrite: Force recomputing path when escaped path matches rewrite target
Thank you for the report by @MaherAzzouzi, and the suggested fix!
2026-03-04 16:18:33 -07:00
Matt Holt e06dfcf6ed Update SECURITY.md
Simplify what versions are supported, clarify our policy for unreleased code (or beta code), and expand our AI policy to require a disclosure in ALL cases, even if AI is not used. As well as an invitation to share in some chocolate milk with us if you're human.
2026-03-04 16:16:24 -07:00
Oleh Konko | semantic verification for trust infra | LLM-augmented operations pipeline (precision-first, claim≤evidence, submit-human) | verify the payload, not the signer 566e710991 fileserver: document hide case-sensitivity (F-CADDY-FILESERVER-HIDE-CASE-001) (#7548) 2026-03-04 17:00:10 -05:00
Tom Paulus a5e7c6e232 reverseproxy: prevent body close on dial-error retries (#7547)
Tests / test (./cmd/caddy/caddy, ~1.26.0, ubuntu-latest, 0, 1.26, linux) (push) Failing after 2m58s
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 1m42s
Cross-Build / build (~1.26.0, 1.26, darwin) (push) Successful in 1m33s
Cross-Build / build (~1.26.0, 1.26, dragonfly) (push) Successful in 1m27s
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 1m24s
Cross-Build / build (~1.26.0, 1.26, linux) (push) Successful in 1m33s
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 1m29s
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 1m25s
Lint / lint (ubuntu-latest, linux) (push) Successful in 2m30s
Lint / govulncheck (push) Successful in 1m50s
Lint / dependency-review (push) Failing after 1m27s
OpenSSF Scorecard supply-chain security / Scorecard analysis (push) Failing after 5m36s
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-03-04 15:17:02 -05:00
Francis Lavoie db2986028f reverseproxy: Track dynamic upstreams, enable passive healthchecking (#7539)
* reverseproxy: Track dynamic upstreams, enable passive healthchecking

* Add tests for dynamic upstream tracking, admin endpoint, health checks
2026-03-04 15:05:26 -05:00
Sam.An 7e83775e3a Merge commit from fork
Only apply repl.ReplaceAll() on values from literal variable names
(e.g. map outputs), not on values resolved from placeholder keys
(e.g. {http.request.header.*}). The placeholder path already resolves
the value via repl.Get(), so a second expansion allows user-controlled
input containing {env.*} or {file.*} to be evaluated, leaking
environment variables and file contents.

Add regression test to verify placeholder-sourced values are not
re-expanded.
2026-03-04 09:08:39 -07:00
newklei 2dbcdefbbe forward_auth: copy_headers does not strip client-supplied identity headers (Fixes GHSA-7r4p-vjf4-gxv4) (#7545)
Tests / test (./cmd/caddy/caddy, ~1.26.0, ubuntu-latest, 0, 1.26, linux) (push) Failing after 1m48s
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 1m50s
Cross-Build / build (~1.26.0, 1.26, darwin) (push) Successful in 1m58s
Cross-Build / build (~1.26.0, 1.26, dragonfly) (push) Successful in 1m58s
Cross-Build / build (~1.26.0, 1.26, freebsd) (push) Successful in 2m12s
Cross-Build / build (~1.26.0, 1.26, illumos) (push) Successful in 2m2s
Cross-Build / build (~1.26.0, 1.26, linux) (push) Successful in 1m54s
Cross-Build / build (~1.26.0, 1.26, netbsd) (push) Successful in 1m40s
Cross-Build / build (~1.26.0, 1.26, openbsd) (push) Successful in 1m41s
Cross-Build / build (~1.26.0, 1.26, solaris) (push) Successful in 1m46s
Cross-Build / build (~1.26.0, 1.26, windows) (push) Successful in 1m39s
Lint / lint (ubuntu-latest, linux) (push) Successful in 2m31s
Lint / govulncheck (push) Successful in 1m32s
Lint / dependency-review (push) Failing after 28s
OpenSSF Scorecard supply-chain security / Scorecard analysis (push) Failing after 38s
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
When using copy_headers in a forward_auth block, client-supplied headers with
the same names were not being removed before being forwarded to the backend.

This happens because PR #6608 added a MatchNot guard that skips the Set
operation when the auth service does not return a given header. That guard
prevents setting headers to empty strings, which is the correct behavior,
but it also means a client can send X-User-Id: admin in their request and
if the auth service validates the token without returning X-User-Id, Caddy
skips the Set and the client value passes through unchanged to the backend.

The fix adds an unconditional delete route for each copy_headers entry,
placed just before the existing conditional set route. The delete always runs
regardless of what the auth service returns. The conditional set still only
runs when the auth service provides that header.

The end result is:
  - Client-supplied headers are always removed
  - When the auth service returns the header, the backend gets that value
  - When the auth service does not return the header, the backend sees nothing

Existing behavior is unchanged for any deployment where the auth service
returns all of the configured copy_headers entries.

Fixes GHSA-7r4p-vjf4-gxv4
2026-03-03 23:30:49 -05:00
Varun Chawla dc36082859 caddyhttp: Collect metrics once per route instead of per handler (#7492)
* perf: collect metrics once per route instead of per handler (#4644)

Move Prometheus metrics instrumentation from the per-handler level to
the per-route level. Previously, every middleware handler in a route was
individually wrapped with metricsInstrumentedHandler, causing metrics to
be collected N times per request (once per handler in the chain). Since
all handlers in a route see the same request, these per-handler metrics
were redundant and added significant CPU overhead (73% of request
handling time per the original profiling).

The fix introduces metricsInstrumentedRoute which wraps the entire
compiled handler chain once in wrapRoute, collecting metrics only when
the route actually matches. The handler label uses the first handler's
module name, which is the most meaningful identifier for the route.

Benchmark results (5 handlers per route):
  Old (per-handler):  ~4650 ns/op, 4400 B/op, 45 allocs/op
  New (per-route):    ~940 ns/op,  816 B/op,   8 allocs/op
  Improvement:        ~5x faster, ~5.4x less memory, ~5.6x fewer allocs

Signed-off-by: Varun Chawla <varun_6april@hotmail.com>

* Remove unused metricsInstrumentedHandler code

Delete the metricsInstrumentedHandler type, its constructor, and
ServeHTTP method since they are no longer used after switching to
route-level metrics collection via metricsInstrumentedRoute. Also
remove the unused metrics parameter from wrapMiddleware and the
middlewareHandlerFunc test helper, and convert existing tests to
use the new route-level API.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* Address review feedback: restore comments, move function to bottom

- Move computeApproximateRequestSize back to bottom of file to minimize diff
- Restore all useful comments that were accidentally dropped
- Old metricsInstrumentedHandler already removed in previous commit

---------

Signed-off-by: Varun Chawla <varun_6april@hotmail.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 15:15:55 -07:00
Paulo Henrique 88616e86e6 api: Add all in-flight requests /reverse_proxy/upstreams (Fixes #7277) (#7517)
This refactors the initial approach in PR #7281, replacing the UsagePool
with a dedicated package-level sync.Map and atomic.Int64 to track
in-flight requests without global lock contention.

It also introduces a lookup map in the admin API to fix a potential
O(n^2) iteration over upstreams, ensuring that draining upstreams
are correctly exposed across config reloads without leaking memory.

Co-authored-by: Y.Horie <u5.horie@gmail.com>

reverseproxy: optimize in-flight tracking and admin API

- Replaced sync.RWMutex with sync.Map and atomic.Int64 to avoid lock contention under high RPS.
- Introduced a lookup map in the admin API to fix a potential O(n^2) iteration over upstreams.
2026-03-03 15:14:55 -07:00
Salent Olivick 7b34e3107e core: Check whether @id is unique (#7002)
* caddy.go: Check whether @id is unique(#6991)

* Alternate implementation, using Gemini 3.1

---------

Co-authored-by: Francis Lavoie <lavofr@gmail.com>
2026-03-03 15:09:49 -07:00
Francis Lavoie a6acb3902c proxyproto: Generated test coverage (#7540) 2026-03-03 15:08:09 -07:00
Francis Lavoie 45cf61b127 logging: Ensure slog error level logs don't print stack traces (#7512) 2026-03-03 14:44:42 -07:00
Francis Lavoie d935a6956c autohttps: Ensure CertMagic config is recreated after autohttps runs (#7510) 2026-03-03 14:44:06 -07:00
prettysunflower 2dd3852416 fix(caddyfile): Prevent parser to panic when no token were added by empty {block} (#7543)
Tests / test (./cmd/caddy/caddy, ~1.26.0, ubuntu-latest, 0, 1.26, linux) (push) Failing after 1m24s
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 1m35s
Cross-Build / build (~1.26.0, 1.26, darwin) (push) Successful in 1m33s
Cross-Build / build (~1.26.0, 1.26, dragonfly) (push) Successful in 1m33s
Cross-Build / build (~1.26.0, 1.26, freebsd) (push) Successful in 1m30s
Cross-Build / build (~1.26.0, 1.26, illumos) (push) Successful in 1m37s
Cross-Build / build (~1.26.0, 1.26, linux) (push) Successful in 1m40s
Cross-Build / build (~1.26.0, 1.26, netbsd) (push) Successful in 2m8s
Cross-Build / build (~1.26.0, 1.26, openbsd) (push) Successful in 1m34s
Cross-Build / build (~1.26.0, 1.26, solaris) (push) Successful in 1m35s
Cross-Build / build (~1.26.0, 1.26, windows) (push) Successful in 1m33s
Lint / lint (ubuntu-latest, linux) (push) Successful in 2m18s
Lint / govulncheck (push) Successful in 1m17s
Lint / dependency-review (push) Failing after 24s
OpenSSF Scorecard supply-chain security / Scorecard analysis (push) Failing after 36s
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-03-03 13:16:21 -05:00
Akın Demirci 11b56c6cfc reverseproxy: Fix health_port being ignored in health checks (#7533) 2026-03-03 13:10:54 -05:00
Alexandre Daubois f283062d37 cmd: Custom binary names through CustomBinaryName and CustomLongDescription (#7513)
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 1m36s
Cross-Build / build (~1.26.0, 1.26, darwin) (push) Successful in 1m39s
Cross-Build / build (~1.26.0, 1.26, dragonfly) (push) Successful in 1m27s
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 1m37s
Cross-Build / build (~1.26.0, 1.26, linux) (push) Successful in 1m50s
Cross-Build / build (~1.26.0, 1.26, netbsd) (push) Successful in 1m39s
Cross-Build / build (~1.26.0, 1.26, openbsd) (push) Successful in 1m46s
Cross-Build / build (~1.26.0, 1.26, solaris) (push) Successful in 1m30s
Cross-Build / build (~1.26.0, 1.26, windows) (push) Successful in 1m30s
Lint / lint (ubuntu-latest, linux) (push) Successful in 2m15s
Lint / govulncheck (push) Successful in 1m20s
Lint / dependency-review (push) Failing after 24s
OpenSSF Scorecard supply-chain security / Scorecard analysis (push) Failing after 35s
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-03-02 06:04:28 -05:00
WeidiDeng 2ab043b890 reverseproxy: query escape request urls when proxy protocol is enabled (#7537) 2026-03-02 02:04:06 -05:00
Pavel Siomachkin f145bce553 tls: Add tls_resolvers global option for DNS challenge configuration (#7297)
Tests / test (./cmd/caddy/caddy, ~1.26.0, ubuntu-latest, 0, 1.26, linux) (push) Failing after 1m37s
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 1m53s
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 1m42s
Cross-Build / build (~1.26.0, 1.26, freebsd) (push) Successful in 1m47s
Cross-Build / build (~1.26.0, 1.26, illumos) (push) Successful in 1m51s
Cross-Build / build (~1.26.0, 1.26, linux) (push) Successful in 2m12s
Cross-Build / build (~1.26.0, 1.26, netbsd) (push) Successful in 1m49s
Cross-Build / build (~1.26.0, 1.26, openbsd) (push) Successful in 1m39s
Cross-Build / build (~1.26.0, 1.26, solaris) (push) Successful in 1m39s
Cross-Build / build (~1.26.0, 1.26, windows) (push) Successful in 1m33s
Lint / lint (ubuntu-latest, linux) (push) Successful in 2m22s
Lint / govulncheck (push) Successful in 1m24s
Lint / dependency-review (push) Failing after 25s
OpenSSF Scorecard supply-chain security / Scorecard analysis (push) Failing after 37s
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
Co-authored-by: Francis Lavoie <lavofr@gmail.com>
2026-03-01 15:32:04 -05:00
Matt Holt 174fa2ddb9 caddyhttp: Evaluate tls.client placeholders more accurately (fix #7530) (#7534)
Tests / test (./cmd/caddy/caddy, ~1.26.0, ubuntu-latest, 0, 1.26, linux) (push) Failing after 2m13s
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 1m33s
Cross-Build / build (~1.26.0, 1.26, darwin) (push) Successful in 1m40s
Cross-Build / build (~1.26.0, 1.26, dragonfly) (push) Successful in 1m33s
Cross-Build / build (~1.26.0, 1.26, freebsd) (push) Successful in 1m38s
Cross-Build / build (~1.26.0, 1.26, illumos) (push) Successful in 1m43s
Cross-Build / build (~1.26.0, 1.26, linux) (push) Successful in 1m48s
Cross-Build / build (~1.26.0, 1.26, netbsd) (push) Successful in 1m51s
Cross-Build / build (~1.26.0, 1.26, openbsd) (push) Successful in 1m37s
Cross-Build / build (~1.26.0, 1.26, solaris) (push) Successful in 1m35s
Cross-Build / build (~1.26.0, 1.26, windows) (push) Successful in 1m35s
Lint / lint (ubuntu-latest, linux) (push) Successful in 2m29s
Lint / govulncheck (push) Successful in 1m24s
Lint / dependency-review (push) Failing after 25s
OpenSSF Scorecard supply-chain security / Scorecard analysis (push) Failing after 34s
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-02-28 22:03:18 -07:00
Matt Holt cd9e1660aa cmd: Pass configFile, not configFlag, for reload command (#7532)
Tests / test (./cmd/caddy/caddy, ~1.26.0, ubuntu-latest, 0, 1.26, linux) (push) Failing after 1m41s
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 1m40s
Cross-Build / build (~1.26.0, 1.26, darwin) (push) Successful in 1m46s
Cross-Build / build (~1.26.0, 1.26, dragonfly) (push) Successful in 1m36s
Cross-Build / build (~1.26.0, 1.26, freebsd) (push) Successful in 1m31s
Cross-Build / build (~1.26.0, 1.26, illumos) (push) Successful in 1m47s
Cross-Build / build (~1.26.0, 1.26, linux) (push) Successful in 2m4s
Cross-Build / build (~1.26.0, 1.26, netbsd) (push) Successful in 1m38s
Cross-Build / build (~1.26.0, 1.26, openbsd) (push) Successful in 1m52s
Cross-Build / build (~1.26.0, 1.26, solaris) (push) Successful in 1m49s
Cross-Build / build (~1.26.0, 1.26, windows) (push) Successful in 1m36s
Lint / lint (ubuntu-latest, linux) (push) Successful in 2m55s
Lint / govulncheck (push) Successful in 2m5s
Lint / dependency-review (push) Failing after 44s
OpenSSF Scorecard supply-chain security / Scorecard analysis (push) Failing after 1m9s
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
* cmd: Pass configFile, not configFlag, for reload command

This *should* fix #7528.

* Remove debug log line

---------

Co-authored-by: Francis Lavoie <lavofr@gmail.com>
2026-02-27 15:24:05 -07:00
Matthew Holt 06a05e383c Revert "encode: Implement Flush for legacy compatibility"
This reverts commit bdcdaf77ba.
2026-02-27 14:14:19 -07:00
Matthew Holt ce203aa9e1 go.mod: Upgrade x/net
Tests / test (./cmd/caddy/caddy, ~1.26.0, ubuntu-latest, 0, 1.26, linux) (push) Failing after 1m38s
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 1m45s
Cross-Build / build (~1.26.0, 1.26, darwin) (push) Successful in 1m34s
Cross-Build / build (~1.26.0, 1.26, dragonfly) (push) Successful in 1m36s
Cross-Build / build (~1.26.0, 1.26, freebsd) (push) Successful in 1m53s
Cross-Build / build (~1.26.0, 1.26, illumos) (push) Successful in 1m37s
Cross-Build / build (~1.26.0, 1.26, linux) (push) Successful in 1m58s
Cross-Build / build (~1.26.0, 1.26, netbsd) (push) Successful in 1m49s
Cross-Build / build (~1.26.0, 1.26, openbsd) (push) Successful in 1m46s
Cross-Build / build (~1.26.0, 1.26, solaris) (push) Successful in 1m45s
Cross-Build / build (~1.26.0, 1.26, windows) (push) Successful in 2m19s
Lint / lint (ubuntu-latest, linux) (push) Successful in 2m35s
Lint / govulncheck (push) Successful in 2m4s
Lint / dependency-review (push) Failing after 30s
OpenSSF Scorecard supply-chain security / Scorecard analysis (push) Failing after 1m4s
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-02-27 10:35:24 -07:00
Matthew Holt eac02ee98f caddyhttp: Limit empty Host check to HTTP/1.1 2026-02-27 10:22:39 -07:00
Oleksandr Redko 72eaf2583a chore: Enable modernize linter (#7519)
Tests / test (./cmd/caddy/caddy, ~1.26.0, ubuntu-latest, 0, 1.26, linux) (push) Failing after 1m39s
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 1m32s
Cross-Build / build (~1.26.0, 1.26, darwin) (push) Successful in 1m34s
Cross-Build / build (~1.26.0, 1.26, dragonfly) (push) Successful in 1m45s
Cross-Build / build (~1.26.0, 1.26, freebsd) (push) Successful in 1m30s
Cross-Build / build (~1.26.0, 1.26, illumos) (push) Successful in 1m42s
Cross-Build / build (~1.26.0, 1.26, linux) (push) Successful in 2m12s
Cross-Build / build (~1.26.0, 1.26, netbsd) (push) Successful in 1m33s
Cross-Build / build (~1.26.0, 1.26, openbsd) (push) Successful in 1m28s
Cross-Build / build (~1.26.0, 1.26, solaris) (push) Successful in 1m50s
Cross-Build / build (~1.26.0, 1.26, windows) (push) Successful in 1m26s
Lint / lint (ubuntu-latest, linux) (push) Successful in 2m20s
Lint / govulncheck (push) Failing after 1m40s
Lint / dependency-review (push) Failing after 32s
OpenSSF Scorecard supply-chain security / Scorecard analysis (push) Failing after 48s
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-02-26 14:01:35 -07:00
Fardjad Davari 9798f6964d caddyhttp: Avoid nil pointer dereference in proxyWrapper (#7521)
Tests / test (./cmd/caddy/caddy, ~1.26.0, ubuntu-latest, 0, 1.26, linux) (push) Failing after 1m25s
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 1m29s
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 1m34s
Cross-Build / build (~1.26.0, 1.26, freebsd) (push) Successful in 1m39s
Cross-Build / build (~1.26.0, 1.26, illumos) (push) Successful in 1m27s
Cross-Build / build (~1.26.0, 1.26, linux) (push) Successful in 1m37s
Cross-Build / build (~1.26.0, 1.26, netbsd) (push) Successful in 1m59s
Cross-Build / build (~1.26.0, 1.26, openbsd) (push) Successful in 1m30s
Cross-Build / build (~1.26.0, 1.26, windows) (push) Successful in 1m28s
Lint / lint (ubuntu-latest, linux) (push) Successful in 2m3s
Lint / govulncheck (push) Successful in 1m22s
Lint / dependency-review (push) Failing after 23s
OpenSSF Scorecard supply-chain security / Scorecard analysis (push) Failing after 55s
Cross-Build / build (~1.26.0, 1.26, solaris) (push) Failing after 10m20s
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-02-25 04:08:41 -05:00
Francis Lavoie 9873752978 logging: Support zstd roll compression (#7515)
Tests / test (./cmd/caddy/caddy, ~1.26.0, ubuntu-latest, 0, 1.26, linux) (push) Failing after 1m20s
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 1m22s
Cross-Build / build (~1.26.0, 1.26, dragonfly) (push) Successful in 1m34s
Cross-Build / build (~1.26.0, 1.26, freebsd) (push) Successful in 1m28s
Cross-Build / build (~1.26.0, 1.26, illumos) (push) Successful in 1m31s
Cross-Build / build (~1.26.0, 1.26, linux) (push) Successful in 1m27s
Cross-Build / build (~1.26.0, 1.26, netbsd) (push) Successful in 2m0s
Cross-Build / build (~1.26.0, 1.26, openbsd) (push) Successful in 1m25s
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 1m23s
Lint / lint (ubuntu-latest, linux) (push) Successful in 2m0s
Lint / govulncheck (push) Successful in 1m18s
Lint / dependency-review (push) Failing after 25s
OpenSSF Scorecard supply-chain security / Scorecard analysis (push) Failing after 33s
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-02-23 16:04:45 -07:00
Dean Ruina 294dfff443 logging: add DirMode options and propagate FileMode to rotations (#7335)
Tests / test (./cmd/caddy/caddy, ~1.26.0, ubuntu-latest, 0, 1.26, linux) (push) Failing after 1m20s
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 1m28s
Cross-Build / build (~1.26.0, 1.26, darwin) (push) Successful in 1m20s
Cross-Build / build (~1.26.0, 1.26, dragonfly) (push) Successful in 1m22s
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 1m26s
Cross-Build / build (~1.26.0, 1.26, linux) (push) Successful in 1m30s
Cross-Build / build (~1.26.0, 1.26, netbsd) (push) Successful in 1m30s
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 1m27s
Cross-Build / build (~1.26.0, 1.26, windows) (push) Successful in 1m21s
Lint / lint (ubuntu-latest, linux) (push) Successful in 2m10s
Lint / govulncheck (push) Successful in 1m20s
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
Co-authored-by: Francis Lavoie <lavofr@gmail.com>
2026-02-23 07:27:27 +00:00
Paulo Henrique 76b198f586 http: Sort auto-HTTPS redirect routes by host specificity (fixes #7390) (#7502)
Tests / test (./cmd/caddy/caddy, ~1.26.0, ubuntu-latest, 0, 1.26, linux) (push) Failing after 1m49s
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 1m46s
Cross-Build / build (~1.26.0, 1.26, darwin) (push) Successful in 1m31s
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 1m31s
Cross-Build / build (~1.26.0, 1.26, illumos) (push) Successful in 1m38s
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 2m22s
Cross-Build / build (~1.26.0, 1.26, openbsd) (push) Successful in 1m40s
Cross-Build / build (~1.26.0, 1.26, solaris) (push) Successful in 1m57s
Cross-Build / build (~1.26.0, 1.26, windows) (push) Successful in 1m27s
Lint / lint (ubuntu-latest, linux) (push) Successful in 2m13s
Lint / govulncheck (push) Successful in 2m1s
Lint / dependency-review (push) Failing after 1m12s
OpenSSF Scorecard supply-chain security / Scorecard analysis (push) Failing after 50s
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-02-21 21:42:40 -05:00
Paulo Henrique 7ffb640a4d httpcaddyfile: Fix missing TLS connection policies when auto_https is default (#7325) (#7507) 2026-02-21 21:42:03 -05:00
Mohammed Al Sahaf d7b21c6104 reverseproxy: fix tls dialing w/ proxy protocol (#7508) 2026-02-21 21:37:10 -05:00
Francis Lavoie 6610e2f1bd chore: Disable windows/arm build target (Go 1.26 disabled) (#7503)
Tests / test (./cmd/caddy/caddy, ~1.26.0, ubuntu-latest, 0, 1.26, linux) (push) Failing after 1m45s
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 1m25s
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 1m28s
Cross-Build / build (~1.26.0, 1.26, freebsd) (push) Successful in 1m24s
Cross-Build / build (~1.26.0, 1.26, illumos) (push) Successful in 1m27s
Cross-Build / build (~1.26.0, 1.26, linux) (push) Successful in 1m27s
Cross-Build / build (~1.26.0, 1.26, netbsd) (push) Successful in 1m36s
Cross-Build / build (~1.26.0, 1.26, openbsd) (push) Successful in 1m37s
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 1m26s
Lint / lint (ubuntu-latest, linux) (push) Successful in 2m3s
Lint / govulncheck (push) Successful in 1m35s
Lint / dependency-review (push) Failing after 23s
OpenSSF Scorecard supply-chain security / Scorecard analysis (push) Failing after 41s
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-02-20 22:47:21 +00:00
Matthew Holt 03243e42fe go.mod: Upgrade dependencies 2026-02-20 12:28:11 -07:00
Matthew Holt cb436f0a0e fileserver: Fix tests on Windows 2026-02-20 11:46:45 -07:00
Matt Holt a1081194bf Merge commit from fork
Necessary as otherwise the early-bail in `until =
strings.IndexByte(remaining, nextCh) ... if until == -1` can cause a
case-insensitive mismatch

Co-authored-by: Asim Viladi Oglu Manizada <manizada@users.noreply.github.com>
2026-02-20 10:54:50 -07:00
Asim Viladi Oglu Manizada eec32a0bb5 Merge commit from fork
Normalize exact hosts at provisioning and reqHost in the fast path so case-different Host variants can’t bypass host-gated routes.

Co-authored-by: Asim Viladi Oglu Manizada <manizada@users.noreply.github.com>
2026-02-20 10:19:42 -07:00
Matthew Holt a2825c5dd9 fileserver: Replace \ with \\ in file matcher paths
Tests / test (./cmd/caddy/caddy, ~1.26.0, ubuntu-latest, 0, 1.26, linux) (push) Failing after 1m15s
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 1m25s
Cross-Build / build (~1.26.0, 1.26, darwin) (push) Successful in 1m32s
Cross-Build / build (~1.26.0, 1.26, dragonfly) (push) Successful in 1m28s
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 1m26s
Cross-Build / build (~1.26.0, 1.26, linux) (push) Successful in 1m24s
Cross-Build / build (~1.26.0, 1.26, netbsd) (push) Successful in 1m49s
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 1m23s
Cross-Build / build (~1.26.0, 1.26, windows) (push) Successful in 1m39s
Lint / lint (ubuntu-latest, linux) (push) Successful in 2m4s
Lint / govulncheck (push) Successful in 1m20s
Lint / dependency-review (push) Failing after 25s
OpenSSF Scorecard supply-chain security / Scorecard analysis (push) Failing after 30s
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-02-19 13:18:14 -07:00
dependabot[bot] db256b53e5 build(deps): bump filippo.io/edwards25519 from 1.1.0 to 1.1.1 (#7497)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-02-19 14:20:06 -05:00
Matthew Holt 6772ffb805 Revert "listeners: Add support for named socket activation (#7243)"
This reverts commit 156ce99d3a.
2026-02-19 11:32:26 -07:00
83 changed files with 4628 additions and 684 deletions
+8 -7
View File
@@ -1,15 +1,14 @@
# Security Policy
The Caddy project would like to make sure that it stays on top of all practically-exploitable vulnerabilities.
The Caddy project would like to make sure that it stays on top of all relevant and practically-exploitable vulnerabilities.
## Supported Versions
| Version | Supported |
| -------- | ----------|
| 2.latest | ✔️ |
| 1.x | :x: |
| < 1.x | :x: |
| Version | Supported |
| ----------- | ----------|
| 2.latest | ✔️ |
| <= 2.latest | :x: |
## Acceptable Scope
@@ -26,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.
We accept security reports and patches, but do not assign CVEs, for code that has not been released with a non-prerelease tag.
## Reporting a Vulnerability
@@ -33,7 +34,7 @@ We get a lot of difficult reports that turn out to be invalid. Clear, obvious re
First please ensure your report falls within the accepted scope of security bugs (above).
**YOU MUST DISCLOSE THE USE OF LLMs ("AI") INVOLVED IN ANY WAY.** Whether you are using AI for discovery, as part of writing the report or its replies, and/or testing or validating proofs and changes, we require you to mention the extent of it. **FAILURE TO INCLUDE A DISCLOSURE MAY LEAD TO IMMEDIATE DISMISSAL OF YOUR REPORT AND POTENTIAL BLOCKLISTING.**
:warning: **YOU MUST DISCLOSE WHETHER YOU USED LLMs ("AI") IN ANY WAY.** Whether you are using AI for discovery, as part of writing the report or its replies, and/or testing or validating proofs and changes, we require you to mention the extent of it. **FAILURE TO INCLUDE A DISCLOSURE EVEN IF YOU DO NOT USE AI MAY LEAD TO IMMEDIATE DISMISSAL OF YOUR REPORT AND POTENTIAL BLOCKLISTING.** We will not waste our time chatting with bots. But if you're a human, pull up a chair and we'll drink some chocolate milk.
We'll need enough information to verify the bug and make a patch. To speed things up, please include:
+2 -2
View File
@@ -16,8 +16,8 @@ jobs:
models: read
contents: read
steps:
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8
- uses: github/ai-moderator@6bcdb2a79c2e564db8d76d7d4439d91a044c4eb6
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd
- uses: github/ai-moderator@81159c370785e295c97461ade67d7c33576e9319
with:
token: ${{ secrets.GITHUB_TOKEN }}
spam-label: 'spam'
+11 -11
View File
@@ -65,15 +65,15 @@ jobs:
actions: write # to allow uploading artifacts and cache
steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1
uses: step-security/harden-runner@a90bcbc6539c36a85cdfeb73f7e2f433735f215b # v2.15.0
with:
egress-policy: audit
- name: Checkout code
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Install Go
uses: actions/setup-go@44694675825211faa026b3c33043df3e48a5fa00 # v6.0.0
uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6.3.0
with:
go-version: ${{ matrix.GO_SEMVER }}
check-latest: true
@@ -120,7 +120,7 @@ jobs:
./caddy stop
- name: Publish Build Artifact
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
with:
name: caddy_${{ runner.os }}_go${{ matrix.go }}_${{ steps.vars.outputs.short_sha }}
path: ${{ matrix.CADDY_BIN_PATH }}
@@ -162,13 +162,13 @@ jobs:
continue-on-error: true # August 2020: s390x VM is down due to weather and power issues
steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1
uses: step-security/harden-runner@a90bcbc6539c36a85cdfeb73f7e2f433735f215b # v2.15.0
with:
egress-policy: audit
allowed-endpoints: ci-s390x.caddyserver.com:22
- name: Checkout code
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Run Tests
run: |
set +e
@@ -221,19 +221,19 @@ jobs:
if: github.event.pull_request.head.repo.full_name == 'caddyserver/caddy' && github.actor != 'dependabot[bot]'
steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1
uses: step-security/harden-runner@a90bcbc6539c36a85cdfeb73f7e2f433735f215b # v2.15.0
with:
egress-policy: audit
- name: Checkout code
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: goreleaser/goreleaser-action@e435ccd777264be153ace6237001ef4d979d3a7a # v6.4.0
- uses: goreleaser/goreleaser-action@ec59f474b9834571250b370d4735c50f8e2d1e29 # v7.0.0
with:
version: latest
args: check
- name: Install Go
uses: actions/setup-go@44694675825211faa026b3c33043df3e48a5fa00 # v6.0.0
uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6.3.0
with:
go-version: "~1.26"
check-latest: true
@@ -241,7 +241,7 @@ jobs:
run: |
go install github.com/caddyserver/xcaddy/cmd/xcaddy@latest
xcaddy version
- uses: goreleaser/goreleaser-action@e435ccd777264be153ace6237001ef4d979d3a7a # v6.4.0
- uses: goreleaser/goreleaser-action@ec59f474b9834571250b370d4735c50f8e2d1e29 # v7.0.0
with:
version: latest
args: build --single-target --snapshot
+3 -3
View File
@@ -51,15 +51,15 @@ jobs:
continue-on-error: true
steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1
uses: step-security/harden-runner@a90bcbc6539c36a85cdfeb73f7e2f433735f215b # v2.15.0
with:
egress-policy: audit
- name: Checkout code
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Install Go
uses: actions/setup-go@44694675825211faa026b3c33043df3e48a5fa00 # v6.0.0
uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6.3.0
with:
go-version: ${{ matrix.GO_SEMVER }}
check-latest: true
+8 -8
View File
@@ -45,18 +45,18 @@ jobs:
steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1
uses: step-security/harden-runner@a90bcbc6539c36a85cdfeb73f7e2f433735f215b # v2.15.0
with:
egress-policy: audit
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
- uses: actions/setup-go@44694675825211faa026b3c33043df3e48a5fa00 # v6.0.0
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6.3.0
with:
go-version: '~1.26'
check-latest: true
- name: golangci-lint
uses: golangci/golangci-lint-action@4afd733a84b1f43292c63897423277bb7f4313a9 # v8.0.0
uses: golangci/golangci-lint-action@1e7e51e771db61008b38414a730f564565cf7c20 # v9.2.0
with:
version: latest
@@ -73,7 +73,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1
uses: step-security/harden-runner@a90bcbc6539c36a85cdfeb73f7e2f433735f215b # v2.15.0
with:
egress-policy: audit
@@ -90,14 +90,14 @@ jobs:
pull-requests: write
steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1
uses: step-security/harden-runner@a90bcbc6539c36a85cdfeb73f7e2f433735f215b # v2.15.0
with:
egress-policy: audit
- name: 'Checkout Repository'
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: 'Dependency Review'
uses: actions/dependency-review-action@56339e523c0409420f6c2c9a2f4292bbb3c07dd3 # v4.8.0
uses: actions/dependency-review-action@05fe4576374b728f0c523d6a13d64c25081e0803 # v4.8.3
with:
comment-summary-in-pr: on-failure
# https://github.com/actions/dependency-review-action/issues/430#issuecomment-1468975566
+2 -2
View File
@@ -28,11 +28,11 @@ jobs:
steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1
uses: step-security/harden-runner@a90bcbc6539c36a85cdfeb73f7e2f433735f215b # v2.15.0
with:
egress-policy: audit
- name: Checkout code
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
fetch-depth: 0
+7 -7
View File
@@ -28,7 +28,7 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
fetch-depth: 0
# Force fetch upstream tags -- because 65 minutes
@@ -355,23 +355,23 @@ jobs:
steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1
uses: step-security/harden-runner@a90bcbc6539c36a85cdfeb73f7e2f433735f215b # v2.15.0
with:
egress-policy: audit
- name: Checkout code
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
fetch-depth: 0
- name: Install Go
uses: actions/setup-go@44694675825211faa026b3c33043df3e48a5fa00 # v6.0.0
uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6.3.0
with:
go-version: ${{ matrix.GO_SEMVER }}
check-latest: true
# Force fetch upstream tags -- because 65 minutes
# tl;dr: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v4.2.2 runs this line:
# tl;dr: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v4.2.2 runs this line:
# git -c protocol.version=2 fetch --no-tags --prune --progress --no-recurse-submodules --depth=1 origin +ebc278ec98bb24f2852b61fde2a9bf2e3d83818b:refs/tags/
# which makes its own local lightweight tag, losing all the annotations in the process. Our earlier script ran:
# git fetch --prune --unshallow
@@ -419,7 +419,7 @@ jobs:
- name: Cosign version
run: cosign version
- name: Install Syft
uses: anchore/sbom-action/download-syft@f8bdd1d8ac5e901a77a92f111440fdb1b593736b # main
uses: anchore/sbom-action/download-syft@17ae1740179002c89186b61233e0f892c3118b11 # main
- name: Syft version
run: syft version
- name: Install xcaddy
@@ -428,7 +428,7 @@ jobs:
xcaddy version
# GoReleaser will take care of publishing those artifacts into the release
- name: Run GoReleaser
uses: goreleaser/goreleaser-action@e435ccd777264be153ace6237001ef4d979d3a7a # v6.4.0
uses: goreleaser/goreleaser-action@ec59f474b9834571250b370d4735c50f8e2d1e29 # v7.0.0
with:
version: latest
args: release --clean --timeout 60m
+3 -3
View File
@@ -24,12 +24,12 @@ jobs:
# See https://github.com/peter-evans/repository-dispatch
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1
uses: step-security/harden-runner@a90bcbc6539c36a85cdfeb73f7e2f433735f215b # v2.15.0
with:
egress-policy: audit
- name: Trigger event on caddyserver/dist
uses: peter-evans/repository-dispatch@5fc4efd1a4797ddb68ffd0714a238564e4cc0e6f # v4.0.0
uses: peter-evans/repository-dispatch@28959ce8df70de7be546dd1250a005dd32156697 # v4.0.1
with:
token: ${{ secrets.REPO_DISPATCH_TOKEN }}
repository: caddyserver/dist
@@ -37,7 +37,7 @@ jobs:
client-payload: '{"tag": "${{ github.event.release.tag_name }}"}'
- name: Trigger event on caddyserver/caddy-docker
uses: peter-evans/repository-dispatch@5fc4efd1a4797ddb68ffd0714a238564e4cc0e6f # v4.0.0
uses: peter-evans/repository-dispatch@28959ce8df70de7be546dd1250a005dd32156697 # v4.0.1
with:
token: ${{ secrets.REPO_DISPATCH_TOKEN }}
repository: caddyserver/caddy-docker
+4 -4
View File
@@ -37,12 +37,12 @@ jobs:
steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1
uses: step-security/harden-runner@a90bcbc6539c36a85cdfeb73f7e2f433735f215b # v2.15.0
with:
egress-policy: audit
- name: "Checkout code"
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
@@ -72,7 +72,7 @@ jobs:
# Upload the results as artifacts (optional). Commenting out will disable uploads of run results in SARIF
# format to the repository Actions tab.
- name: "Upload artifact"
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
with:
name: SARIF file
path: results.sarif
@@ -81,6 +81,6 @@ jobs:
# Upload the results to GitHub's code scanning dashboard (optional).
# Commenting out will disable upload of results to your repo's Code Scanning dashboard
- name: "Upload to code-scanning"
uses: github/codeql-action/upload-sarif@3599b3baa15b485a2e49ef411a7a4bb2452e7f93 # v3.29.5
uses: github/codeql-action/upload-sarif@89a39a4e59826350b863aa6b6252a07ad50cf83e # v3.29.5
with:
sarif_file: results.sarif
+1
View File
@@ -32,6 +32,7 @@ linters:
- importas
- ineffassign
- misspell
- modernize
- prealloc
- promlinter
- sloglint
+3 -1
View File
@@ -13,7 +13,7 @@ before:
- cp cmd/caddy/main.go caddy-build/main.go
- /bin/sh -c 'cd ./caddy-build && go mod init caddy'
# prepare syso files for windows embedding
- /bin/sh -c 'for a in amd64 arm arm64; do XCADDY_SKIP_BUILD=1 GOOS=windows GOARCH=$a xcaddy build {{.Env.TAG}}; done'
- /bin/sh -c 'for a in amd64 arm64; do XCADDY_SKIP_BUILD=1 GOOS=windows GOARCH=$a xcaddy build {{.Env.TAG}}; done'
- /bin/sh -c 'mv /tmp/buildenv_*/*.syso caddy-build'
# GoReleaser doesn't seem to offer {{.Tag}} at this stage, so we have to embed it into the env
# so we run: TAG=$(git describe --abbrev=0) goreleaser release --rm-dist --skip-publish --skip-validate
@@ -67,6 +67,8 @@ builds:
goarch: s390x
- goos: windows
goarch: riscv64
- goos: windows
goarch: arm
- goos: freebsd
goarch: ppc64le
- goos: freebsd
+5 -1
View File
@@ -749,10 +749,14 @@ func stopAdminServer(srv *http.Server) error {
if srv == nil {
return fmt.Errorf("no admin server")
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
timeout := 10 * time.Second
ctx, cancel := context.WithTimeoutCause(context.Background(), timeout, fmt.Errorf("stopping admin server: %ds timeout", int(timeout.Seconds())))
defer cancel()
err := srv.Shutdown(ctx)
if err != nil {
if cause := context.Cause(ctx); cause != nil && errors.Is(err, context.DeadlineExceeded) {
err = cause
}
return fmt.Errorf("shutting down admin server: %v", err)
}
Log().Named("admin").Info("stopped previous server", zap.String("address", srv.Addr))
+55 -10
View File
@@ -88,7 +88,7 @@ type Config struct {
storage certmagic.Storage
eventEmitter eventEmitter
cancelFunc context.CancelFunc
cancelFunc context.CancelCauseFunc
// fileSystems is a dict of fileSystems that will later be loaded from and added to.
fileSystems FileSystems
@@ -227,8 +227,18 @@ func changeConfig(method, path string, input []byte, ifMatchHeader string, force
idx := make(map[string]string)
err = indexConfigObjects(rawCfg[rawConfigKey], "/"+rawConfigKey, idx)
if err != nil {
if len(rawCfgJSON) > 0 {
var oldCfg any
err2 := json.Unmarshal(rawCfgJSON, &oldCfg)
if err2 != nil {
err = fmt.Errorf("%v; additionally, restoring old config: %v", err, err2)
}
rawCfg[rawConfigKey] = oldCfg
} else {
rawCfg[rawConfigKey] = nil
}
return APIError{
HTTPStatus: http.StatusInternalServerError,
HTTPStatus: http.StatusBadRequest,
Err: fmt.Errorf("indexing config: %v", err),
}
}
@@ -248,6 +258,8 @@ func changeConfig(method, path string, input []byte, ifMatchHeader string, force
err = fmt.Errorf("%v; additionally, restoring old config: %v", err, err2)
}
rawCfg[rawConfigKey] = oldCfg
} else {
rawCfg[rawConfigKey] = nil
}
return fmt.Errorf("loading new config: %v", err)
@@ -281,14 +293,19 @@ func indexConfigObjects(ptr any, configPath string, index map[string]string) err
case map[string]any:
for k, v := range val {
if k == idKey {
var idStr string
switch idVal := v.(type) {
case string:
index[idVal] = configPath
idStr = idVal
case float64: // all JSON numbers decode as float64
index[fmt.Sprintf("%v", idVal)] = configPath
idStr = fmt.Sprintf("%v", idVal)
default:
return fmt.Errorf("%s: %s field must be a string or number", configPath, idKey)
}
if existingPath, ok := index[idStr]; ok {
return fmt.Errorf("duplicate ID '%s' found at %s and %s", idStr, existingPath, configPath)
}
index[idStr] = configPath
continue
}
// traverse this object property recursively
@@ -416,7 +433,7 @@ func run(newCfg *Config, start bool) (Context, error) {
// partially copied from provisionContext
if err != nil {
globalMetrics.configSuccess.Set(0)
ctx.cfg.cancelFunc()
ctx.cfg.cancelFunc(fmt.Errorf("configuration start error: %w", err))
if currentCtx.cfg != nil {
certmagic.Default.Storage = currentCtx.cfg.storage
@@ -492,7 +509,7 @@ func provisionContext(newCfg *Config, replaceAdminServer bool) (Context, error)
// cleanup occurs when we return if there
// was an error; if no error, it will get
// cleaned up on next config cycle
ctx, cancel := NewContext(Context{Context: context.Background(), cfg: newCfg})
ctx, cancelCause := NewContextWithCause(Context{Context: context.Background(), cfg: newCfg})
defer func() {
if err != nil {
globalMetrics.configSuccess.Set(0)
@@ -501,7 +518,7 @@ func provisionContext(newCfg *Config, replaceAdminServer bool) (Context, error)
// since the associated config won't be used;
// this will cause all modules that were newly
// provisioned to clean themselves up
cancel()
cancelCause(fmt.Errorf("configuration error: %w", err))
// also undo any other state changes we made
if currentCtx.cfg != nil {
@@ -509,7 +526,7 @@ func provisionContext(newCfg *Config, replaceAdminServer bool) (Context, error)
}
}
}()
newCfg.cancelFunc = cancel // clean up later
newCfg.cancelFunc = cancelCause // clean up later
// set up logging before anything bad happens
if newCfg.Logging == nil {
@@ -729,7 +746,7 @@ func unsyncedStop(ctx Context) {
}
// clean up all modules
ctx.cfg.cancelFunc()
ctx.cfg.cancelFunc(fmt.Errorf("stopping apps"))
}
// Validate loads, provisions, and validates
@@ -737,7 +754,7 @@ func unsyncedStop(ctx Context) {
func Validate(cfg *Config) error {
_, err := run(cfg, false)
if err == nil {
cfg.cancelFunc() // call Cleanup on all modules
cfg.cancelFunc(fmt.Errorf("validation complete")) // call Cleanup on all modules
}
return err
}
@@ -945,6 +962,34 @@ func InstanceID() (uuid.UUID, error) {
// for example.
var CustomVersion string
// CustomBinaryName is an optional string that overrides the root
// command name from the default of "caddy". This is useful for
// downstream projects that embed Caddy but use a different binary
// name. Shell completions and help text will use this name instead
// of "caddy".
//
// Set this variable during `go build` with `-ldflags`:
//
// -ldflags '-X github.com/caddyserver/caddy/v2.CustomBinaryName=my_custom_caddy'
//
// for example.
var CustomBinaryName string
// CustomLongDescription is an optional string that overrides the
// long description of the root Cobra command. This is useful for
// downstream projects that embed Caddy but want different help
// output.
//
// Set this variable in an init() function of a package that is
// imported by your main:
//
// func init() {
// caddy.CustomLongDescription = "My custom server based on Caddy..."
// }
//
// for example.
var CustomLongDescription string
// Version returns the Caddy version in a simple/short form, and
// a full version string. The short form will not have spaces and
// is intended for User-Agent strings and similar, but may be
+1 -1
View File
@@ -270,7 +270,7 @@ func (d *Dispenser) File() string {
// targets are left unchanged. If all the targets are filled,
// then true is returned.
func (d *Dispenser) Args(targets ...*string) bool {
for i := 0; i < len(targets); i++ {
for i := range targets {
if !d.NextArg() {
return false
}
+2 -2
View File
@@ -507,7 +507,7 @@ func (p *parser) doImport(nesting int) error {
// format, won't check for nesting correctness or any other error, that's what parser does.
if !maybeSnippet && nesting == 0 {
// first of the line
if i == 0 || isNextOnNewLine(tokensCopy[i-1], token) {
if i == 0 || isNextOnNewLine(tokensCopy[len(tokensCopy)-1], token) {
index = 0
} else {
index++
@@ -616,7 +616,7 @@ func (p *parser) doSingleImport(importFile string) ([]Token, error) {
if err != nil {
return nil, p.Errf("Failed to get absolute path of file: %s: %v", importFile, err)
}
for i := 0; i < len(importedTokens); i++ {
for i := range importedTokens {
importedTokens[i].File = filename
}
+1 -1
View File
@@ -822,7 +822,7 @@ func (st *ServerType) serversFromPairings(
// https://caddy.community/t/making-sense-of-auto-https-and-why-disabling-it-still-serves-https-instead-of-http/9761
createdTLSConnPolicies, ok := sblock.pile["tls.connection_policy"]
hasTLSEnabled := (ok && len(createdTLSConnPolicies) > 0) ||
(addr.Host != "" && srv.AutoHTTPS != nil && !slices.Contains(srv.AutoHTTPS.Skip, addr.Host))
(addr.Host != "" && (srv.AutoHTTPS == nil || !slices.Contains(srv.AutoHTTPS.Skip, addr.Host)))
// we'll need to remember if the address qualifies for auto-HTTPS, so we
// can add a TLS conn policy if necessary
@@ -1,9 +1,11 @@
package httpcaddyfile
import (
"encoding/json"
"testing"
"github.com/caddyserver/caddy/v2/caddyconfig/caddyfile"
"github.com/caddyserver/caddy/v2/modules/caddyhttp"
)
func TestMatcherSyntax(t *testing.T) {
@@ -209,3 +211,53 @@ func TestGlobalOptions(t *testing.T) {
}
}
}
func TestDefaultSNIWithoutHTTPS(t *testing.T) {
caddyfileStr := `{
default_sni my-sni.com
}
example.com {
}`
adapter := caddyfile.Adapter{
ServerType: ServerType{},
}
result, _, err := adapter.Adapt([]byte(caddyfileStr), nil)
if err != nil {
t.Fatalf("Failed to adapt Caddyfile: %v", err)
}
var config struct {
Apps struct {
HTTP struct {
Servers map[string]*caddyhttp.Server `json:"servers"`
} `json:"http"`
} `json:"apps"`
}
if err := json.Unmarshal(result, &config); err != nil {
t.Fatalf("Failed to unmarshal JSON config: %v", err)
}
server, ok := config.Apps.HTTP.Servers["srv0"]
if !ok {
t.Fatalf("Expected server 'srv0' to be created")
}
if len(server.TLSConnPolicies) == 0 {
t.Fatalf("Expected TLS connection policies to be generated, got none")
}
found := false
for _, policy := range server.TLSConnPolicies {
if policy.DefaultSNI == "my-sni.com" {
found = true
break
}
}
if !found {
t.Errorf("Expected default_sni 'my-sni.com' in TLS connection policies, but it was missing. Generated JSON: %s", string(result))
}
}
+10
View File
@@ -64,6 +64,7 @@ func init() {
RegisterGlobalOption("preferred_chains", parseOptPreferredChains)
RegisterGlobalOption("persist_config", parseOptPersistConfig)
RegisterGlobalOption("dns", parseOptDNS)
RegisterGlobalOption("tls_resolvers", parseOptTLSResolvers)
RegisterGlobalOption("ech", parseOptECH)
RegisterGlobalOption("renewal_window_ratio", parseOptRenewalWindowRatio)
}
@@ -306,6 +307,15 @@ func parseOptSingleString(d *caddyfile.Dispenser, _ any) (any, error) {
return val, nil
}
func parseOptTLSResolvers(d *caddyfile.Dispenser, _ any) (any, error) {
d.Next() // consume option name
resolvers := d.RemainingArgs()
if len(resolvers) == 0 {
return nil, d.ArgErr()
}
return resolvers, nil
}
func parseOptDefaultBind(d *caddyfile.Dispenser, _ any) (any, error) {
d.Next() // consume option name
+104
View File
@@ -1,9 +1,11 @@
package httpcaddyfile
import (
"encoding/json"
"testing"
"github.com/caddyserver/caddy/v2/caddyconfig/caddyfile"
"github.com/caddyserver/caddy/v2/modules/caddytls"
_ "github.com/caddyserver/caddy/v2/modules/logging"
)
@@ -62,3 +64,105 @@ func TestGlobalLogOptionSyntax(t *testing.T) {
}
}
}
func TestGlobalResolversOption(t *testing.T) {
tests := []struct {
name string
input string
expectResolvers []string
expectError bool
}{
{
name: "single resolver",
input: `{
tls_resolvers 1.1.1.1
}
example.com {
}`,
expectResolvers: []string{"1.1.1.1"},
expectError: false,
},
{
name: "two resolvers",
input: `{
tls_resolvers 1.1.1.1 8.8.8.8
}
example.com {
}`,
expectResolvers: []string{"1.1.1.1", "8.8.8.8"},
expectError: false,
},
{
name: "multiple resolvers",
input: `{
tls_resolvers 1.1.1.1 8.8.8.8 9.9.9.9
}
example.com {
}`,
expectResolvers: []string{"1.1.1.1", "8.8.8.8", "9.9.9.9"},
expectError: false,
},
{
name: "no resolvers specified",
input: `{
}
example.com {
}`,
expectResolvers: nil,
expectError: false,
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
adapter := caddyfile.Adapter{
ServerType: ServerType{},
}
out, _, err := adapter.Adapt([]byte(tc.input), nil)
if (err != nil) != tc.expectError {
t.Errorf("error expectation failed. Expected error: %v, got: %v", tc.expectError, err)
return
}
if tc.expectError {
return
}
// Parse the output JSON to check resolvers
var config struct {
Apps struct {
TLS *caddytls.TLS `json:"tls"`
} `json:"apps"`
}
if err := json.Unmarshal(out, &config); err != nil {
t.Errorf("failed to unmarshal output: %v", err)
return
}
// Check if resolvers match expected
if config.Apps.TLS == nil {
if tc.expectResolvers != nil {
t.Errorf("Expected TLS config with resolvers %v, but TLS config is nil", tc.expectResolvers)
}
return
}
actualResolvers := config.Apps.TLS.Resolvers
if len(tc.expectResolvers) == 0 && len(actualResolvers) == 0 {
return // Both empty, ok
}
if len(actualResolvers) != len(tc.expectResolvers) {
t.Errorf("Expected %d resolvers, got %d. Expected: %v, got: %v", len(tc.expectResolvers), len(actualResolvers), tc.expectResolvers, actualResolvers)
return
}
for j, expected := range tc.expectResolvers {
if actualResolvers[j] != expected {
t.Errorf("Resolver %d mismatch. Expected: %s, got: %s", j, expected, actualResolvers[j])
}
}
})
}
}
+14
View File
@@ -334,6 +334,11 @@ func (st ServerType) buildTLSApp(
tlsApp.DNSRaw = caddyconfig.JSONModuleObject(globalDNS, "name", globalDNS.(caddy.Module).CaddyModule().ID.Name(), nil)
}
// set up "global" (to the TLS app) DNS resolvers config
if globalResolvers, ok := options["tls_resolvers"]; ok && globalResolvers != nil {
tlsApp.Resolvers = globalResolvers.([]string)
}
// set up ECH from Caddyfile options
if ech, ok := options["ech"].(*caddytls.ECH); ok {
tlsApp.EncryptedClientHello = ech
@@ -595,6 +600,15 @@ func fillInGlobalACMEDefaults(issuer certmagic.Issuer, options map[string]any) e
if globalCertLifetime != nil && acmeIssuer.CertificateLifetime == 0 {
acmeIssuer.CertificateLifetime = globalCertLifetime.(caddy.Duration)
}
// apply global resolvers if DNS challenge is configured and resolvers are not already set
globalResolvers := options["tls_resolvers"]
if globalResolvers != nil && acmeIssuer.Challenges != nil && acmeIssuer.Challenges.DNS != nil {
// Check if DNS challenge is actually configured
hasDNSChallenge := globalACMEDNSok || acmeIssuer.Challenges.DNS.ProviderRaw != nil
if hasDNSChallenge && len(acmeIssuer.Challenges.DNS.Resolvers) == 0 {
acmeIssuer.Challenges.DNS.Resolvers = globalResolvers.([]string)
}
}
return nil
}
+1 -1
View File
@@ -151,7 +151,7 @@ func doHttpCallWithRetries(ctx caddy.Context, client *http.Client, request *http
var err error
const maxAttempts = 10
for i := 0; i < maxAttempts; i++ {
for i := range maxAttempts {
resp, err = attemptHttpCall(client, request)
if err != nil && i < maxAttempts-1 {
select {
+116
View File
@@ -1,6 +1,7 @@
package caddytest
import (
"bytes"
"net/http"
"strings"
"testing"
@@ -126,3 +127,118 @@ func TestLoadUnorderedJSON(t *testing.T) {
}
tester.AssertResponseCode(req, 200)
}
func TestCheckID(t *testing.T) {
tester := NewTester(t)
tester.InitServer(`{
"admin": {
"listen": "localhost:2999"
},
"apps": {
"http": {
"http_port": 9080,
"servers": {
"s_server": {
"@id": "s_server",
"listen": [
":9080"
],
"routes": [
{
"handle": [
{
"handler": "static_response",
"body": "Hello"
}
]
}
]
}
}
}
}
}
`, "json")
headers := []string{"Content-Type:application/json"}
sServer1 := []byte(`{"@id":"s_server","listen":[":9080"],"routes":[{"@id":"route1","handle":[{"handler":"static_response","body":"Hello 2"}]}]}`)
// PUT to an existing ID should fail with a 409 conflict
tester.AssertPutResponseBody(
"http://localhost:2999/id/s_server",
headers,
bytes.NewBuffer(sServer1),
409,
`{"error":"[/config/apps/http/servers/s_server] key already exists: s_server"}`+"\n")
// POST replaces the object fully
tester.AssertPostResponseBody(
"http://localhost:2999/id/s_server",
headers,
bytes.NewBuffer(sServer1),
200,
"")
// Verify the server is running the new route
tester.AssertGetResponse(
"http://localhost:9080/",
200,
"Hello 2")
// Update the existing route to ensure IDs are handled correctly when replaced
tester.AssertPostResponseBody(
"http://localhost:2999/id/s_server",
headers,
bytes.NewBuffer([]byte(`{"@id":"s_server","listen":[":9080"],"routes":[{"@id":"route1","handle":[{"handler":"static_response","body":"Hello2"}],"match":[{"path":["/route_1/*"]}]}]}`)),
200,
"")
sServer2 := []byte(`{"@id":"s_server","listen":[":9080"],"routes":[{"@id":"route1","handle":[{"handler":"static_response","body":"Hello2"}],"match":[{"path":["/route_1/*"]}]}]}`)
// Identical patch should succeed and return 200 (config is unchanged branch)
tester.AssertPatchResponseBody(
"http://localhost:2999/id/s_server",
headers,
bytes.NewBuffer(sServer2),
200,
"")
route2 := []byte(`{"@id":"route2","handle": [{"handler": "static_response","body": "route2"}],"match":[{"path":["/route_2/*"]}]}`)
// Put a new route2 object before the route1 object due to the path of /id/route1
// Being translated to: /config/apps/http/servers/s_server/routes/0
tester.AssertPutResponseBody(
"http://localhost:2999/id/route1",
headers,
bytes.NewBuffer(route2),
200,
"")
// Verify that the whole config looks correct, now containing both route1 and route2
tester.AssertGetResponse(
"http://localhost:2999/config/",
200,
`{"admin":{"listen":"localhost:2999"},"apps":{"http":{"http_port":9080,"servers":{"s_server":{"@id":"s_server","listen":[":9080"],"routes":[{"@id":"route2","handle":[{"body":"route2","handler":"static_response"}],"match":[{"path":["/route_2/*"]}]},{"@id":"route1","handle":[{"body":"Hello2","handler":"static_response"}],"match":[{"path":["/route_1/*"]}]}]}}}}}`+"\n")
// Try to add another copy of route2 using POST to test duplicate ID handling
// Since the first route2 ended up at array index 0, and we are appending to the array, the index for the new element would be 2
tester.AssertPostResponseBody(
"http://localhost:2999/id/route2",
headers,
bytes.NewBuffer(route2),
400,
`{"error":"indexing config: duplicate ID 'route2' found at /config/apps/http/servers/s_server/routes/0 and /config/apps/http/servers/s_server/routes/2"}`+"\n")
// Use PATCH to modify an existing object successfully
tester.AssertPatchResponseBody(
"http://localhost:2999/id/route1",
headers,
bytes.NewBuffer([]byte(`{"@id":"route1","handle":[{"handler":"static_response","body":"route1"}],"match":[{"path":["/route_1/*"]}]}`)),
200,
"")
// Verify the PATCH updated the server state
tester.AssertGetResponse(
"http://localhost:9080/route_1/",
200,
"route1")
}
+2 -2
View File
@@ -51,7 +51,7 @@ func TestACMEServerWithDefaults(t *testing.T) {
Client: &acme.Client{
Directory: "https://acme.localhost:9443/acme/local/directory",
HTTPClient: tester.Client,
Logger: slog.New(zapslog.NewHandler(logger.Core())),
Logger: slog.New(zapslog.NewHandler(logger.Core(), zapslog.WithName("acmez"))),
},
ChallengeSolvers: map[string]acmez.Solver{
acme.ChallengeTypeHTTP01: &naiveHTTPSolver{logger: logger},
@@ -120,7 +120,7 @@ func TestACMEServerWithMismatchedChallenges(t *testing.T) {
Client: &acme.Client{
Directory: "https://acme.localhost:9443/acme/local/directory",
HTTPClient: tester.Client,
Logger: slog.New(zapslog.NewHandler(logger.Core())),
Logger: slog.New(zapslog.NewHandler(logger.Core(), zapslog.WithName("acmez"))),
},
ChallengeSolvers: map[string]acmez.Solver{
acme.ChallengeTypeHTTP01: &naiveHTTPSolver{logger: logger},
+23
View File
@@ -143,3 +143,26 @@ func TestAutoHTTPRedirectsInsertedBeforeUserDefinedCatchAllWithNoExplicitHTTPSit
tester.AssertGetResponse("http://foo.localhost:9080/", 200, "Foo")
tester.AssertGetResponse("http://baz.localhost:9080/", 200, "Foo")
}
func TestAutoHTTPSRedirectSortingExactMatchOverWildcard(t *testing.T) {
tester := caddytest.NewTester(t)
tester.InitServer(`
{
skip_install_trust
admin localhost:2999
http_port 9080
https_port 9443
local_certs
}
*.localhost:10443 {
respond "Wildcard"
}
dev.localhost {
respond "Exact"
}
`, "caddyfile")
tester.AssertRedirect("http://dev.localhost:9080/", "https://dev.localhost/", http.StatusPermanentRedirect)
tester.AssertRedirect("http://foo.localhost:9080/", "https://foo.localhost:10443/", http.StatusPermanentRedirect)
}
@@ -46,6 +46,18 @@ app.example.com {
}
]
},
{
"handle": [
{
"handler": "headers",
"request": {
"delete": [
"Remote-Email"
]
}
}
]
},
{
"handle": [
{
@@ -73,6 +85,18 @@ app.example.com {
}
]
},
{
"handle": [
{
"handler": "headers",
"request": {
"delete": [
"Remote-Groups"
]
}
}
]
},
{
"handle": [
{
@@ -100,6 +124,18 @@ app.example.com {
}
]
},
{
"handle": [
{
"handler": "headers",
"request": {
"delete": [
"Remote-Name"
]
}
}
]
},
{
"handle": [
{
@@ -127,6 +163,18 @@ app.example.com {
}
]
},
{
"handle": [
{
"handler": "headers",
"request": {
"delete": [
"Remote-User"
]
}
}
]
},
{
"handle": [
{
@@ -200,4 +248,4 @@ app.example.com {
}
}
}
}
}
@@ -0,0 +1,146 @@
:8080
forward_auth 127.0.0.1:9091 {
uri /
copy_headers X-User-Id X-User-Role
}
----------
{
"apps": {
"http": {
"servers": {
"srv0": {
"listen": [
":8080"
],
"routes": [
{
"handle": [
{
"handle_response": [
{
"match": {
"status_code": [
2
]
},
"routes": [
{
"handle": [
{
"handler": "vars"
}
]
},
{
"handle": [
{
"handler": "headers",
"request": {
"delete": [
"X-User-Id"
]
}
}
]
},
{
"handle": [
{
"handler": "headers",
"request": {
"set": {
"X-User-Id": [
"{http.reverse_proxy.header.X-User-Id}"
]
}
}
}
],
"match": [
{
"not": [
{
"vars": {
"{http.reverse_proxy.header.X-User-Id}": [
""
]
}
}
]
}
]
},
{
"handle": [
{
"handler": "headers",
"request": {
"delete": [
"X-User-Role"
]
}
}
]
},
{
"handle": [
{
"handler": "headers",
"request": {
"set": {
"X-User-Role": [
"{http.reverse_proxy.header.X-User-Role}"
]
}
}
}
],
"match": [
{
"not": [
{
"vars": {
"{http.reverse_proxy.header.X-User-Role}": [
""
]
}
}
]
}
]
}
]
}
],
"handler": "reverse_proxy",
"headers": {
"request": {
"set": {
"X-Forwarded-Method": [
"{http.request.method}"
],
"X-Forwarded-Uri": [
"{http.request.uri}"
]
}
}
},
"rewrite": {
"method": "GET",
"uri": "/"
},
"upstreams": [
{
"dial": "127.0.0.1:9091"
}
]
}
]
}
]
}
}
}
}
}
@@ -35,6 +35,18 @@ forward_auth localhost:9000 {
}
]
},
{
"handle": [
{
"handler": "headers",
"request": {
"delete": [
"1"
]
}
}
]
},
{
"handle": [
{
@@ -62,6 +74,18 @@ forward_auth localhost:9000 {
}
]
},
{
"handle": [
{
"handler": "headers",
"request": {
"delete": [
"B"
]
}
}
]
},
{
"handle": [
{
@@ -89,6 +113,18 @@ forward_auth localhost:9000 {
}
]
},
{
"handle": [
{
"handler": "headers",
"request": {
"delete": [
"3"
]
}
}
]
},
{
"handle": [
{
@@ -116,6 +152,18 @@ forward_auth localhost:9000 {
}
]
},
{
"handle": [
{
"handler": "headers",
"request": {
"delete": [
"D"
]
}
}
]
},
{
"handle": [
{
@@ -143,6 +191,18 @@ forward_auth localhost:9000 {
}
]
},
{
"handle": [
{
"handler": "headers",
"request": {
"delete": [
"5"
]
}
}
]
},
{
"handle": [
{
@@ -203,4 +263,4 @@ forward_auth localhost:9000 {
}
}
}
}
}
@@ -0,0 +1,77 @@
{
email test@example.com
dns mock
tls_resolvers 1.1.1.1 8.8.8.8
acme_dns
}
example.com {
}
----------
{
"apps": {
"http": {
"servers": {
"srv0": {
"listen": [
":443"
],
"routes": [
{
"match": [
{
"host": [
"example.com"
]
}
],
"terminal": true
}
]
}
}
},
"tls": {
"automation": {
"policies": [
{
"issuers": [
{
"challenges": {
"dns": {
"resolvers": [
"1.1.1.1",
"8.8.8.8"
]
}
},
"email": "test@example.com",
"module": "acme"
},
{
"ca": "https://acme.zerossl.com/v2/DV90",
"challenges": {
"dns": {
"resolvers": [
"1.1.1.1",
"8.8.8.8"
]
}
},
"email": "test@example.com",
"module": "acme"
}
]
}
]
},
"dns": {
"name": "mock"
},
"resolvers": [
"1.1.1.1",
"8.8.8.8"
]
}
}
}
@@ -0,0 +1,38 @@
{
tls_resolvers 1.1.1.1 8.8.8.8
}
example.com {
}
----------
{
"apps": {
"http": {
"servers": {
"srv0": {
"listen": [
":443"
],
"routes": [
{
"match": [
{
"host": [
"example.com"
]
}
],
"terminal": true
}
]
}
}
},
"tls": {
"resolvers": [
"1.1.1.1",
"8.8.8.8"
]
}
}
}
@@ -0,0 +1,72 @@
{
email test@example.com
dns mock
tls_resolvers 1.1.1.1 8.8.8.8
}
example.com {
tls {
dns mock
}
}
----------
{
"apps": {
"http": {
"servers": {
"srv0": {
"listen": [
":443"
],
"routes": [
{
"match": [
{
"host": [
"example.com"
]
}
],
"terminal": true
}
]
}
}
},
"tls": {
"automation": {
"policies": [
{
"subjects": [
"example.com"
],
"issuers": [
{
"challenges": {
"dns": {
"provider": {
"name": "mock"
},
"resolvers": [
"1.1.1.1",
"8.8.8.8"
]
}
},
"email": "test@example.com",
"module": "acme"
}
]
}
]
},
"dns": {
"name": "mock"
},
"resolvers": [
"1.1.1.1",
"8.8.8.8"
]
}
}
}
@@ -0,0 +1,98 @@
{
email test@example.com
dns mock
tls_resolvers 1.1.1.1 8.8.8.8
acme_dns
}
example.com {
tls {
resolvers 9.9.9.9
}
}
----------
{
"apps": {
"http": {
"servers": {
"srv0": {
"listen": [
":443"
],
"routes": [
{
"match": [
{
"host": [
"example.com"
]
}
],
"terminal": true
}
]
}
}
},
"tls": {
"automation": {
"policies": [
{
"subjects": [
"example.com"
],
"issuers": [
{
"challenges": {
"dns": {
"resolvers": [
"9.9.9.9"
]
}
},
"email": "test@example.com",
"module": "acme"
}
]
},
{
"issuers": [
{
"challenges": {
"dns": {
"resolvers": [
"1.1.1.1",
"8.8.8.8"
]
}
},
"email": "test@example.com",
"module": "acme"
},
{
"ca": "https://acme.zerossl.com/v2/DV90",
"challenges": {
"dns": {
"resolvers": [
"1.1.1.1",
"8.8.8.8"
]
}
},
"email": "test@example.com",
"module": "acme"
}
]
}
]
},
"dns": {
"name": "mock"
},
"resolvers": [
"1.1.1.1",
"8.8.8.8"
]
}
}
}
@@ -0,0 +1,112 @@
{
email test@example.com
dns mock
tls_resolvers 1.1.1.1 8.8.8.8
acme_dns
}
site1.example.com {
}
site2.example.com {
tls {
resolvers 9.9.9.9 8.8.4.4
}
}
----------
{
"apps": {
"http": {
"servers": {
"srv0": {
"listen": [
":443"
],
"routes": [
{
"match": [
{
"host": [
"site1.example.com"
]
}
],
"terminal": true
},
{
"match": [
{
"host": [
"site2.example.com"
]
}
],
"terminal": true
}
]
}
}
},
"tls": {
"automation": {
"policies": [
{
"subjects": [
"site2.example.com"
],
"issuers": [
{
"challenges": {
"dns": {
"resolvers": [
"9.9.9.9",
"8.8.4.4"
]
}
},
"email": "test@example.com",
"module": "acme"
}
]
},
{
"issuers": [
{
"challenges": {
"dns": {
"resolvers": [
"1.1.1.1",
"8.8.8.8"
]
}
},
"email": "test@example.com",
"module": "acme"
},
{
"ca": "https://acme.zerossl.com/v2/DV90",
"challenges": {
"dns": {
"resolvers": [
"1.1.1.1",
"8.8.8.8"
]
}
},
"email": "test@example.com",
"module": "acme"
}
]
}
]
},
"dns": {
"name": "mock"
},
"resolvers": [
"1.1.1.1",
"8.8.8.8"
]
}
}
}
@@ -0,0 +1,52 @@
import testdata/issue_7518_unused_block_panic_snippets.conf
example.com {
import snippet
}
----------
{
"apps": {
"http": {
"servers": {
"srv0": {
"listen": [
":443"
],
"routes": [
{
"match": [
{
"host": [
"example.com"
]
}
],
"handle": [
{
"handler": "subroute",
"routes": [
{
"handle": [
{
"handler": "headers",
"response": {
"set": {
"Reverse_proxy": [
"localhost:3000"
]
}
}
}
]
}
]
}
],
"terminal": true
}
]
}
}
}
}
}
@@ -1,27 +1,47 @@
:80
log {
log one {
output file /var/log/access.log {
mode 0644
dir_mode 0755
roll_size 1gb
roll_uncompressed
roll_compression none
roll_local_time
roll_keep 5
roll_keep_for 90d
}
}
log two {
output file /var/log/access-2.log {
mode 0777
dir_mode from_file
roll_size 1gib
roll_compression zstd
roll_interval 12h
roll_at 00:00 06:00 12:00,18:00
roll_minutes 10 40 45,46
roll_keep 10
roll_keep_for 90d
}
}
----------
{
"logging": {
"logs": {
"default": {
"exclude": [
"http.log.access.log0"
"http.log.access.one",
"http.log.access.two"
]
},
"log0": {
"one": {
"writer": {
"dir_mode": "0755",
"filename": "/var/log/access.log",
"mode": "0644",
"output": "file",
"roll_compression": "none",
"roll_gzip": false,
"roll_keep": 5,
"roll_keep_days": 90,
@@ -29,7 +49,35 @@ log {
"roll_size_mb": 954
},
"include": [
"http.log.access.log0"
"http.log.access.one"
]
},
"two": {
"writer": {
"dir_mode": "from_file",
"filename": "/var/log/access-2.log",
"mode": "0777",
"output": "file",
"roll_at": [
"00:00",
"06:00",
"12:00",
"18:00"
],
"roll_compression": "zstd",
"roll_interval": 43200000000000,
"roll_keep": 10,
"roll_keep_days": 90,
"roll_minutes": [
10,
40,
45,
46
],
"roll_size_mb": 1024
},
"include": [
"http.log.access.two"
]
}
}
@@ -42,7 +90,7 @@ log {
":80"
],
"logs": {
"default_logger_name": "log0"
"default_logger_name": "two"
}
}
}
+206
View File
@@ -0,0 +1,206 @@
// 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.
package integration
import (
"fmt"
"net/http"
"net/http/httptest"
"strings"
"sync"
"testing"
"github.com/caddyserver/caddy/v2/caddytest"
)
// TestForwardAuthCopyHeadersStripsClientHeaders is a regression test for the
// header injection vulnerability in forward_auth copy_headers.
//
// When the auth service returns 200 OK without one of the copy_headers headers,
// the MatchNot guard skips the Set operation. Before this fix, the original
// client-supplied header survived unchanged into the backend request, allowing
// privilege escalation with only a valid (non-privileged) bearer token. After
// the fix, an unconditional delete route runs first, so the backend always
// sees an absent header rather than the attacker-supplied value.
func TestForwardAuthCopyHeadersStripsClientHeaders(t *testing.T) {
// Mock auth service: accepts any Bearer token, returns 200 OK with NO
// identity headers. This is the stateless JWT validator pattern that
// triggers the vulnerability.
authSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if strings.HasPrefix(r.Header.Get("Authorization"), "Bearer ") {
w.WriteHeader(http.StatusOK)
return
}
w.WriteHeader(http.StatusUnauthorized)
}))
defer authSrv.Close()
// Mock backend: records the identity headers it receives. A real application
// would use X-User-Id / X-User-Role to make authorization decisions.
type received struct{ userID, userRole string }
var (
mu sync.Mutex
last received
)
backendSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
mu.Lock()
last = received{
userID: r.Header.Get("X-User-Id"),
userRole: r.Header.Get("X-User-Role"),
}
mu.Unlock()
w.WriteHeader(http.StatusOK)
fmt.Fprint(w, "ok")
}))
defer backendSrv.Close()
authAddr := strings.TrimPrefix(authSrv.URL, "http://")
backendAddr := strings.TrimPrefix(backendSrv.URL, "http://")
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 {
forward_auth %s {
uri /
copy_headers X-User-Id X-User-Role
}
reverse_proxy %s
}
`, authAddr, backendAddr), "caddyfile")
// Case 1: no token. Auth must still reject the request even when the client
// includes identity headers. This confirms the auth check is not bypassed.
req, _ := http.NewRequest(http.MethodGet, "http://localhost:9080/", nil)
req.Header.Set("X-User-Id", "injected")
req.Header.Set("X-User-Role", "injected")
resp := tester.AssertResponseCode(req, http.StatusUnauthorized)
resp.Body.Close()
// Case 2: valid token, no injected headers. The backend should see absent
// identity headers (the auth service never returns them).
req, _ = http.NewRequest(http.MethodGet, "http://localhost:9080/", nil)
req.Header.Set("Authorization", "Bearer token123")
tester.AssertResponse(req, http.StatusOK, "ok")
mu.Lock()
gotID, gotRole := last.userID, last.userRole
mu.Unlock()
if gotID != "" {
t.Errorf("baseline: X-User-Id should be absent, got %q", gotID)
}
if gotRole != "" {
t.Errorf("baseline: X-User-Role should be absent, got %q", gotRole)
}
// Case 3 (the security regression): valid token plus forged identity headers.
// The fix must strip those values so the backend never sees them.
req, _ = http.NewRequest(http.MethodGet, "http://localhost:9080/", nil)
req.Header.Set("Authorization", "Bearer token123")
req.Header.Set("X-User-Id", "admin") // forged
req.Header.Set("X-User-Role", "superadmin") // forged
tester.AssertResponse(req, http.StatusOK, "ok")
mu.Lock()
gotID, gotRole = last.userID, last.userRole
mu.Unlock()
if gotID != "" {
t.Errorf("injection: X-User-Id must be stripped, got %q", gotID)
}
if gotRole != "" {
t.Errorf("injection: X-User-Role must be stripped, got %q", gotRole)
}
}
// TestForwardAuthCopyHeadersAuthResponseWins verifies that when the auth
// service does include a copy_headers header in its response, that value
// is forwarded to the backend and takes precedence over any client-supplied
// value for the same header.
func TestForwardAuthCopyHeadersAuthResponseWins(t *testing.T) {
const wantUserID = "service-user-42"
const wantUserRole = "editor"
// Auth service: accepts bearer token and sets identity headers.
authSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if strings.HasPrefix(r.Header.Get("Authorization"), "Bearer ") {
w.Header().Set("X-User-Id", wantUserID)
w.Header().Set("X-User-Role", wantUserRole)
w.WriteHeader(http.StatusOK)
return
}
w.WriteHeader(http.StatusUnauthorized)
}))
defer authSrv.Close()
type received struct{ userID, userRole string }
var (
mu sync.Mutex
last received
)
backendSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
mu.Lock()
last = received{
userID: r.Header.Get("X-User-Id"),
userRole: r.Header.Get("X-User-Role"),
}
mu.Unlock()
w.WriteHeader(http.StatusOK)
fmt.Fprint(w, "ok")
}))
defer backendSrv.Close()
authAddr := strings.TrimPrefix(authSrv.URL, "http://")
backendAddr := strings.TrimPrefix(backendSrv.URL, "http://")
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 {
forward_auth %s {
uri /
copy_headers X-User-Id X-User-Role
}
reverse_proxy %s
}
`, authAddr, backendAddr), "caddyfile")
// The client sends forged headers; the auth service overrides them with
// its own values. The backend must receive the auth service values.
req, _ := http.NewRequest(http.MethodGet, "http://localhost:9080/", nil)
req.Header.Set("Authorization", "Bearer token123")
req.Header.Set("X-User-Id", "forged-id") // must be overwritten
req.Header.Set("X-User-Role", "forged-role") // must be overwritten
tester.AssertResponse(req, http.StatusOK, "ok")
mu.Lock()
gotID, gotRole := last.userID, last.userRole
mu.Unlock()
if gotID != wantUserID {
t.Errorf("X-User-Id: want %q, got %q", wantUserID, gotID)
}
if gotRole != wantUserRole {
t.Errorf("X-User-Role: want %q, got %q", wantUserRole, gotRole)
}
}
+595
View File
@@ -0,0 +1,595 @@
// 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.
// Integration tests for Caddy's PROXY protocol support, covering two distinct
// roles that Caddy can play:
//
// 1. As a PROXY protocol *sender* (reverse proxy outbound transport):
// Caddy receives an inbound request from a test client and the
// reverse_proxy handler forwards it to an upstream with a PROXY protocol
// header (v1 or v2) prepended to the connection. A lightweight backend
// built with go-proxyproto validates that the header was received and
// carries the correct client address.
//
// Transport versions tested:
// - "1.1" -> plain HTTP/1.1 to the upstream
// - "h2c" -> HTTP/2 cleartext (h2c) to the upstream (regression for #7529)
// - "2" -> HTTP/2 over TLS (h2) to the upstream
//
// For each transport version both PROXY protocol v1 and v2 are exercised.
//
// HTTP/3 (h3) is not included because it uses QUIC/UDP and therefore
// bypasses the TCP-level dialContext that injects PROXY protocol headers;
// there is no meaningful h3 + proxy protocol sender combination to test.
//
// 2. As a PROXY protocol *receiver* (server-side listener wrapper):
// A raw TCP client dials Caddy directly, injects a PROXY v2 header
// spoofing a source address, and sends a normal HTTP/1.1 request. The
// Caddy server is configured with the proxy_protocol listener wrapper and
// is expected to surface the spoofed address via the
// {http.request.remote.host} placeholder.
package integration
import (
"crypto/tls"
"encoding/json"
"fmt"
"net"
"net/http"
"net/http/httptest"
"slices"
"strings"
"sync"
"testing"
goproxy "github.com/pires/go-proxyproto"
"golang.org/x/net/http2"
"golang.org/x/net/http2/h2c"
"github.com/caddyserver/caddy/v2/caddytest"
)
// proxyProtoBackend is a minimal HTTP server that sits behind a
// go-proxyproto listener and records the source address that was
// delivered in the PROXY header for each request.
type proxyProtoBackend struct {
mu sync.Mutex
headerAddrs []string // host:port strings extracted from each PROXY header
ln net.Listener
srv *http.Server
}
// newProxyProtoBackend starts a TCP listener wrapped with go-proxyproto on a
// random local port and serves requests with a simple "OK" body. The PROXY
// header source addresses are accumulated in headerAddrs so tests can
// inspect them.
func newProxyProtoBackend(t *testing.T) *proxyProtoBackend {
t.Helper()
b := &proxyProtoBackend{}
rawLn, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("backend: listen: %v", err)
}
// Wrap with go-proxyproto so the PROXY header is stripped and parsed
// before the HTTP server sees the connection. We use REQUIRE so that a
// missing header returns an error instead of silently passing through.
pLn := &goproxy.Listener{
Listener: rawLn,
Policy: func(_ net.Addr) (goproxy.Policy, error) {
return goproxy.REQUIRE, nil
},
}
b.ln = pLn
// Wrap the handler with h2c support so the backend can speak HTTP/2
// cleartext (h2c) as well as plain HTTP/1.1. Without this, Caddy's
// reverse proxy would receive a 'frame too large' error when the
// upstream transport is configured to use h2c.
h2Server := &http2.Server{}
handlerFn := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// go-proxyproto has already updated the net.Conn's remote
// address to the value from the PROXY header; the HTTP server
// surfaces it in r.RemoteAddr.
b.mu.Lock()
b.headerAddrs = append(b.headerAddrs, r.RemoteAddr)
b.mu.Unlock()
w.WriteHeader(http.StatusOK)
_, _ = fmt.Fprint(w, "OK")
})
b.srv = &http.Server{
Handler: h2c.NewHandler(handlerFn, h2Server),
}
go b.srv.Serve(pLn) //nolint:errcheck
t.Cleanup(func() {
_ = b.srv.Close()
_ = rawLn.Close()
})
return b
}
// addr returns the listening address (host:port) of the backend.
func (b *proxyProtoBackend) addr() string {
return b.ln.Addr().String()
}
// recordedAddrs returns a snapshot of all PROXY-header source addresses seen
// so far.
func (b *proxyProtoBackend) recordedAddrs() []string {
b.mu.Lock()
defer b.mu.Unlock()
cp := make([]string, len(b.headerAddrs))
copy(cp, b.headerAddrs)
return cp
}
// tlsProxyProtoBackend is a TLS-enabled backend that sits behind a
// go-proxyproto listener. The PROXY header is stripped before the TLS
// handshake so the layer order on a connection is:
//
// raw TCP → go-proxyproto (strips PROXY header) → TLS handshake → HTTP/2
type tlsProxyProtoBackend struct {
mu sync.Mutex
headerAddrs []string
srv *httptest.Server
}
// newTLSProxyProtoBackend starts a TLS listener that first reads and strips
// PROXY protocol headers (go-proxyproto, REQUIRE policy) and then performs a
// TLS handshake. The backend speaks HTTP/2 over TLS (h2).
//
// The certificate is the standard self-signed certificate generated by
// httptest.Server; the Caddy transport must be configured with
// insecure_skip_verify: true to trust it.
func newTLSProxyProtoBackend(t *testing.T) *tlsProxyProtoBackend {
t.Helper()
b := &tlsProxyProtoBackend{}
handlerFn := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
b.mu.Lock()
b.headerAddrs = append(b.headerAddrs, r.RemoteAddr)
b.mu.Unlock()
w.WriteHeader(http.StatusOK)
_, _ = fmt.Fprint(w, "OK")
})
rawLn, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("tlsBackend: listen: %v", err)
}
// Wrap with go-proxyproto so the PROXY header is consumed before TLS.
pLn := &goproxy.Listener{
Listener: rawLn,
Policy: func(_ net.Addr) (goproxy.Policy, error) {
return goproxy.REQUIRE, nil
},
}
// httptest.NewUnstartedServer lets us replace the listener before
// calling StartTLS(), which wraps our proxyproto listener with
// tls.NewListener. This gives us the right layer order.
b.srv = httptest.NewUnstartedServer(handlerFn)
b.srv.Listener = pLn
// StartTLS enables HTTP/2 on the server automatically.
b.srv.StartTLS()
t.Cleanup(func() {
b.srv.Close()
})
return b
}
// addr returns the listening address (host:port) of the TLS backend.
func (b *tlsProxyProtoBackend) addr() string {
return b.srv.Listener.Addr().String()
}
// tlsConfig returns the *tls.Config used by the backend server.
// Tests can use it to verify cert details if needed.
func (b *tlsProxyProtoBackend) tlsConfig() *tls.Config {
return b.srv.TLS
}
// recordedAddrs returns a snapshot of all PROXY-header source addresses.
func (b *tlsProxyProtoBackend) recordedAddrs() []string {
b.mu.Lock()
defer b.mu.Unlock()
cp := make([]string, len(b.headerAddrs))
copy(cp, b.headerAddrs)
return cp
}
// proxyProtoTLSConfig builds a Caddy JSON configuration that proxies to a TLS
// upstream with PROXY protocol. The transport uses insecure_skip_verify so
// the self-signed certificate generated by httptest.Server is accepted.
func proxyProtoTLSConfig(listenPort int, backendAddr, ppVersion string, transportVersions []string) string {
versionsJSON, _ := json.Marshal(transportVersions)
return fmt.Sprintf(`{
"admin": {
"listen": "localhost:2999"
},
"apps": {
"pki": {
"certificate_authorities": {
"local": {
"install_trust": false
}
}
},
"http": {
"grace_period": 1,
"servers": {
"proxy": {
"listen": [":%d"],
"automatic_https": {
"disable": true
},
"routes": [
{
"handle": [
{
"handler": "reverse_proxy",
"upstreams": [{"dial": "%s"}],
"transport": {
"protocol": "http",
"proxy_protocol": "%s",
"versions": %s,
"tls": {
"insecure_skip_verify": true
}
}
}
]
}
]
}
}
}
}
}`, listenPort, backendAddr, ppVersion, string(versionsJSON))
}
// testTLSProxyProtocolMatrix is the shared implementation for TLS-based proxy
// protocol tests. It mirrors testProxyProtocolMatrix but uses a TLS backend.
func testTLSProxyProtocolMatrix(t *testing.T, ppVersion string, transportVersions []string, numRequests int) {
t.Helper()
backend := newTLSProxyProtoBackend(t)
listenPort := freePort(t)
tester := caddytest.NewTester(t)
tester.WithDefaultOverrides(caddytest.Config{
AdminPort: 2999,
})
cfg := proxyProtoTLSConfig(listenPort, backend.addr(), ppVersion, transportVersions)
tester.InitServer(cfg, "json")
proxyURL := fmt.Sprintf("http://127.0.0.1:%d/", listenPort)
for i := 0; i < numRequests; i++ {
resp, err := tester.Client.Get(proxyURL)
if err != nil {
t.Fatalf("request %d/%d: GET %s: %v", i+1, numRequests, proxyURL, err)
}
resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Errorf("request %d/%d: expected status 200, got %d", i+1, numRequests, resp.StatusCode)
}
}
addrs := backend.recordedAddrs()
if len(addrs) == 0 {
t.Fatalf("backend recorded no PROXY protocol addresses (expected at least 1)")
}
for i, addr := range addrs {
host, _, err := net.SplitHostPort(addr)
if err != nil {
t.Errorf("addr[%d] %q: SplitHostPort: %v", i, addr, err)
continue
}
if host != "127.0.0.1" {
t.Errorf("addr[%d]: expected source 127.0.0.1, got %q", i, host)
}
}
}
// proxyProtoConfig builds a Caddy JSON configuration that:
// - listens on listenPort for inbound HTTP requests
// - proxies them to backendAddr with PROXY protocol ppVersion ("v1"/"v2")
// - uses the given transport versions (e.g. ["1.1"] or ["h2c"])
func proxyProtoConfig(listenPort int, backendAddr, ppVersion string, transportVersions []string) string {
versionsJSON, _ := json.Marshal(transportVersions)
return fmt.Sprintf(`{
"admin": {
"listen": "localhost:2999"
},
"apps": {
"pki": {
"certificate_authorities": {
"local": {
"install_trust": false
}
}
},
"http": {
"grace_period": 1,
"servers": {
"proxy": {
"listen": [":%d"],
"automatic_https": {
"disable": true
},
"routes": [
{
"handle": [
{
"handler": "reverse_proxy",
"upstreams": [{"dial": "%s"}],
"transport": {
"protocol": "http",
"proxy_protocol": "%s",
"versions": %s
}
}
]
}
]
}
}
}
}
}`, listenPort, backendAddr, ppVersion, string(versionsJSON))
}
// freePort returns a free local TCP port by binding briefly and releasing it.
func freePort(t *testing.T) int {
t.Helper()
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("freePort: %v", err)
}
port := ln.Addr().(*net.TCPAddr).Port
_ = ln.Close()
return port
}
// TestProxyProtocolV1WithH1 verifies that PROXY protocol v1 headers are sent
// correctly when the transport uses HTTP/1.1 to the upstream.
func TestProxyProtocolV1WithH1(t *testing.T) {
testProxyProtocolMatrix(t, "v1", []string{"1.1"}, 1)
}
// TestProxyProtocolV2WithH1 verifies that PROXY protocol v2 headers are sent
// correctly when the transport uses HTTP/1.1 to the upstream.
func TestProxyProtocolV2WithH1(t *testing.T) {
testProxyProtocolMatrix(t, "v2", []string{"1.1"}, 1)
}
// TestProxyProtocolV1WithH2C verifies that PROXY protocol v1 headers are sent
// correctly when the transport uses h2c (HTTP/2 cleartext) to the upstream.
func TestProxyProtocolV1WithH2C(t *testing.T) {
testProxyProtocolMatrix(t, "v1", []string{"h2c"}, 1)
}
// TestProxyProtocolV2WithH2C verifies that PROXY protocol v2 headers are sent
// correctly when the transport uses h2c (HTTP/2 cleartext) to the upstream.
// This is the primary regression test for github.com/caddyserver/caddy/issues/7529:
// before the fix, the h2 transport opened a new TCP connection per request
// (because req.URL.Host was mangled differently for each request due to the
// varying client port), which caused file-descriptor exhaustion under load.
func TestProxyProtocolV2WithH2C(t *testing.T) {
testProxyProtocolMatrix(t, "v2", []string{"h2c"}, 1)
}
// TestProxyProtocolV2WithH2CMultipleRequests sends several sequential requests
// through the h2c + PROXY-protocol path and confirms that:
// 1. Every request receives a 200 response (no connection exhaustion).
// 2. The backend received at least one PROXY header (connection was reused).
//
// This is the core regression guard for issue #7529: without the fix, a new
// TCP connection was opened per request, quickly exhausting file descriptors.
func TestProxyProtocolV2WithH2CMultipleRequests(t *testing.T) {
testProxyProtocolMatrix(t, "v2", []string{"h2c"}, 5)
}
// TestProxyProtocolV1WithH2 verifies that PROXY protocol v1 headers are sent
// correctly when the transport uses HTTP/2 over TLS (h2) to the upstream.
func TestProxyProtocolV1WithH2(t *testing.T) {
testTLSProxyProtocolMatrix(t, "v1", []string{"2"}, 1)
}
// TestProxyProtocolV2WithH2 verifies that PROXY protocol v2 headers are sent
// correctly when the transport uses HTTP/2 over TLS (h2) to the upstream.
func TestProxyProtocolV2WithH2(t *testing.T) {
testTLSProxyProtocolMatrix(t, "v2", []string{"2"}, 1)
}
// TestProxyProtocolServerAndProxy is an end-to-end matrix test that exercises
// all combinations of PROXY protocol version x transport version.
func TestProxyProtocolServerAndProxy(t *testing.T) {
plainTests := []struct {
name string
ppVersion string
transportVersions []string
numRequests int
}{
{"h1-v1", "v1", []string{"1.1"}, 3},
{"h1-v2", "v2", []string{"1.1"}, 3},
{"h2c-v1", "v1", []string{"h2c"}, 3},
{"h2c-v2", "v2", []string{"h2c"}, 3},
}
for _, tc := range plainTests {
t.Run(tc.name, func(t *testing.T) {
testProxyProtocolMatrix(t, tc.ppVersion, tc.transportVersions, tc.numRequests)
})
}
tlsTests := []struct {
name string
ppVersion string
transportVersions []string
numRequests int
}{
{"h2-v1", "v1", []string{"2"}, 3},
{"h2-v2", "v2", []string{"2"}, 3},
}
for _, tc := range tlsTests {
t.Run(tc.name, func(t *testing.T) {
testTLSProxyProtocolMatrix(t, tc.ppVersion, tc.transportVersions, tc.numRequests)
})
}
}
// testProxyProtocolMatrix is the shared implementation for the proxy protocol
// tests. It:
// 1. Starts a go-proxyproto-wrapped backend.
// 2. Configures Caddy as a reverse proxy with the given PROXY protocol
// version and transport versions.
// 3. Sends numRequests GET requests through Caddy and asserts 200 OK each time.
// 4. Asserts the backend recorded at least one PROXY header whose source host
// is 127.0.0.1 (the loopback address used by the test client).
func testProxyProtocolMatrix(t *testing.T, ppVersion string, transportVersions []string, numRequests int) {
t.Helper()
backend := newProxyProtoBackend(t)
listenPort := freePort(t)
tester := caddytest.NewTester(t)
tester.WithDefaultOverrides(caddytest.Config{
AdminPort: 2999,
})
cfg := proxyProtoConfig(listenPort, backend.addr(), ppVersion, transportVersions)
tester.InitServer(cfg, "json")
// If the test is h2c-only (no "1.1" in versions), reconfigure the test
// client transport to use unencrypted HTTP/2 so we actually exercise the
// h2c code path through Caddy.
if slices.Contains(transportVersions, "h2c") && !slices.Contains(transportVersions, "1.1") {
tr, ok := tester.Client.Transport.(*http.Transport)
if ok {
tr.Protocols = new(http.Protocols)
tr.Protocols.SetHTTP1(false)
tr.Protocols.SetUnencryptedHTTP2(true)
}
}
proxyURL := fmt.Sprintf("http://127.0.0.1:%d/", listenPort)
for i := 0; i < numRequests; i++ {
resp, err := tester.Client.Get(proxyURL)
if err != nil {
t.Fatalf("request %d/%d: GET %s: %v", i+1, numRequests, proxyURL, err)
}
resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Errorf("request %d/%d: expected status 200, got %d", i+1, numRequests, resp.StatusCode)
}
}
// The backend must have seen at least one PROXY header. For h1, there is
// one per request; for h2c, requests share the same connection so only one
// header is written at connection establishment.
addrs := backend.recordedAddrs()
if len(addrs) == 0 {
t.Fatalf("backend recorded no PROXY protocol addresses (expected at least 1)")
}
// Every PROXY-decoded source address must be the loopback address since
// the test client always connects from 127.0.0.1.
for i, addr := range addrs {
host, _, err := net.SplitHostPort(addr)
if err != nil {
t.Errorf("addr[%d] %q: SplitHostPort: %v", i, addr, err)
continue
}
if host != "127.0.0.1" {
t.Errorf("addr[%d]: expected source 127.0.0.1, got %q", i, host)
}
}
}
// TestProxyProtocolListenerWrapper verifies that Caddy's
// caddy.listeners.proxy_protocol listener wrapper can successfully parse
// incoming PROXY protocol headers.
//
// The test dials Caddy's listening port directly, injects a raw PROXY v2
// header spoofing source address 10.0.0.1:1234, then sends a normal
// HTTP/1.1 GET request. The Caddy server is configured to echo back the
// remote address ({http.request.remote.host}). The test asserts that the
// echoed address is the spoofed 10.0.0.1.
func TestProxyProtocolListenerWrapper(t *testing.T) {
tester := caddytest.NewTester(t)
tester.InitServer(`{
skip_install_trust
admin localhost:2999
http_port 9080
https_port 9443
grace_period 1ns
servers :9080 {
listener_wrappers {
proxy_protocol {
timeout 5s
allow 127.0.0.0/8
}
}
}
}
http://localhost:9080 {
respond "{http.request.remote.host}"
}`, "caddyfile")
// Dial the Caddy listener directly and inject a PROXY v2 header that
// claims the connection originates from 10.0.0.1:1234.
conn, err := net.Dial("tcp", "127.0.0.1:9080")
if err != nil {
t.Fatalf("dial: %v", err)
}
defer conn.Close()
spoofedSrc := &net.TCPAddr{IP: net.ParseIP("10.0.0.1"), Port: 1234}
spoofedDst := &net.TCPAddr{IP: net.ParseIP("127.0.0.1"), Port: 9080}
hdr := goproxy.HeaderProxyFromAddrs(2, spoofedSrc, spoofedDst)
if _, err := hdr.WriteTo(conn); err != nil {
t.Fatalf("write proxy header: %v", err)
}
// Write a minimal HTTP/1.1 GET request.
_, err = fmt.Fprintf(conn,
"GET / HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n")
if err != nil {
t.Fatalf("write HTTP request: %v", err)
}
// Read the raw response and look for the spoofed address in the body.
buf := make([]byte, 4096)
n, _ := conn.Read(buf)
raw := string(buf[:n])
if !strings.Contains(raw, "10.0.0.1") {
t.Errorf("expected spoofed address 10.0.0.1 in response body; full response:\n%s", raw)
}
}
@@ -386,6 +386,68 @@ func TestReverseProxyHealthCheck(t *testing.T) {
tester.AssertGetResponse("http://localhost:9080/", 200, "Hello, World!")
}
// TestReverseProxyHealthCheckPortUsed verifies that health_port is actually
// used for active health checks and not the upstream's main port. This is a
// regression test for https://github.com/caddyserver/caddy/issues/7524.
func TestReverseProxyHealthCheckPortUsed(t *testing.T) {
// upstream server: serves proxied requests normally, but returns 503 for
// /health so that if health checks mistakenly hit this port the upstream
// gets marked unhealthy and the proxy returns 503.
upstreamSrv := &http.Server{
Handler: http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
if req.URL.Path == "/health" {
w.WriteHeader(http.StatusServiceUnavailable)
return
}
_, _ = w.Write([]byte("Hello, World!"))
}),
}
ln0, err := net.Listen("tcp", "127.0.0.1:2022")
if err != nil {
t.Fatalf("failed to listen on 127.0.0.1:2022: %v", err)
}
go upstreamSrv.Serve(ln0)
t.Cleanup(func() { upstreamSrv.Close(); ln0.Close() })
// separate health check server on the configured health_port: returns 200
// so the upstream is marked healthy only if health checks go to this port.
healthSrv := &http.Server{
Handler: http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
_, _ = w.Write([]byte("ok"))
}),
}
ln1, err := net.Listen("tcp", "127.0.0.1:2023")
if err != nil {
t.Fatalf("failed to listen on 127.0.0.1:2023: %v", err)
}
go healthSrv.Serve(ln1)
t.Cleanup(func() { healthSrv.Close(); ln1.Close() })
tester := caddytest.NewTester(t)
tester.InitServer(`
{
skip_install_trust
admin localhost:2999
http_port 9080
https_port 9443
grace_period 1ns
}
http://localhost:9080 {
reverse_proxy {
to localhost:2022
health_uri /health
health_port 2023
health_interval 10ms
health_timeout 100ms
health_passes 1
health_fails 1
}
}
`, "caddyfile")
tester.AssertGetResponse("http://localhost:9080/", 200, "Hello, World!")
}
func TestReverseProxyHealthCheckUnixSocket(t *testing.T) {
if runtime.GOOS == "windows" {
t.SkipNow()
@@ -0,0 +1,15 @@
# Used by import_block_snippet_non_replaced_block_from_separate_file.caddyfiletest
(snippet) {
header {
reverse_proxy localhost:3000
{block}
}
}
# This snippet being unused by the test Caddyfile is intentional.
# This is to test that a panic runtime error triggered by an out-of-range slice index access
# will not happen again, please see issue #7518 and pull request #7543 for more information
(unused_snippet) {
header SomeHeader SomeValue
}
+14 -4
View File
@@ -9,9 +9,14 @@ import (
)
var defaultFactory = newRootCommandFactory(func() *cobra.Command {
return &cobra.Command{
Use: "caddy",
Long: `Caddy is an extensible server platform written in Go.
bin := caddy.CustomBinaryName
if bin == "" {
bin = "caddy"
}
long := caddy.CustomLongDescription
if long == "" {
long = `Caddy is an extensible server platform written in Go.
At its core, Caddy merely manages configuration. Modules are plugged
in statically at compile-time to provide useful functionality. Caddy's
@@ -91,7 +96,12 @@ package installers: https://caddyserver.com/docs/install
Instructions for running Caddy in production are also available:
https://caddyserver.com/docs/running
`,
`
}
return &cobra.Command{
Use: bin,
Long: long,
Example: ` $ caddy run
$ caddy run --config caddy.json
$ caddy reload --config caddy.json
+1 -1
View File
@@ -372,7 +372,7 @@ func cmdReload(fl Flags) (int, error) {
return caddy.ExitCodeFailedStartup, fmt.Errorf("no config file to load")
}
adminAddr, err := DetermineAdminAPIAddress(addressFlag, config, configFlag, configAdapterFlag)
adminAddr, err := DetermineAdminAPIAddress(addressFlag, config, configFile, configAdapterFlag)
if err != nil {
return caddy.ExitCodeFailedStartup, fmt.Errorf("couldn't determine admin API address: %v", err)
}
+7 -1
View File
@@ -484,7 +484,13 @@ func setResourceLimits(logger *zap.Logger) func() {
// See https://pkg.go.dev/runtime/debug#SetMemoryLimit
_, _ = memlimit.SetGoMemLimitWithOpts(
memlimit.WithLogger(
slog.New(zapslog.NewHandler(logger.Core())),
slog.New(zapslog.NewHandler(
logger.Core(),
zapslog.WithName("memlimit"),
// the default enables traces at ERROR level, this disables
// them by setting it to a level higher than any other level
zapslog.AddStacktraceAt(slog.Level(127)),
)),
),
memlimit.WithProvider(
memlimit.ApplyFallback(
+18 -6
View File
@@ -63,10 +63,17 @@ type Context struct {
// modules which are loaded will be properly unloaded.
// See standard library context package's documentation.
func NewContext(ctx Context) (Context, context.CancelFunc) {
newCtx, cancelCause := NewContextWithCause(ctx)
return newCtx, func() { cancelCause(nil) }
}
// NewContextWithCause is like NewContext but returns a context.CancelCauseFunc.
// EXPERIMENTAL: This API is subject to change.
func NewContextWithCause(ctx Context) (Context, context.CancelCauseFunc) {
newCtx := Context{moduleInstances: make(map[string][]Module), cfg: ctx.cfg, metricsRegistry: prometheus.NewPedanticRegistry()}
c, cancel := context.WithCancel(ctx.Context)
wrappedCancel := func() {
cancel()
c, cancel := context.WithCancelCause(ctx.Context)
wrappedCancel := func(cause error) {
cancel(cause)
for _, f := range ctx.cleanupFuncs {
f()
@@ -608,6 +615,11 @@ func (ctx Context) Slogger() *slog.Logger {
core zapcore.Core
moduleID string
)
// the default enables traces at ERROR level, this disables
// them by setting it to a level higher than any other level
tracesOpt := zapslog.AddStacktraceAt(slog.Level(127))
if ctx.cfg == nil {
// often the case in tests; just use a dev logger
l, err := zap.NewDevelopment()
@@ -616,16 +628,16 @@ func (ctx Context) Slogger() *slog.Logger {
}
core = l.Core()
handler = zapslog.NewHandler(core)
handler = zapslog.NewHandler(core, tracesOpt)
} else {
mod := ctx.Module()
if mod == nil {
core = Log().Core()
handler = zapslog.NewHandler(core)
handler = zapslog.NewHandler(core, tracesOpt)
} else {
moduleID = string(mod.CaddyModule().ID)
core = ctx.cfg.Logging.Logger(mod).Core()
handler = zapslog.NewHandler(core, zapslog.WithName(moduleID))
handler = zapslog.NewHandler(core, zapslog.WithName(moduleID), tracesOpt)
}
}
+14 -14
View File
@@ -1,6 +1,6 @@
module github.com/caddyserver/caddy/v2
go 1.25
go 1.25.0
require (
github.com/BurntSushi/toml v1.6.0
@@ -9,7 +9,7 @@ require (
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.1
github.com/caddyserver/certmagic v0.25.2
github.com/caddyserver/zerossl v0.1.5
github.com/cloudflare/circl v1.6.3
github.com/dustin/go-humanize v1.0.1
@@ -18,10 +18,10 @@ require (
github.com/google/uuid v1.6.0
github.com/klauspost/compress v1.18.4
github.com/klauspost/cpuid/v2 v2.3.0
github.com/mholt/acmez/v3 v3.1.4
github.com/mholt/acmez/v3 v3.1.6
github.com/prometheus/client_golang v1.23.2
github.com/quic-go/quic-go v0.59.0
github.com/smallstep/certificates v0.30.0-rc2.0.20260211214201-20608299c29c
github.com/smallstep/certificates v0.30.0-rc3
github.com/smallstep/nosql v0.7.0
github.com/smallstep/truststore v0.13.0
github.com/spf13/cobra v1.10.2
@@ -35,13 +35,13 @@ require (
go.opentelemetry.io/contrib/propagators/autoprop v0.65.0
go.opentelemetry.io/otel v1.40.0
go.opentelemetry.io/otel/sdk v1.40.0
go.step.sm/crypto v0.76.0
go.step.sm/crypto v0.76.2
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.48.0
golang.org/x/crypto/x509roots/fallback v0.0.0-20250927194341-2beaa59a3c99
golang.org/x/net v0.50.0
golang.org/x/crypto/x509roots/fallback v0.0.0-20260213171211-a408498e5541
golang.org/x/net v0.51.0
golang.org/x/sync v0.19.0
golang.org/x/term v0.40.0
golang.org/x/time v0.14.0
@@ -108,18 +108,18 @@ require (
go.yaml.in/yaml/v2 v2.4.3 // 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.34.0 // indirect
google.golang.org/api v0.265.0 // indirect
golang.org/x/oauth2 v0.35.0 // indirect
google.golang.org/api v0.266.0 // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20260128011058-8636f8732409 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260128011058-8636f8732409 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260203192932-546029d2fa20 // indirect
google.golang.org/grpc/cmd/protoc-gen-go-grpc v1.5.1 // indirect
)
require (
filippo.io/edwards25519 v1.1.0 // indirect
filippo.io/edwards25519 v1.2.0 // indirect
github.com/AndreasBriese/bbloom v0.0.0-20190825152654-46b345b51c96 // indirect
github.com/Masterminds/goutils v1.1.1 // indirect
github.com/Masterminds/semver/v3 v3.3.1 // indirect
github.com/Masterminds/semver/v3 v3.4.0 // indirect
github.com/beorn7/perks v1.0.1 // indirect
github.com/cespare/xxhash v1.1.0 // indirect
github.com/cespare/xxhash/v2 v2.3.0
@@ -145,7 +145,7 @@ require (
github.com/mattn/go-colorable v0.1.14 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d // indirect
github.com/miekg/dns v1.1.70 // indirect
github.com/miekg/dns v1.1.72 // indirect
github.com/mitchellh/copystructure v1.2.0 // indirect
github.com/mitchellh/go-ps v1.0.0 // indirect
github.com/mitchellh/reflectwalk v1.0.2 // indirect
@@ -172,7 +172,7 @@ require (
golang.org/x/sys v0.41.0
golang.org/x/text v0.34.0
golang.org/x/tools v0.42.0 // indirect
google.golang.org/grpc v1.78.0 // indirect
google.golang.org/grpc v1.79.1 // indirect
google.golang.org/protobuf v1.36.11 // indirect
howett.net/plist v1.0.0 // indirect
)
+32 -26
View File
@@ -14,12 +14,14 @@ cloud.google.com/go/kms v1.25.0 h1:gVqvGGUmz0nYCmtoxWmdc1wli2L1apgP8U4fghPGSbQ=
cloud.google.com/go/kms v1.25.0/go.mod h1:XIdHkzfj0bUO3E+LvwPg+oc7s58/Ns8Nd8Sdtljihbk=
cloud.google.com/go/longrunning v0.8.0 h1:LiKK77J3bx5gDLi4SMViHixjD2ohlkwBi+mKA7EhfW8=
cloud.google.com/go/longrunning v0.8.0/go.mod h1:UmErU2Onzi+fKDg2gR7dusz11Pe26aknR4kHmJJqIfk=
code.pfad.fr/check v1.1.0 h1:GWvjdzhSEgHvEHe2uJujDcpmZoySKuHQNrZMfzfO0bE=
code.pfad.fr/check v1.1.0/go.mod h1:NiUH13DtYsb7xp5wll0U4SXx7KhXQVCtRgdC96IPfoM=
dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8=
dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA=
filippo.io/bigmod v0.1.0 h1:UNzDk7y9ADKST+axd9skUpBQeW7fG2KrTZyOE4uGQy8=
filippo.io/bigmod v0.1.0/go.mod h1:OjOXDNlClLblvXdwgFFOQFJEocLhhtai8vGLy0JCZlI=
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
filippo.io/edwards25519 v1.2.0 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo=
filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc=
github.com/AndreasBriese/bbloom v0.0.0-20190825152654-46b345b51c96 h1:cTp8I5+VIoKjsnZuH8vjyaysT/ses3EvZeaV/1UkF2M=
github.com/AndreasBriese/bbloom v0.0.0-20190825152654-46b345b51c96/go.mod h1:bOvUY6CB00SOBii9/FifXqc0awNKxLFCL/+pkDPuyl8=
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
@@ -32,8 +34,8 @@ github.com/KimMachineGun/automemlimit v0.7.5 h1:RkbaC0MwhjL1ZuBKunGDjE/ggwAX43Dw
github.com/KimMachineGun/automemlimit v0.7.5/go.mod h1:QZxpHaGOQoYvFhv/r4u3U0JTC2ZcOwbSr11UZF46UBM=
github.com/Masterminds/goutils v1.1.1 h1:5nUrii3FMTL5diU80unEVvNevw1nH4+ZV4DSLVJLSYI=
github.com/Masterminds/goutils v1.1.1/go.mod h1:8cTjp+g8YejhMuvIA5y2vz3BpJxksy863GQaJW2MFNU=
github.com/Masterminds/semver/v3 v3.3.1 h1:QtNSWtVZ3nBfk8mAOu/B6v7FMJ+NHTIgUPi7rj+4nv4=
github.com/Masterminds/semver/v3 v3.3.1/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM=
github.com/Masterminds/semver/v3 v3.4.0 h1:Zog+i5UMtVoCU8oKka5P7i9q9HgrJeGzI9SA1Xbatp0=
github.com/Masterminds/semver/v3 v3.4.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM=
github.com/Masterminds/sprig/v3 v3.3.0 h1:mQh0Yrg1XPo6vjYXgtf5OtijNAKJRNcTdOOGZe3tPhs=
github.com/Masterminds/sprig/v3 v3.3.0/go.mod h1:Zy1iXRYNqNLUolqCpL4uhk6SHUMAOSCzdgBfDb35Lz0=
github.com/OneOfOne/xxhash v1.2.2 h1:KMrpdQIwFcEqXDklaen+P1axHaj9BSKzvpUUfnHldSE=
@@ -83,8 +85,8 @@ github.com/aws/smithy-go v1.24.0 h1:LpilSUItNPFr1eY85RYgTIg5eIEPtvFbskaFcmmIUnk=
github.com/aws/smithy-go v1.24.0/go.mod h1:LEj2LM3rBRQJxPZTB4KuzZkaZYnZPnvgIhb4pu07mx0=
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.1 h1:4sIKKbOt5pg6+sL7tEwymE1x2bj6CHr80da1CRRIPbY=
github.com/caddyserver/certmagic v0.25.1/go.mod h1:VhyvndxtVton/Fo/wKhRoC46Rbw1fmjvQ3GjHYSQTEY=
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/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=
@@ -223,6 +225,10 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
github.com/letsencrypt/challtestsrv v1.4.2 h1:0ON3ldMhZyWlfVNYYpFuWRTmZNnyfiL9Hh5YzC3JVwU=
github.com/letsencrypt/challtestsrv v1.4.2/go.mod h1:GhqMqcSoeGpYd5zX5TgwA6er/1MbWzx/o7yuuVya+Wk=
github.com/letsencrypt/pebble/v2 v2.10.0 h1:Wq6gYXlsY6ubqI3hhxsTzdyotvfdjFBxuwYqCLCnj/U=
github.com/letsencrypt/pebble/v2 v2.10.0/go.mod h1:Sk8cmUIPcIdv2nINo+9PB4L+ZBhzY+F9A1a/h/xmWiQ=
github.com/libdns/libdns v1.1.1 h1:wPrHrXILoSHKWJKGd0EiAVmiJbFShguILTg9leS/P/U=
github.com/libdns/libdns v1.1.1/go.mod h1:4Bj9+5CQiNMVGf87wjX4CY3HQJypUHRuLvlsfsZqLWQ=
github.com/magiconair/properties v1.8.0/go.mod h1:PppfXfuXeibc/6YijjN8zIbojt8czPbwD3XqdrwzmxQ=
@@ -234,10 +240,10 @@ github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWE
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d h1:5PJl274Y63IEHC+7izoQE9x6ikvDFZS2mDVS3drnohI=
github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d/go.mod h1:01TrycV0kFyexm33Z7vhZRXopbI8J3TDReVlkTgMUxE=
github.com/mholt/acmez/v3 v3.1.4 h1:DyzZe/RnAzT3rpZj/2Ii5xZpiEvvYk3cQEN/RmqxwFQ=
github.com/mholt/acmez/v3 v3.1.4/go.mod h1:L1wOU06KKvq7tswuMDwKdcHeKpFFgkppZy/y0DFxagQ=
github.com/miekg/dns v1.1.70 h1:DZ4u2AV35VJxdD9Fo9fIWm119BsQL5cZU1cQ9s0LkqA=
github.com/miekg/dns v1.1.70/go.mod h1:+EuEPhdHOsfk6Wk5TT2CzssZdqkmFhf8r+aVyDEToIs=
github.com/mholt/acmez/v3 v3.1.6 h1:eGVQNObP0pBN4sxqrXeg7MYqTOWyoiYpQqITVWlrevk=
github.com/mholt/acmez/v3 v3.1.6/go.mod h1:5nTPosTGosLxF3+LU4ygbgMRFDhbAVpqMI4+a4aHLBY=
github.com/miekg/dns v1.1.72 h1:vhmr+TF2A3tuoGNkLDFK9zi36F2LS+hKTRW0Uf8kbzI=
github.com/miekg/dns v1.1.72/go.mod h1:+EuEPhdHOsfk6Wk5TT2CzssZdqkmFhf8r+aVyDEToIs=
github.com/mitchellh/copystructure v1.2.0 h1:vpKXTN4ewci03Vljg/q9QvCGUDttBOGBIa15WveJJGw=
github.com/mitchellh/copystructure v1.2.0/go.mod h1:qLl+cE2AmVv+CoeAwDPye/v+N2HKCj9FbZEVFJRxO9s=
github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0=
@@ -295,8 +301,8 @@ github.com/slackhq/nebula v1.10.3 h1:EstYj8ODEcv6T0R9X5BVq1zgWZnyU5gtPzk99QF1PMU
github.com/slackhq/nebula v1.10.3/go.mod h1:IL5TUQm4x9IFx2kCKPYm1gP47pwd5b8QGnnBH2RHnvs=
github.com/smallstep/assert v0.0.0-20200723003110-82e2b9b3b262 h1:unQFBIznI+VYD1/1fApl1A+9VcBk+9dcqGfnePY87LY=
github.com/smallstep/assert v0.0.0-20200723003110-82e2b9b3b262/go.mod h1:MyOHs9Po2fbM1LHej6sBUT8ozbxmMOFG+E+rx/GSGuc=
github.com/smallstep/certificates v0.30.0-rc2.0.20260211214201-20608299c29c h1:XQpX0IPYUAoJ661YlgfOJmY48ZOhIbglw4E2gw9mcyc=
github.com/smallstep/certificates v0.30.0-rc2.0.20260211214201-20608299c29c/go.mod h1:75NRLmYJq6ZcCb8ApJc+W1eL4oMYwjeufMJDHpv4rx4=
github.com/smallstep/certificates v0.30.0-rc3 h1:Lx/NNJ4n+L3Pyx5NtVRGXeqviPPXTFFGLRiC1fCwU50=
github.com/smallstep/certificates v0.30.0-rc3/go.mod h1:e5/ylYYpvnjCVZz6RpyOkpTe73EGPYoL+8TZZ5EtLjI=
github.com/smallstep/cli-utils v0.12.2 h1:lGzM9PJrH/qawbzMC/s2SvgLdJPKDWKwKzx9doCVO+k=
github.com/smallstep/cli-utils v0.12.2/go.mod h1:uCPqefO29goHLGqFnwk0i8W7XJu18X3WHQFRtOm/00Y=
github.com/smallstep/go-attestation v0.4.4-0.20241119153605-2306d5b464ca h1:VX8L0r8vybH0bPeaIxh4NQzafKQiqvlOn8pmOXbFLO4=
@@ -425,8 +431,8 @@ go.opentelemetry.io/otel/trace v1.40.0 h1:WA4etStDttCSYuhwvEa8OP8I5EWu24lkOzp+ZY
go.opentelemetry.io/otel/trace v1.40.0/go.mod h1:zeAhriXecNGP/s2SEG3+Y8X9ujcJOTqQ5RgdEJcawiA=
go.opentelemetry.io/proto/otlp v1.9.0 h1:l706jCMITVouPOqEnii2fIAuO3IVGBRPV5ICjceRb/A=
go.opentelemetry.io/proto/otlp v1.9.0/go.mod h1:xE+Cx5E/eEHw+ISFkwPLwCZefwVjY+pqKg1qcK03+/4=
go.step.sm/crypto v0.76.0 h1:K23BSaeoiY7Y5dvvijTeYC9EduDBetNwQYMBwMhi1aA=
go.step.sm/crypto v0.76.0/go.mod h1:PXYJdKkK8s+GHLwLguFaLxHNAFsFL3tL1vSBrYfey5k=
go.step.sm/crypto v0.76.2 h1:JJ/yMcs/rmcCAwlo+afrHjq74XBFRTJw5B2y4Q4Z4c4=
go.step.sm/crypto v0.76.2/go.mod h1:m6KlB/HzIuGFep0UWI5e0SYi38UxpoKeCg6qUaHV6/Q=
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=
@@ -452,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.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
golang.org/x/crypto/x509roots/fallback v0.0.0-20250927194341-2beaa59a3c99 h1:CH0o4/bZX6KIUCjjgjmtNtfM/kXSkTYlzTOB9vZF45g=
golang.org/x/crypto/x509roots/fallback v0.0.0-20250927194341-2beaa59a3c99/go.mod h1:MEIPiCnxvQEjA4astfaKItNwEVZA5Ki+3+nyGbJ5N18=
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=
@@ -471,10 +477,10 @@ golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60=
golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM=
golang.org/x/oauth2 v0.34.0 h1:hqK/t4AKgbqWkdkcAeI8XLmbK+4m4G5YeQRrmiotGlw=
golang.org/x/oauth2 v0.34.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo=
golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y=
golang.org/x/oauth2 v0.35.0 h1:Mv2mzuHuZuY2+bkyWXIHMfhNdJAdwW3FuWeCPYN5GVQ=
golang.org/x/oauth2 v0.35.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
@@ -537,16 +543,16 @@ golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
google.golang.org/api v0.265.0 h1:FZvfUdI8nfmuNrE34aOWFPmLC+qRBEiNm3JdivTvAAU=
google.golang.org/api v0.265.0/go.mod h1:uAvfEl3SLUj/7n6k+lJutcswVojHPp2Sp08jWCu8hLY=
google.golang.org/api v0.266.0 h1:hco+oNCf9y7DmLeAtHJi/uBAY7n/7XC9mZPxu1ROiyk=
google.golang.org/api v0.266.0/go.mod h1:Jzc0+ZfLnyvXma3UtaTl023TdhZu6OMBP9tJ+0EmFD0=
google.golang.org/genproto v0.0.0-20260128011058-8636f8732409 h1:VQZ/yAbAtjkHgH80teYd2em3xtIkkHd7ZhqfH2N9CsM=
google.golang.org/genproto v0.0.0-20260128011058-8636f8732409/go.mod h1:rxKD3IEILWEu3P44seeNOAwZN4SaoKaQ/2eTg4mM6EM=
google.golang.org/genproto/googleapis/api v0.0.0-20260128011058-8636f8732409 h1:merA0rdPeUV3YIIfHHcH4qBkiQAc1nfCKSI7lB4cV2M=
google.golang.org/genproto/googleapis/api v0.0.0-20260128011058-8636f8732409/go.mod h1:fl8J1IvUjCilwZzQowmw2b7HQB2eAuYBabMXzWurF+I=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260128011058-8636f8732409 h1:H86B94AW+VfJWDqFeEbBPhEtHzJwJfTbgE2lZa54ZAQ=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260128011058-8636f8732409/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ=
google.golang.org/grpc v1.78.0 h1:K1XZG/yGDJnzMdd/uZHAkVqJE+xIDOcmdSFZkBUicNc=
google.golang.org/grpc v1.78.0/go.mod h1:I47qjTo4OKbMkjA/aOOwxDIiPSBofUtQUI5EfpWvW7U=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260203192932-546029d2fa20 h1:Jr5R2J6F6qWyzINc+4AM8t5pfUz6beZpHp678GNrMbE=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260203192932-546029d2fa20/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ=
google.golang.org/grpc v1.79.1 h1:zGhSi45ODB9/p3VAawt9a+O/MULLl9dpizzNNpq7flY=
google.golang.org/grpc v1.79.1/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ=
google.golang.org/grpc/cmd/protoc-gen-go-grpc v1.5.1 h1:F29+wU6Ee6qgu9TddPgooOdaqsxTMunOoj8KA5yuS5A=
google.golang.org/grpc/cmd/protoc-gen-go-grpc v1.5.1/go.mod h1:5KF+wpkbTSbGcR9zteSqZV6fqFOWBl4Yde8En8MryZA=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
+11 -91
View File
@@ -38,10 +38,6 @@ import (
"github.com/caddyserver/caddy/v2/internal"
)
// listenFdsStart is the first file descriptor number for systemd socket activation.
// File descriptors 0, 1, 2 are reserved for stdin, stdout, stderr.
const listenFdsStart = 3
// NetworkAddress represents one or more network addresses.
// It contains the individual components for a parsed network
// address of the form accepted by ParseNetworkAddress().
@@ -233,7 +229,7 @@ func (na NetworkAddress) JoinHostPort(offset uint) string {
func (na NetworkAddress) Expand() []NetworkAddress {
size := na.PortRangeSize()
addrs := make([]NetworkAddress, size)
for portOffset := uint(0); portOffset < size; portOffset++ {
for portOffset := range size {
addrs[portOffset] = na.At(portOffset)
}
return addrs
@@ -309,64 +305,6 @@ func IsFdNetwork(netw string) bool {
return strings.HasPrefix(netw, "fd")
}
// getFdByName returns the file descriptor number for the given
// socket name from systemd's LISTEN_FDNAMES environment variable.
// Socket names are provided by systemd via socket activation.
//
// The name can optionally include an index to handle multiple sockets
// with the same name: "web:0" for first, "web:1" for second, etc.
// If no index is specified, defaults to index 0 (first occurrence).
func getFdByName(nameWithIndex string) (int, error) {
if nameWithIndex == "" {
return 0, fmt.Errorf("socket name cannot be empty")
}
fdNamesStr := os.Getenv("LISTEN_FDNAMES")
if fdNamesStr == "" {
return 0, fmt.Errorf("LISTEN_FDNAMES environment variable not set")
}
// Parse name and optional index
parts := strings.Split(nameWithIndex, ":")
if len(parts) > 2 {
return 0, fmt.Errorf("invalid socket name format '%s': too many colons", nameWithIndex)
}
name := parts[0]
targetIndex := 0
if len(parts) > 1 {
var err error
targetIndex, err = strconv.Atoi(parts[1])
if err != nil {
return 0, fmt.Errorf("invalid socket index '%s': %v", parts[1], err)
}
if targetIndex < 0 {
return 0, fmt.Errorf("socket index cannot be negative: %d", targetIndex)
}
}
// Parse the socket names
names := strings.Split(fdNamesStr, ":")
// Find the Nth occurrence of the requested name
matchCount := 0
for i, fdName := range names {
if fdName == name {
if matchCount == targetIndex {
return listenFdsStart + i, nil
}
matchCount++
}
}
if matchCount == 0 {
return 0, fmt.Errorf("socket name '%s' not found in LISTEN_FDNAMES", name)
}
return 0, fmt.Errorf("socket name '%s' found %d times, but index %d requested", name, matchCount, targetIndex)
}
// ParseNetworkAddress parses addr into its individual
// components. The input string is expected to be of
// the form "network/host:port-range" where any part is
@@ -398,27 +336,9 @@ func ParseNetworkAddressWithDefaults(addr, defaultNetwork string, defaultPort ui
}, err
}
if IsFdNetwork(network) {
fdAddr := host
// Handle named socket activation (fdname/name, fdgramname/name)
if strings.HasPrefix(network, "fdname") || strings.HasPrefix(network, "fdgramname") {
fdNum, err := getFdByName(host)
if err != nil {
return NetworkAddress{}, fmt.Errorf("named socket activation: %v", err)
}
fdAddr = strconv.Itoa(fdNum)
// Normalize network to standard fd/fdgram
if strings.HasPrefix(network, "fdname") {
network = "fd"
} else {
network = "fdgram"
}
}
return NetworkAddress{
Network: network,
Host: fdAddr,
Host: host,
}, nil
}
var start, end uint64
@@ -592,7 +512,7 @@ func ListenerUsage(network, addr string) int {
// contextAndCancelFunc groups context and its cancelFunc
type contextAndCancelFunc struct {
context.Context
context.CancelFunc
context.CancelCauseFunc
}
// sharedQUICState manages GetConfigForClient
@@ -622,17 +542,17 @@ func (sqs *sharedQUICState) getConfigForClient(ch *tls.ClientHelloInfo) (*tls.Co
// 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.CancelFunc) {
func (sqs *sharedQUICState) addState(tlsConfig *tls.Config) (context.Context, context.CancelCauseFunc) {
sqs.rmu.Lock()
defer sqs.rmu.Unlock()
if cacc, ok := sqs.tlsConfs[tlsConfig]; ok {
return cacc.Context, cacc.CancelFunc
return cacc.Context, cacc.CancelCauseFunc
}
ctx, cancel := context.WithCancel(context.Background())
wrappedCancel := func() {
cancel()
ctx, cancel := context.WithCancelCause(context.Background())
wrappedCancel := func(cause error) {
cancel(cause)
sqs.rmu.Lock()
defer sqs.rmu.Unlock()
@@ -688,13 +608,13 @@ func fakeClosedErr(l interface{ Addr() net.Addr }) error {
// indicating that it is pretending to be closed so that the
// server using it can terminate, while the underlying
// socket is actually left open.
var errFakeClosed = fmt.Errorf("listener 'closed' 😉")
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
context context.Context
contextCancel context.CancelFunc
contextCancel context.CancelCauseFunc
}
// Currently Accept ignores the passed context, however a situation where
@@ -717,7 +637,7 @@ func (fcql *fakeCloseQuicListener) Accept(_ context.Context) (*quic.Conn, error)
func (fcql *fakeCloseQuicListener) Close() error {
if atomic.CompareAndSwapInt32(&fcql.closed, 0, 1) {
fcql.contextCancel()
fcql.contextCancel(errFakeClosed)
} else if atomic.CompareAndSwapInt32(&fcql.closed, 1, 2) {
_, _ = listenerPool.Delete(fcql.sharedQuicListener.key)
}
-284
View File
@@ -15,7 +15,6 @@
package caddy
import (
"os"
"reflect"
"testing"
@@ -653,286 +652,3 @@ func TestSplitUnixSocketPermissionsBits(t *testing.T) {
}
}
}
// TestGetFdByName tests the getFdByName function for systemd socket activation.
func TestGetFdByName(t *testing.T) {
// Save original environment
originalFdNames := os.Getenv("LISTEN_FDNAMES")
// Restore environment after test
defer func() {
if originalFdNames != "" {
os.Setenv("LISTEN_FDNAMES", originalFdNames)
} else {
os.Unsetenv("LISTEN_FDNAMES")
}
}()
tests := []struct {
name string
fdNames string
socketName string
expectedFd int
expectError bool
}{
{
name: "simple http socket",
fdNames: "http",
socketName: "http",
expectedFd: 3,
},
{
name: "multiple different sockets - first",
fdNames: "http:https:dns",
socketName: "http",
expectedFd: 3,
},
{
name: "multiple different sockets - second",
fdNames: "http:https:dns",
socketName: "https",
expectedFd: 4,
},
{
name: "multiple different sockets - third",
fdNames: "http:https:dns",
socketName: "dns",
expectedFd: 5,
},
{
name: "duplicate names - first occurrence (no index)",
fdNames: "web:web:api",
socketName: "web",
expectedFd: 3,
},
{
name: "duplicate names - first occurrence (explicit index 0)",
fdNames: "web:web:api",
socketName: "web:0",
expectedFd: 3,
},
{
name: "duplicate names - second occurrence (index 1)",
fdNames: "web:web:api",
socketName: "web:1",
expectedFd: 4,
},
{
name: "complex duplicates - first api",
fdNames: "web:api:web:api:dns",
socketName: "api:0",
expectedFd: 4,
},
{
name: "complex duplicates - second api",
fdNames: "web:api:web:api:dns",
socketName: "api:1",
expectedFd: 6,
},
{
name: "complex duplicates - first web",
fdNames: "web:api:web:api:dns",
socketName: "web:0",
expectedFd: 3,
},
{
name: "complex duplicates - second web",
fdNames: "web:api:web:api:dns",
socketName: "web:1",
expectedFd: 5,
},
{
name: "socket not found",
fdNames: "http:https",
socketName: "missing",
expectError: true,
},
{
name: "empty socket name",
fdNames: "http",
socketName: "",
expectError: true,
},
{
name: "missing LISTEN_FDNAMES",
fdNames: "",
socketName: "http",
expectError: true,
},
{
name: "index out of range",
fdNames: "web:web",
socketName: "web:2",
expectError: true,
},
{
name: "negative index",
fdNames: "web",
socketName: "web:-1",
expectError: true,
},
{
name: "invalid index format",
fdNames: "web",
socketName: "web:abc",
expectError: true,
},
{
name: "too many colons",
fdNames: "web",
socketName: "web:0:extra",
expectError: true,
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
// Set up environment
if tc.fdNames != "" {
os.Setenv("LISTEN_FDNAMES", tc.fdNames)
} else {
os.Unsetenv("LISTEN_FDNAMES")
}
// Test the function
fd, err := getFdByName(tc.socketName)
if tc.expectError {
if err == nil {
t.Errorf("Expected error but got none")
}
} else {
if err != nil {
t.Errorf("Expected no error but got: %v", err)
}
if fd != tc.expectedFd {
t.Errorf("Expected FD %d but got %d", tc.expectedFd, fd)
}
}
})
}
}
// TestParseNetworkAddressFdName tests parsing of fdname and fdgramname addresses.
func TestParseNetworkAddressFdName(t *testing.T) {
// Save and restore environment
originalFdNames := os.Getenv("LISTEN_FDNAMES")
defer func() {
if originalFdNames != "" {
os.Setenv("LISTEN_FDNAMES", originalFdNames)
} else {
os.Unsetenv("LISTEN_FDNAMES")
}
}()
// Set up test environment
os.Setenv("LISTEN_FDNAMES", "http:https:dns")
tests := []struct {
input string
expectAddr NetworkAddress
expectErr bool
}{
{
input: "fdname/http",
expectAddr: NetworkAddress{
Network: "fd",
Host: "3",
},
},
{
input: "fdname/https",
expectAddr: NetworkAddress{
Network: "fd",
Host: "4",
},
},
{
input: "fdname/dns",
expectAddr: NetworkAddress{
Network: "fd",
Host: "5",
},
},
{
input: "fdname/http:0",
expectAddr: NetworkAddress{
Network: "fd",
Host: "3",
},
},
{
input: "fdname/https:0",
expectAddr: NetworkAddress{
Network: "fd",
Host: "4",
},
},
{
input: "fdgramname/http",
expectAddr: NetworkAddress{
Network: "fdgram",
Host: "3",
},
},
{
input: "fdgramname/https",
expectAddr: NetworkAddress{
Network: "fdgram",
Host: "4",
},
},
{
input: "fdgramname/http:0",
expectAddr: NetworkAddress{
Network: "fdgram",
Host: "3",
},
},
{
input: "fdname/nonexistent",
expectErr: true,
},
{
input: "fdgramname/nonexistent",
expectErr: true,
},
{
input: "fdname/http:99",
expectErr: true,
},
{
input: "fdname/invalid:abc",
expectErr: true,
},
// Test that old fd/N syntax still works
{
input: "fd/7",
expectAddr: NetworkAddress{
Network: "fd",
Host: "7",
},
},
{
input: "fdgram/8",
expectAddr: NetworkAddress{
Network: "fdgram",
Host: "8",
},
},
}
for i, tc := range tests {
actualAddr, err := ParseNetworkAddress(tc.input)
if tc.expectErr && err == nil {
t.Errorf("Test %d (%s): Expected error but got none", i, tc.input)
}
if !tc.expectErr && err != nil {
t.Errorf("Test %d (%s): Expected no error but got: %v", i, tc.input, err)
}
if !tc.expectErr && !reflect.DeepEqual(tc.expectAddr, actualAddr) {
t.Errorf("Test %d (%s): Expected %+v but got %+v", i, tc.input, tc.expectAddr, actualAddr)
}
}
}
+10 -2
View File
@@ -18,6 +18,7 @@ import (
"cmp"
"context"
"crypto/tls"
"errors"
"fmt"
"maps"
"net"
@@ -711,9 +712,10 @@ func (app *App) Stop() error {
// enforce grace period if configured
if app.GracePeriod > 0 {
var cancel context.CancelFunc
ctx, cancel = context.WithTimeout(ctx, time.Duration(app.GracePeriod))
timeout := time.Duration(app.GracePeriod)
ctx, cancel = context.WithTimeoutCause(ctx, timeout, fmt.Errorf("server graceful shutdown %ds timeout", int(timeout.Seconds())))
defer cancel()
app.logger.Info("servers shutting down; grace period initiated", zap.Duration("duration", time.Duration(app.GracePeriod)))
app.logger.Info("servers shutting down; grace period initiated", zap.Duration("duration", timeout))
} else {
app.logger.Info("servers shutting down with eternal grace period")
}
@@ -739,6 +741,9 @@ func (app *App) Stop() error {
}
if err := server.server.Shutdown(ctx); err != nil {
if cause := context.Cause(ctx); cause != nil && errors.Is(err, context.DeadlineExceeded) {
err = cause
}
app.logger.Error("server shutdown",
zap.Error(err),
zap.Strings("addresses", server.Listen))
@@ -762,6 +767,9 @@ func (app *App) Stop() error {
}
if err := server.h3server.Shutdown(ctx); err != nil {
if cause := context.Cause(ctx); cause != nil && errors.Is(err, context.DeadlineExceeded) {
err = cause
}
app.logger.Error("HTTP/3 server shutdown",
zap.Error(err),
zap.Strings("addresses", server.Listen))
+78
View File
@@ -424,6 +424,40 @@ redirServersLoop:
// we'll create a new server for all the listener addresses
// that are unused and serve the remaining redirects from it
// Sort redirect routes by host specificity to ensure exact matches
// take precedence over wildcards, preventing ambiguous routing.
slices.SortFunc(routes, func(a, b Route) int {
hostA := getFirstHostFromRoute(a)
hostB := getFirstHostFromRoute(b)
// Catch-all routes (empty host) have the lowest priority
if hostA == "" && hostB != "" {
return 1
}
if hostB == "" && hostA != "" {
return -1
}
hasWildcardA := strings.Contains(hostA, "*")
hasWildcardB := strings.Contains(hostB, "*")
// Exact domains take precedence over wildcards
if !hasWildcardA && hasWildcardB {
return -1
}
if hasWildcardA && !hasWildcardB {
return 1
}
// If both are exact or both are wildcards, the longer one is more specific
if len(hostA) != len(hostB) {
return len(hostB) - len(hostA)
}
// Tie-breaker: alphabetical order to ensure determinism
return strings.Compare(hostA, hostB)
})
// Use the sorted srvNames to consistently find the target server
for _, srvName := range srvNames {
srv := app.Servers[srvName]
@@ -580,6 +614,27 @@ func (app *App) createAutomationPolicies(ctx caddy.Context, internalNames, tails
}
}
// Ensure automation policies' CertMagic configs are rebuilt when
// ACME issuer templates may have been modified above (for example,
// alternate ports filled in by the HTTP app). If a policy is already
// provisioned, perform a lightweight rebuild of the CertMagic config
// so issuers receive SetConfig with the updated templates; otherwise
// run a normal Provision to initialize the policy.
for i, ap := range app.tlsApp.Automation.Policies {
// If the policy is already provisioned, rebuild only the CertMagic
// config so issuers get SetConfig with updated templates. Otherwise
// provision the policy normally (which may load modules).
if ap.IsProvisioned() {
if err := ap.RebuildCertMagic(app.tlsApp); err != nil {
return fmt.Errorf("rebuilding certmagic config for automation policy %d: %v", i, err)
}
} else {
if err := ap.Provision(app.tlsApp); err != nil {
return fmt.Errorf("provisioning automation policy %d after auto-HTTPS defaults: %v", i, err)
}
}
}
if basePolicy == nil {
// no base policy found; we will make one
basePolicy = new(caddytls.AutomationPolicy)
@@ -793,3 +848,26 @@ func isTailscaleDomain(name string) bool {
}
type acmeCapable interface{ GetACMEIssuer() *caddytls.ACMEIssuer }
// getFirstHostFromRoute traverses a route's matchers to find the Host rule.
// Since we are dealing with internally generated redirect routes, the host
// is typically the first string within the MatchHost.
func getFirstHostFromRoute(r Route) string {
for _, matcherSet := range r.MatcherSets {
for _, m := range matcherSet {
// Check if the matcher is of type MatchHost (value or pointer)
switch hm := m.(type) {
case MatchHost:
if len(hm) > 0 {
return hm[0]
}
case *MatchHost:
if len(*hm) > 0 {
return (*hm)[0]
}
}
}
}
// Return an empty string if it's a catch-all route (no specific host)
return ""
}
-8
View File
@@ -307,14 +307,6 @@ func (rw *responseWriter) FlushError() error {
return http.NewResponseController(rw.ResponseWriter).Flush()
}
// Flush calls FlushError() and simply discards any error. It is only implemented for backwards
// compatibility with legacy code that does not use FlushError; we know at least one sponsor
// needs this. It should not be relied upon as a stable part of the exported API, as it may be
// removed in the future.
func (rw *responseWriter) Flush() {
_ = rw.FlushError()
}
// Write writes to the response. If the response qualifies,
// it is encoded using the encoder, which is initialized
// if not done so already.
+1
View File
@@ -720,6 +720,7 @@ var globSafeRepl = strings.NewReplacer(
"*", "\\*",
"[", "\\[",
"?", "\\?",
"\\", "\\\\",
)
const (
+69 -38
View File
@@ -20,7 +20,9 @@ import (
"net/http/httptest"
"net/url"
"os"
"path/filepath"
"runtime"
"strings"
"testing"
"github.com/caddyserver/caddy/v2"
@@ -28,6 +30,13 @@ import (
"github.com/caddyserver/caddy/v2/modules/caddyhttp"
)
type testCase struct {
path string
expectedPath string
expectedType string
matched bool
}
func TestFileMatcher(t *testing.T) {
// Windows doesn't like colons in files names
isWindows := runtime.GOOS == "windows"
@@ -45,12 +54,7 @@ func TestFileMatcher(t *testing.T) {
f.Close()
}
for i, tc := range []struct {
path string
expectedPath string
expectedType string
matched bool
}{
for i, tc := range []testCase{
{
path: "/foo.txt",
expectedPath: "/foo.txt",
@@ -116,44 +120,71 @@ func TestFileMatcher(t *testing.T) {
matched: !isWindows,
},
} {
m := &MatchFile{
fsmap: &filesystems.FileSystemMap{},
Root: "./testdata",
TryFiles: []string{"{http.request.uri.path}", "{http.request.uri.path}/"},
}
fileMatcherTest(t, i, tc)
}
}
u, err := url.Parse(tc.path)
if err != nil {
t.Errorf("Test %d: parsing path: %v", i, err)
}
func TestFileMatcherNonWindows(t *testing.T) {
if runtime.GOOS == "windows" {
return
}
req := &http.Request{URL: u}
repl := caddyhttp.NewTestReplacer(req)
// this is impossible to test on Windows, but tests a security patch for other platforms
tc := testCase{
path: "/foodir/secr%5Cet.txt",
expectedPath: "/foodir/secr\\et.txt",
expectedType: "file",
matched: true,
}
result, err := m.MatchWithError(req)
if err != nil {
t.Errorf("Test %d: unexpected error: %v", i, err)
}
if result != tc.matched {
t.Errorf("Test %d: expected match=%t, got %t", i, tc.matched, result)
}
f, err := os.Create(filepath.Join("testdata", strings.TrimPrefix(tc.expectedPath, "/")))
if err != nil {
t.Fatalf("could not create test file: %v", err)
}
defer f.Close()
defer os.Remove(f.Name())
rel, ok := repl.Get("http.matchers.file.relative")
if !ok && result {
t.Errorf("Test %d: expected replacer value", i)
}
if !result {
continue
}
fileMatcherTest(t, 0, tc)
}
if rel != tc.expectedPath {
t.Errorf("Test %d: actual path: %v, expected: %v", i, rel, tc.expectedPath)
}
func fileMatcherTest(t *testing.T, i int, tc testCase) {
m := &MatchFile{
fsmap: &filesystems.FileSystemMap{},
Root: "./testdata",
TryFiles: []string{"{http.request.uri.path}", "{http.request.uri.path}/"},
}
fileType, _ := repl.Get("http.matchers.file.type")
if fileType != tc.expectedType {
t.Errorf("Test %d: actual file type: %v, expected: %v", i, fileType, tc.expectedType)
}
u, err := url.Parse(tc.path)
if err != nil {
t.Errorf("Test %d: parsing path: %v", i, err)
}
req := &http.Request{URL: u}
repl := caddyhttp.NewTestReplacer(req)
result, err := m.MatchWithError(req)
if err != nil {
t.Errorf("Test %d: unexpected error: %v", i, err)
}
if result != tc.matched {
t.Errorf("Test %d: expected match=%t, got %t", i, tc.matched, result)
}
rel, ok := repl.Get("http.matchers.file.relative")
if !ok && result {
t.Errorf("Test %d: expected replacer value", i)
}
if !result {
return
}
if rel != tc.expectedPath {
t.Errorf("Test %d: actual path: %v, expected: %v", i, rel, tc.expectedPath)
}
fileType, _ := repl.Get("http.matchers.file.type")
if fileType != tc.expectedType {
t.Errorf("Test %d: actual file type: %v, expected: %v", i, fileType, tc.expectedType)
}
}
@@ -125,6 +125,11 @@ type FileServer struct {
// When possible, all paths are resolved to their absolute form before
// comparisons are made. For maximum clarity and explictness, use complete,
// absolute paths; or, for greater portability, use relative paths instead.
//
// Note that hide comparisons are case-sensitive. On case-insensitive
// filesystems, requests with different path casing may still resolve to the
// same file or directory on disk, so hide should not be treated as a
// security boundary for sensitive paths.
Hide []string `json:"hide,omitempty"`
// The names of files to try as index files if a folder is requested.
+3 -3
View File
@@ -161,11 +161,11 @@ func (ops *HeaderOps) Provision(_ caddy.Context) error {
// containsPlaceholders checks if the string contains Caddy placeholder syntax {key}
func containsPlaceholders(s string) bool {
openIdx := strings.Index(s, "{")
if openIdx == -1 {
_, after, ok := strings.Cut(s, "{")
if !ok {
return false
}
closeIdx := strings.Index(s[openIdx+1:], "}")
closeIdx := strings.Index(after, "}")
if closeIdx == -1 {
return false
}
+11 -5
View File
@@ -262,13 +262,17 @@ func (m MatchHost) Provision(_ caddy.Context) error {
if err != nil {
return fmt.Errorf("converting hostname '%s' to ASCII: %v", host, err)
}
if asciiHost != host {
m[i] = asciiHost
}
normalizedHost := strings.ToLower(asciiHost)
if firstI, ok := seen[normalizedHost]; ok {
return fmt.Errorf("host at index %d is repeated at index %d: %s", firstI, i, host)
}
// Normalize exact hosts for standardized comparison in large-list fastpath later on.
// Keep wildcards/placeholders untouched.
if m.fuzzy(asciiHost) {
m[i] = asciiHost
} else {
m[i] = normalizedHost
}
seen[normalizedHost] = i
}
@@ -312,14 +316,15 @@ func (m MatchHost) MatchWithError(r *http.Request) (bool, error) {
}
if m.large() {
reqHostLower := strings.ToLower(reqHost)
// fast path: locate exact match using binary search (about 100-1000x faster for large lists)
pos := sort.Search(len(m), func(i int) bool {
if m.fuzzy(m[i]) {
return false
}
return m[i] >= reqHost
return m[i] >= reqHostLower
})
if pos < len(m) && strings.EqualFold(m[pos], reqHost) {
if pos < len(m) && m[pos] == reqHostLower {
return true, nil
}
}
@@ -533,6 +538,7 @@ func (m MatchPath) MatchWithError(r *http.Request) (bool, error) {
}
func (MatchPath) matchPatternWithEscapeSequence(escapedPath, matchPath string) bool {
escapedPath = strings.ToLower(escapedPath)
// We would just compare the pattern against r.URL.Path,
// but the pattern contains %, indicating that we should
// compare at least some part of the path in raw/escaped
+15 -1
View File
@@ -417,6 +417,11 @@ func TestPathMatcher(t *testing.T) {
input: "/ADMIN%2fpanel",
expect: true,
},
{
match: MatchPath{"/admin%2fpa*el"},
input: "/ADMIN%2fPaAzZLm123NEL",
expect: true,
},
} {
err := tc.match.Provision(caddy.Context{})
if err == nil && tc.provisionErr {
@@ -962,6 +967,7 @@ func TestVarREMatcher(t *testing.T) {
desc string
match MatchVarsRE
input VarsMiddleware
headers http.Header
expect bool
expectRepl map[string]string
}{
@@ -996,6 +1002,14 @@ func TestVarREMatcher(t *testing.T) {
input: VarsMiddleware{"Var1": "var1Value"},
expect: true,
},
{
desc: "placeholder key value containing braces is not double-expanded",
match: MatchVarsRE{"{http.request.header.X-Input}": &MatchRegexp{Pattern: ".+", Name: "val"}},
input: VarsMiddleware{},
headers: http.Header{"X-Input": []string{"{env.HOME}"}},
expect: true,
expectRepl: map[string]string{"val.0": "{env.HOME}"},
},
} {
t.Run(tc.desc, func(t *testing.T) {
t.Parallel()
@@ -1012,7 +1026,7 @@ func TestVarREMatcher(t *testing.T) {
}
// set up the fake request and its Replacer
req := &http.Request{URL: new(url.URL), Method: http.MethodGet}
req := &http.Request{URL: new(url.URL), Method: http.MethodGet, Header: tc.headers}
repl := caddy.NewReplacer()
ctx := context.WithValue(req.Context(), caddy.ReplacerCtxKey, repl)
ctx = context.WithValue(ctx, VarsCtxKey, make(map[string]any))
+11 -8
View File
@@ -214,21 +214,24 @@ func serverNameFromContext(ctx context.Context) string {
return srv.name
}
type metricsInstrumentedHandler struct {
// metricsInstrumentedRoute wraps a compiled route Handler with metrics
// instrumentation. It wraps the entire compiled route chain once,
// collecting metrics only once per route match.
type metricsInstrumentedRoute struct {
handler string
mh MiddlewareHandler
next Handler
metrics *Metrics
}
func newMetricsInstrumentedHandler(ctx caddy.Context, handler string, mh MiddlewareHandler, metrics *Metrics) *metricsInstrumentedHandler {
metrics.init.Do(func() {
initHTTPMetrics(ctx, metrics)
func newMetricsInstrumentedRoute(ctx caddy.Context, handler string, next Handler, m *Metrics) *metricsInstrumentedRoute {
m.init.Do(func() {
initHTTPMetrics(ctx, m)
})
return &metricsInstrumentedHandler{handler, mh, metrics}
return &metricsInstrumentedRoute{handler: handler, next: next, metrics: m}
}
func (h *metricsInstrumentedHandler) ServeHTTP(w http.ResponseWriter, r *http.Request, next Handler) error {
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)
@@ -267,7 +270,7 @@ func (h *metricsInstrumentedHandler) ServeHTTP(w http.ResponseWriter, r *http.Re
return false
})
wrec := NewResponseRecorder(w, nil, writeHeaderRecorder)
err := h.mh.ServeHTTP(wrec, r, next)
err := h.next.ServeHTTP(wrec, r)
dur := time.Since(start).Seconds()
h.metrics.httpMetrics.requestCount.With(labels).Inc()
+124 -38
View File
@@ -47,16 +47,12 @@ func TestMetricsInstrumentedHandler(t *testing.T) {
return handlerErr
})
mh := middlewareHandlerFunc(func(w http.ResponseWriter, r *http.Request, h Handler) error {
return h.ServeHTTP(w, r)
})
ih := newMetricsInstrumentedHandler(ctx, "bar", mh, metrics)
ih := newMetricsInstrumentedRoute(ctx, "bar", h, metrics)
r := httptest.NewRequest("GET", "/", nil)
w := httptest.NewRecorder()
if actual := ih.ServeHTTP(w, r, h); actual != handlerErr {
if actual := ih.ServeHTTP(w, r); actual != handlerErr {
t.Errorf("Not same: expected %#v, but got %#v", handlerErr, actual)
}
if actual := testutil.ToFloat64(metrics.httpMetrics.requestInFlight); actual != 0.0 {
@@ -64,19 +60,19 @@ func TestMetricsInstrumentedHandler(t *testing.T) {
}
handlerErr = nil
if err := ih.ServeHTTP(w, r, h); err != nil {
if err := ih.ServeHTTP(w, r); err != nil {
t.Errorf("Received unexpected error: %v", err)
}
// an empty handler - no errors, no header written
mh = middlewareHandlerFunc(func(w http.ResponseWriter, r *http.Request, h Handler) error {
emptyHandler := HandlerFunc(func(w http.ResponseWriter, r *http.Request) error {
return nil
})
ih = newMetricsInstrumentedHandler(ctx, "empty", mh, metrics)
ih = newMetricsInstrumentedRoute(ctx, "empty", emptyHandler, metrics)
r = httptest.NewRequest("GET", "/", nil)
w = httptest.NewRecorder()
if err := ih.ServeHTTP(w, r, h); err != nil {
if err := ih.ServeHTTP(w, r); err != nil {
t.Errorf("Received unexpected error: %v", err)
}
if actual := w.Result().StatusCode; actual != 200 {
@@ -87,16 +83,16 @@ func TestMetricsInstrumentedHandler(t *testing.T) {
}
// handler returning an error with an HTTP status
mh = middlewareHandlerFunc(func(w http.ResponseWriter, r *http.Request, h Handler) error {
errHandler := HandlerFunc(func(w http.ResponseWriter, r *http.Request) error {
return Error(http.StatusTooManyRequests, nil)
})
ih = newMetricsInstrumentedHandler(ctx, "foo", mh, metrics)
ih = newMetricsInstrumentedRoute(ctx, "foo", errHandler, metrics)
r = httptest.NewRequest("GET", "/", nil)
w = httptest.NewRecorder()
if err := ih.ServeHTTP(w, r, nil); err == nil {
if err := ih.ServeHTTP(w, r); err == nil {
t.Errorf("expected error to be propagated")
}
@@ -225,16 +221,12 @@ func TestMetricsInstrumentedHandlerPerHost(t *testing.T) {
return handlerErr
})
mh := middlewareHandlerFunc(func(w http.ResponseWriter, r *http.Request, h Handler) error {
return h.ServeHTTP(w, r)
})
ih := newMetricsInstrumentedHandler(ctx, "bar", mh, metrics)
ih := newMetricsInstrumentedRoute(ctx, "bar", h, metrics)
r := httptest.NewRequest("GET", "/", nil)
w := httptest.NewRecorder()
if actual := ih.ServeHTTP(w, r, h); actual != handlerErr {
if actual := ih.ServeHTTP(w, r); actual != handlerErr {
t.Errorf("Not same: expected %#v, but got %#v", handlerErr, actual)
}
if actual := testutil.ToFloat64(metrics.httpMetrics.requestInFlight); actual != 0.0 {
@@ -242,19 +234,19 @@ func TestMetricsInstrumentedHandlerPerHost(t *testing.T) {
}
handlerErr = nil
if err := ih.ServeHTTP(w, r, h); err != nil {
if err := ih.ServeHTTP(w, r); err != nil {
t.Errorf("Received unexpected error: %v", err)
}
// an empty handler - no errors, no header written
mh = middlewareHandlerFunc(func(w http.ResponseWriter, r *http.Request, h Handler) error {
emptyHandler := HandlerFunc(func(w http.ResponseWriter, r *http.Request) error {
return nil
})
ih = newMetricsInstrumentedHandler(ctx, "empty", mh, metrics)
ih = newMetricsInstrumentedRoute(ctx, "empty", emptyHandler, metrics)
r = httptest.NewRequest("GET", "/", nil)
w = httptest.NewRecorder()
if err := ih.ServeHTTP(w, r, h); err != nil {
if err := ih.ServeHTTP(w, r); err != nil {
t.Errorf("Received unexpected error: %v", err)
}
if actual := w.Result().StatusCode; actual != 200 {
@@ -265,16 +257,16 @@ func TestMetricsInstrumentedHandlerPerHost(t *testing.T) {
}
// handler returning an error with an HTTP status
mh = middlewareHandlerFunc(func(w http.ResponseWriter, r *http.Request, h Handler) error {
errHandler := HandlerFunc(func(w http.ResponseWriter, r *http.Request) error {
return Error(http.StatusTooManyRequests, nil)
})
ih = newMetricsInstrumentedHandler(ctx, "foo", mh, metrics)
ih = newMetricsInstrumentedRoute(ctx, "foo", errHandler, metrics)
r = httptest.NewRequest("GET", "/", nil)
w = httptest.NewRecorder()
if err := ih.ServeHTTP(w, r, nil); err == nil {
if err := ih.ServeHTTP(w, r); err == nil {
t.Errorf("expected error to be propagated")
}
@@ -397,30 +389,30 @@ func TestMetricsCardinalityProtection(t *testing.T) {
// Add one allowed host
metrics.allowedHosts["allowed.com"] = struct{}{}
mh := middlewareHandlerFunc(func(w http.ResponseWriter, r *http.Request, h Handler) error {
h := HandlerFunc(func(w http.ResponseWriter, r *http.Request) error {
w.Write([]byte("hello"))
return nil
})
ih := newMetricsInstrumentedHandler(ctx, "test", mh, metrics)
ih := newMetricsInstrumentedRoute(ctx, "test", h, metrics)
// Test request to allowed host
r1 := httptest.NewRequest("GET", "http://allowed.com/", nil)
r1.Host = "allowed.com"
w1 := httptest.NewRecorder()
ih.ServeHTTP(w1, r1, HandlerFunc(func(w http.ResponseWriter, r *http.Request) error { return nil }))
ih.ServeHTTP(w1, r1)
// Test request to unknown host (should be mapped to "_other")
r2 := httptest.NewRequest("GET", "http://attacker.com/", nil)
r2.Host = "attacker.com"
w2 := httptest.NewRecorder()
ih.ServeHTTP(w2, r2, HandlerFunc(func(w http.ResponseWriter, r *http.Request) error { return nil }))
ih.ServeHTTP(w2, r2)
// Test request to another unknown host (should also be mapped to "_other")
r3 := httptest.NewRequest("GET", "http://evil.com/", nil)
r3.Host = "evil.com"
w3 := httptest.NewRecorder()
ih.ServeHTTP(w3, r3, HandlerFunc(func(w http.ResponseWriter, r *http.Request) error { return nil }))
ih.ServeHTTP(w3, r3)
// Check that metrics contain:
// - One entry for "allowed.com"
@@ -452,26 +444,26 @@ func TestMetricsHTTPSCatchAll(t *testing.T) {
allowedHosts: make(map[string]struct{}), // Empty - no explicitly allowed hosts
}
mh := middlewareHandlerFunc(func(w http.ResponseWriter, r *http.Request, h Handler) error {
h := HandlerFunc(func(w http.ResponseWriter, r *http.Request) error {
w.Write([]byte("hello"))
return nil
})
ih := newMetricsInstrumentedHandler(ctx, "test", mh, metrics)
ih := newMetricsInstrumentedRoute(ctx, "test", h, metrics)
// Test HTTPS request (should be allowed even though not in allowedHosts)
r1 := httptest.NewRequest("GET", "https://unknown.com/", nil)
r1.Host = "unknown.com"
r1.TLS = &tls.ConnectionState{} // Mark as TLS/HTTPS
w1 := httptest.NewRecorder()
ih.ServeHTTP(w1, r1, HandlerFunc(func(w http.ResponseWriter, r *http.Request) error { return nil }))
ih.ServeHTTP(w1, r1)
// Test HTTP request (should be mapped to "_other")
r2 := httptest.NewRequest("GET", "http://unknown.com/", nil)
r2.Host = "unknown.com"
// No TLS field = HTTP request
w2 := httptest.NewRecorder()
ih.ServeHTTP(w2, r2, HandlerFunc(func(w http.ResponseWriter, r *http.Request) error { return nil }))
ih.ServeHTTP(w2, r2)
// Check that HTTPS request gets real host, HTTP gets "_other"
expected := `
@@ -488,8 +480,102 @@ func TestMetricsHTTPSCatchAll(t *testing.T) {
}
}
type middlewareHandlerFunc func(http.ResponseWriter, *http.Request, Handler) error
func TestMetricsInstrumentedRoute(t *testing.T) {
ctx, _ := caddy.NewContext(caddy.Context{Context: context.Background()})
m := &Metrics{
init: sync.Once{},
httpMetrics: &httpMetrics{},
}
func (f middlewareHandlerFunc) ServeHTTP(w http.ResponseWriter, r *http.Request, h Handler) error {
return f(w, r, h)
handlerErr := errors.New("oh noes")
response := []byte("hello world!")
innerHandler := HandlerFunc(func(w http.ResponseWriter, r *http.Request) error {
if actual := testutil.ToFloat64(m.httpMetrics.requestInFlight); actual != 1.0 {
t.Errorf("Expected requestInFlight to be 1.0, got %v", actual)
}
if handlerErr == nil {
w.Write(response)
}
return handlerErr
})
ih := newMetricsInstrumentedRoute(ctx, "test_handler", innerHandler, m)
r := httptest.NewRequest("GET", "/", nil)
w := httptest.NewRecorder()
// Test with error
if actual := ih.ServeHTTP(w, r); actual != handlerErr {
t.Errorf("Expected error %v, got %v", handlerErr, actual)
}
if actual := testutil.ToFloat64(m.httpMetrics.requestInFlight); actual != 0.0 {
t.Errorf("Expected requestInFlight to be 0.0 after request, got %v", actual)
}
if actual := testutil.ToFloat64(m.httpMetrics.requestErrors); actual != 1.0 {
t.Errorf("Expected requestErrors to be 1.0, got %v", actual)
}
// Test without error
handlerErr = nil
w = httptest.NewRecorder()
if err := ih.ServeHTTP(w, r); err != nil {
t.Errorf("Unexpected error: %v", err)
}
}
func BenchmarkMetricsInstrumentedRoute(b *testing.B) {
ctx, _ := caddy.NewContext(caddy.Context{Context: context.Background()})
m := &Metrics{
init: sync.Once{},
httpMetrics: &httpMetrics{},
}
noopHandler := HandlerFunc(func(w http.ResponseWriter, r *http.Request) error {
w.Write([]byte("ok"))
return nil
})
ih := newMetricsInstrumentedRoute(ctx, "bench_handler", noopHandler, m)
r := httptest.NewRequest("GET", "/", nil)
w := httptest.NewRecorder()
b.ResetTimer()
b.ReportAllocs()
for i := 0; i < b.N; i++ {
ih.ServeHTTP(w, r)
}
}
// BenchmarkSingleRouteMetrics simulates the new behavior where metrics
// are collected once for the entire route.
func BenchmarkSingleRouteMetrics(b *testing.B) {
ctx, _ := caddy.NewContext(caddy.Context{Context: context.Background()})
m := &Metrics{
init: sync.Once{},
httpMetrics: &httpMetrics{},
}
// Build a chain of 5 plain middleware handlers (no per-handler metrics)
var next Handler = HandlerFunc(func(w http.ResponseWriter, r *http.Request) error {
return nil
})
for i := 0; i < 5; i++ {
capturedNext := next
next = HandlerFunc(func(w http.ResponseWriter, r *http.Request) error {
return capturedNext.ServeHTTP(w, r)
})
}
// Wrap the entire chain with a single route-level metrics handler
ih := newMetricsInstrumentedRoute(ctx, "handler", next, m)
r := httptest.NewRequest("GET", "/", nil)
w := httptest.NewRecorder()
b.ResetTimer()
b.ReportAllocs()
for i := 0; i < b.N; i++ {
ih.ServeHTTP(w, r)
}
}
+10 -1
View File
@@ -420,7 +420,16 @@ func getReqTLSReplacement(req *http.Request, key string) (any, bool) {
if strings.HasPrefix(field, "client.") {
cert := getTLSPeerCert(req.TLS)
if cert == nil {
return nil, false
// Instead of returning (nil, false) here, we set it to a dummy
// value to fix #7530. This way, even if there is no client cert,
// evaluating placeholders with ReplaceKnown() will still remove
// the placeholder, which would be expected. It is not expected
// for the placeholder to sometimes get removed based on whether
// the client presented a cert. We also do not return true here
// because we probably should remain accurate about whether a
// placeholder is, in fact, known or not.
// (This allocation may be slightly inefficient.)
cert = new(x509.Certificate)
}
// subject alternate names (SANs)
+26 -2
View File
@@ -73,8 +73,9 @@ func (adminUpstreams) handleUpstreams(w http.ResponseWriter, r *http.Request) er
// Collect the results to respond with
results := []upstreamStatus{}
knownHosts := make(map[string]struct{})
// Iterate over the upstream pool (needs to be fast)
// Iterate over the static upstream pool (needs to be fast)
var rangeErr error
hosts.Range(func(key, val any) bool {
address, ok := key.(string)
@@ -95,6 +96,8 @@ func (adminUpstreams) handleUpstreams(w http.ResponseWriter, r *http.Request) er
return false
}
knownHosts[address] = struct{}{}
results = append(results, upstreamStatus{
Address: address,
NumRequests: upstream.NumRequests(),
@@ -103,11 +106,32 @@ func (adminUpstreams) handleUpstreams(w http.ResponseWriter, r *http.Request) er
return true
})
// If an error happened during the range, return it
currentInFlight := getInFlightRequests()
for address, count := range currentInFlight {
if _, exists := knownHosts[address]; !exists && count > 0 {
results = append(results, upstreamStatus{
Address: address,
NumRequests: int(count),
Fails: 0,
})
}
}
if rangeErr != nil {
return rangeErr
}
// Also include dynamic upstreams
dynamicHostsMu.RLock()
for address, entry := range dynamicHosts {
results = append(results, upstreamStatus{
Address: address,
NumRequests: entry.host.NumRequests(),
Fails: entry.host.Fails(),
})
}
dynamicHostsMu.RUnlock()
err := enc.Encode(results)
if err != nil {
return caddy.APIError{
@@ -0,0 +1,275 @@
// 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.
package reverseproxy
import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"time"
)
// adminHandlerFixture sets up the global host state for an admin endpoint test
// and returns a cleanup function that must be deferred by the caller.
//
// staticAddrs are inserted into the UsagePool (as a static upstream would be).
// dynamicAddrs are inserted into the dynamicHosts map (as a dynamic upstream would be).
func adminHandlerFixture(t *testing.T, staticAddrs, dynamicAddrs []string) func() {
t.Helper()
for _, addr := range staticAddrs {
u := &Upstream{Dial: addr}
u.fillHost()
}
dynamicHostsMu.Lock()
for _, addr := range dynamicAddrs {
dynamicHosts[addr] = dynamicHostEntry{host: new(Host), lastSeen: time.Now()}
}
dynamicHostsMu.Unlock()
return func() {
// Remove static entries from the UsagePool.
for _, addr := range staticAddrs {
_, _ = hosts.Delete(addr)
}
// Remove dynamic entries.
dynamicHostsMu.Lock()
for _, addr := range dynamicAddrs {
delete(dynamicHosts, addr)
}
dynamicHostsMu.Unlock()
}
}
// callAdminUpstreams fires a GET against handleUpstreams and returns the
// decoded response body.
func callAdminUpstreams(t *testing.T) []upstreamStatus {
t.Helper()
req := httptest.NewRequest(http.MethodGet, "/reverse_proxy/upstreams", nil)
w := httptest.NewRecorder()
handler := adminUpstreams{}
if err := handler.handleUpstreams(w, req); err != nil {
t.Fatalf("handleUpstreams returned unexpected error: %v", err)
}
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", w.Code)
}
if ct := w.Header().Get("Content-Type"); ct != "application/json" {
t.Fatalf("expected Content-Type application/json, got %q", ct)
}
var results []upstreamStatus
if err := json.NewDecoder(w.Body).Decode(&results); err != nil {
t.Fatalf("failed to decode response: %v", err)
}
return results
}
// resultsByAddress indexes a slice of upstreamStatus by address for easier
// lookup in assertions.
func resultsByAddress(statuses []upstreamStatus) map[string]upstreamStatus {
m := make(map[string]upstreamStatus, len(statuses))
for _, s := range statuses {
m[s.Address] = s
}
return m
}
// TestAdminUpstreamsMethodNotAllowed verifies that non-GET methods are rejected.
func TestAdminUpstreamsMethodNotAllowed(t *testing.T) {
for _, method := range []string{http.MethodPost, http.MethodPut, http.MethodDelete} {
req := httptest.NewRequest(method, "/reverse_proxy/upstreams", nil)
w := httptest.NewRecorder()
err := (adminUpstreams{}).handleUpstreams(w, req)
if err == nil {
t.Errorf("method %s: expected an error, got nil", method)
continue
}
apiErr, ok := err.(interface{ HTTPStatus() int })
if !ok {
// caddy.APIError stores the code in HTTPStatus field, access via the
// exported interface it satisfies indirectly; just check non-nil.
continue
}
if code := apiErr.HTTPStatus(); code != http.StatusMethodNotAllowed {
t.Errorf("method %s: expected 405, got %d", method, code)
}
}
}
// TestAdminUpstreamsEmpty verifies that an empty response is valid JSON when
// no upstreams are registered.
func TestAdminUpstreamsEmpty(t *testing.T) {
resetDynamicHosts()
results := callAdminUpstreams(t)
if results == nil {
t.Error("expected non-nil (empty) slice, got nil")
}
if len(results) != 0 {
t.Errorf("expected 0 results with empty pools, got %d", len(results))
}
}
// TestAdminUpstreamsStaticOnly verifies that static upstreams (from the
// UsagePool) appear in the response with correct addresses.
func TestAdminUpstreamsStaticOnly(t *testing.T) {
resetDynamicHosts()
cleanup := adminHandlerFixture(t,
[]string{"10.0.0.1:80", "10.0.0.2:80"},
nil,
)
defer cleanup()
results := callAdminUpstreams(t)
byAddr := resultsByAddress(results)
for _, addr := range []string{"10.0.0.1:80", "10.0.0.2:80"} {
if _, ok := byAddr[addr]; !ok {
t.Errorf("expected static upstream %q in response", addr)
}
}
if len(results) != 2 {
t.Errorf("expected exactly 2 results, got %d", len(results))
}
}
// TestAdminUpstreamsDynamicOnly verifies that dynamic upstreams (from
// dynamicHosts) appear in the response with correct addresses.
func TestAdminUpstreamsDynamicOnly(t *testing.T) {
resetDynamicHosts()
cleanup := adminHandlerFixture(t,
nil,
[]string{"10.0.1.1:80", "10.0.1.2:80"},
)
defer cleanup()
results := callAdminUpstreams(t)
byAddr := resultsByAddress(results)
for _, addr := range []string{"10.0.1.1:80", "10.0.1.2:80"} {
if _, ok := byAddr[addr]; !ok {
t.Errorf("expected dynamic upstream %q in response", addr)
}
}
if len(results) != 2 {
t.Errorf("expected exactly 2 results, got %d", len(results))
}
}
// TestAdminUpstreamsBothPools verifies that static and dynamic upstreams are
// both present in the same response and that there is no overlap or omission.
func TestAdminUpstreamsBothPools(t *testing.T) {
resetDynamicHosts()
cleanup := adminHandlerFixture(t,
[]string{"10.0.2.1:80"},
[]string{"10.0.2.2:80"},
)
defer cleanup()
results := callAdminUpstreams(t)
if len(results) != 2 {
t.Fatalf("expected 2 results (1 static + 1 dynamic), got %d", len(results))
}
byAddr := resultsByAddress(results)
if _, ok := byAddr["10.0.2.1:80"]; !ok {
t.Error("static upstream missing from response")
}
if _, ok := byAddr["10.0.2.2:80"]; !ok {
t.Error("dynamic upstream missing from response")
}
}
// TestAdminUpstreamsNoOverlapBetweenPools verifies that an address registered
// only as a static upstream does not also appear as a dynamic entry, and
// vice-versa.
func TestAdminUpstreamsNoOverlapBetweenPools(t *testing.T) {
resetDynamicHosts()
cleanup := adminHandlerFixture(t,
[]string{"10.0.3.1:80"},
[]string{"10.0.3.2:80"},
)
defer cleanup()
results := callAdminUpstreams(t)
seen := make(map[string]int)
for _, r := range results {
seen[r.Address]++
}
for addr, count := range seen {
if count > 1 {
t.Errorf("address %q appeared %d times; expected exactly once", addr, count)
}
}
}
// TestAdminUpstreamsReportsFailCounts verifies that fail counts accumulated on
// a dynamic upstream's Host are reflected in the response.
func TestAdminUpstreamsReportsFailCounts(t *testing.T) {
resetDynamicHosts()
const addr = "10.0.4.1:80"
h := new(Host)
_ = h.countFail(3)
dynamicHostsMu.Lock()
dynamicHosts[addr] = dynamicHostEntry{host: h, lastSeen: time.Now()}
dynamicHostsMu.Unlock()
defer func() {
dynamicHostsMu.Lock()
delete(dynamicHosts, addr)
dynamicHostsMu.Unlock()
}()
results := callAdminUpstreams(t)
byAddr := resultsByAddress(results)
status, ok := byAddr[addr]
if !ok {
t.Fatalf("expected %q in response", addr)
}
if status.Fails != 3 {
t.Errorf("expected Fails=3, got %d", status.Fails)
}
}
// TestAdminUpstreamsReportsNumRequests verifies that the active request count
// for a static upstream is reflected in the response.
func TestAdminUpstreamsReportsNumRequests(t *testing.T) {
resetDynamicHosts()
const addr = "10.0.4.2:80"
u := &Upstream{Dial: addr}
u.fillHost()
defer func() { _, _ = hosts.Delete(addr) }()
_ = u.Host.countRequest(2)
defer func() { _ = u.Host.countRequest(-2) }()
results := callAdminUpstreams(t)
byAddr := resultsByAddress(results)
status, ok := byAddr[addr]
if !ok {
t.Fatalf("expected %q in response", addr)
}
if status.NumRequests != 2 {
t.Errorf("expected NumRequests=2, got %d", status.NumRequests)
}
}
@@ -0,0 +1,345 @@
// 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.
package reverseproxy
import (
"sync"
"testing"
"time"
"github.com/caddyserver/caddy/v2"
)
// resetDynamicHosts clears global dynamic host state between tests.
func resetDynamicHosts() {
dynamicHostsMu.Lock()
dynamicHosts = make(map[string]dynamicHostEntry)
dynamicHostsMu.Unlock()
// Reset the Once so cleanup goroutine tests can re-trigger if needed.
dynamicHostsCleanerOnce = sync.Once{}
}
// TestFillDynamicHostCreatesEntry verifies that calling fillDynamicHost on a
// new address inserts an entry into dynamicHosts and assigns a non-nil Host.
func TestFillDynamicHostCreatesEntry(t *testing.T) {
resetDynamicHosts()
u := &Upstream{Dial: "192.0.2.1:80"}
u.fillDynamicHost()
if u.Host == nil {
t.Fatal("expected Host to be set after fillDynamicHost")
}
dynamicHostsMu.RLock()
entry, ok := dynamicHosts["192.0.2.1:80"]
dynamicHostsMu.RUnlock()
if !ok {
t.Fatal("expected entry in dynamicHosts map")
}
if entry.host != u.Host {
t.Error("dynamicHosts entry host should be the same pointer assigned to Upstream.Host")
}
if entry.lastSeen.IsZero() {
t.Error("expected lastSeen to be set")
}
}
// TestFillDynamicHostReusesSameHost verifies that two calls for the same address
// return the exact same *Host pointer so that state (e.g. fail counts) is shared.
func TestFillDynamicHostReusesSameHost(t *testing.T) {
resetDynamicHosts()
u1 := &Upstream{Dial: "192.0.2.2:80"}
u1.fillDynamicHost()
u2 := &Upstream{Dial: "192.0.2.2:80"}
u2.fillDynamicHost()
if u1.Host != u2.Host {
t.Error("expected both upstreams to share the same *Host pointer")
}
}
// TestFillDynamicHostUpdatesLastSeen verifies that a second call for the same
// address advances the lastSeen timestamp.
func TestFillDynamicHostUpdatesLastSeen(t *testing.T) {
resetDynamicHosts()
u := &Upstream{Dial: "192.0.2.3:80"}
u.fillDynamicHost()
dynamicHostsMu.RLock()
first := dynamicHosts["192.0.2.3:80"].lastSeen
dynamicHostsMu.RUnlock()
// Ensure measurable time passes.
time.Sleep(2 * time.Millisecond)
u2 := &Upstream{Dial: "192.0.2.3:80"}
u2.fillDynamicHost()
dynamicHostsMu.RLock()
second := dynamicHosts["192.0.2.3:80"].lastSeen
dynamicHostsMu.RUnlock()
if !second.After(first) {
t.Error("expected lastSeen to be updated on second fillDynamicHost call")
}
}
// TestFillDynamicHostIndependentAddresses verifies that different addresses get
// independent Host entries.
func TestFillDynamicHostIndependentAddresses(t *testing.T) {
resetDynamicHosts()
u1 := &Upstream{Dial: "192.0.2.4:80"}
u1.fillDynamicHost()
u2 := &Upstream{Dial: "192.0.2.5:80"}
u2.fillDynamicHost()
if u1.Host == u2.Host {
t.Error("different addresses should have different *Host entries")
}
}
// TestFillDynamicHostPreservesFailCount verifies that fail counts on a dynamic
// host survive across multiple fillDynamicHost calls (simulating sequential
// requests), which is the core behaviour fixed by this change.
func TestFillDynamicHostPreservesFailCount(t *testing.T) {
resetDynamicHosts()
// First "request": provision and record a failure.
u1 := &Upstream{Dial: "192.0.2.6:80"}
u1.fillDynamicHost()
_ = u1.Host.countFail(1)
if u1.Host.Fails() != 1 {
t.Fatalf("expected 1 fail, got %d", u1.Host.Fails())
}
// Second "request": provision the same address again (new *Upstream, same address).
u2 := &Upstream{Dial: "192.0.2.6:80"}
u2.fillDynamicHost()
if u2.Host.Fails() != 1 {
t.Errorf("expected fail count to persist across fillDynamicHost calls, got %d", u2.Host.Fails())
}
}
// TestProvisionUpstreamDynamic verifies that provisionUpstream with dynamic=true
// uses fillDynamicHost (not the UsagePool) and sets healthCheckPolicy /
// MaxRequests correctly from handler config.
func TestProvisionUpstreamDynamic(t *testing.T) {
resetDynamicHosts()
passive := &PassiveHealthChecks{
FailDuration: caddy.Duration(10 * time.Second),
MaxFails: 3,
UnhealthyRequestCount: 5,
}
h := Handler{
HealthChecks: &HealthChecks{
Passive: passive,
},
}
u := &Upstream{Dial: "192.0.2.7:80"}
h.provisionUpstream(u, true)
if u.Host == nil {
t.Fatal("Host should be set after provisionUpstream")
}
if u.healthCheckPolicy != passive {
t.Error("healthCheckPolicy should point to the handler's PassiveHealthChecks")
}
if u.MaxRequests != 5 {
t.Errorf("expected MaxRequests=5 from UnhealthyRequestCount, got %d", u.MaxRequests)
}
// Must be in dynamicHosts, not in the static UsagePool.
dynamicHostsMu.RLock()
_, inDynamic := dynamicHosts["192.0.2.7:80"]
dynamicHostsMu.RUnlock()
if !inDynamic {
t.Error("dynamic upstream should be stored in dynamicHosts")
}
_, inPool := hosts.References("192.0.2.7:80")
if inPool {
t.Error("dynamic upstream should NOT be stored in the static UsagePool")
}
}
// TestProvisionUpstreamStatic verifies that provisionUpstream with dynamic=false
// uses the UsagePool and does NOT insert into dynamicHosts.
func TestProvisionUpstreamStatic(t *testing.T) {
resetDynamicHosts()
h := Handler{}
u := &Upstream{Dial: "192.0.2.8:80"}
h.provisionUpstream(u, false)
if u.Host == nil {
t.Fatal("Host should be set after provisionUpstream")
}
refs, inPool := hosts.References("192.0.2.8:80")
if !inPool {
t.Error("static upstream should be in the UsagePool")
}
if refs != 1 {
t.Errorf("expected ref count 1, got %d", refs)
}
dynamicHostsMu.RLock()
_, inDynamic := dynamicHosts["192.0.2.8:80"]
dynamicHostsMu.RUnlock()
if inDynamic {
t.Error("static upstream should NOT be in dynamicHosts")
}
// Clean up the pool entry we just added.
_, _ = hosts.Delete("192.0.2.8:80")
}
// TestDynamicHostHealthyConsultsFails verifies the end-to-end passive health
// check path: after enough failures are recorded against a dynamic upstream's
// shared *Host, Healthy() returns false for a newly provisioned *Upstream with
// the same address.
func TestDynamicHostHealthyConsultsFails(t *testing.T) {
resetDynamicHosts()
passive := &PassiveHealthChecks{
FailDuration: caddy.Duration(time.Minute),
MaxFails: 2,
}
h := Handler{
HealthChecks: &HealthChecks{Passive: passive},
}
// First request: provision and record two failures.
u1 := &Upstream{Dial: "192.0.2.9:80"}
h.provisionUpstream(u1, true)
_ = u1.Host.countFail(1)
_ = u1.Host.countFail(1)
// Second request: fresh *Upstream, same address.
u2 := &Upstream{Dial: "192.0.2.9:80"}
h.provisionUpstream(u2, true)
if u2.Healthy() {
t.Error("upstream should be unhealthy after MaxFails failures have been recorded against its shared Host")
}
}
// TestDynamicHostCleanupEvictsStaleEntries verifies that the cleanup sweep
// removes entries whose lastSeen is older than dynamicHostIdleExpiry.
func TestDynamicHostCleanupEvictsStaleEntries(t *testing.T) {
resetDynamicHosts()
const addr = "192.0.2.10:80"
// Insert an entry directly with a lastSeen far in the past.
dynamicHostsMu.Lock()
dynamicHosts[addr] = dynamicHostEntry{
host: new(Host),
lastSeen: time.Now().Add(-2 * dynamicHostIdleExpiry),
}
dynamicHostsMu.Unlock()
// Run the cleanup logic inline (same logic as the goroutine).
dynamicHostsMu.Lock()
for a, entry := range dynamicHosts {
if time.Since(entry.lastSeen) > dynamicHostIdleExpiry {
delete(dynamicHosts, a)
}
}
dynamicHostsMu.Unlock()
dynamicHostsMu.RLock()
_, stillPresent := dynamicHosts[addr]
dynamicHostsMu.RUnlock()
if stillPresent {
t.Error("stale dynamic host entry should have been evicted by cleanup sweep")
}
}
// TestDynamicHostCleanupRetainsFreshEntries verifies that the cleanup sweep
// keeps entries whose lastSeen is within dynamicHostIdleExpiry.
func TestDynamicHostCleanupRetainsFreshEntries(t *testing.T) {
resetDynamicHosts()
const addr = "192.0.2.11:80"
dynamicHostsMu.Lock()
dynamicHosts[addr] = dynamicHostEntry{
host: new(Host),
lastSeen: time.Now(),
}
dynamicHostsMu.Unlock()
// Run the cleanup logic inline.
dynamicHostsMu.Lock()
for a, entry := range dynamicHosts {
if time.Since(entry.lastSeen) > dynamicHostIdleExpiry {
delete(dynamicHosts, a)
}
}
dynamicHostsMu.Unlock()
dynamicHostsMu.RLock()
_, stillPresent := dynamicHosts[addr]
dynamicHostsMu.RUnlock()
if !stillPresent {
t.Error("fresh dynamic host entry should be retained by cleanup sweep")
}
}
// TestDynamicHostConcurrentFillHost verifies that concurrent calls to
// fillDynamicHost for the same address all get the same *Host pointer and
// don't race (run with -race).
func TestDynamicHostConcurrentFillHost(t *testing.T) {
resetDynamicHosts()
const addr = "192.0.2.12:80"
const goroutines = 50
var wg sync.WaitGroup
hosts := make([]*Host, goroutines)
for i := range goroutines {
wg.Add(1)
go func(idx int) {
defer wg.Done()
u := &Upstream{Dial: addr}
u.fillDynamicHost()
hosts[idx] = u.Host
}(i)
}
wg.Wait()
first := hosts[0]
for i, h := range hosts {
if h != first {
t.Errorf("goroutine %d got a different *Host pointer; expected all to share the same entry", i)
}
}
}
@@ -442,7 +442,7 @@ func (t Transport) splitPos(path string) int {
for _, split := range t.SplitPath {
splitLen := len(split)
for i := 0; i < pathLen; i++ {
for i := range pathLen {
if path[i] >= utf8.RuneSelf {
if _, end := splitSearchNonASCII.IndexString(path, split); end > -1 {
return end
@@ -456,7 +456,7 @@ func (t Transport) splitPos(path string) int {
}
match := true
for j := 0; j < splitLen; j++ {
for j := range splitLen {
c := path[i+j]
if c >= utf8.RuneSelf {
@@ -208,6 +208,24 @@ func parseCaddyfile(h httpcaddyfile.Helper) ([]httpcaddyfile.ConfigValue, error)
for _, from := range sortedHeadersToCopy {
to := http.CanonicalHeaderKey(headersToCopy[from])
placeholderName := "http.reverse_proxy.header." + http.CanonicalHeaderKey(from)
// Always delete the client-supplied header before conditionally setting
// it from the auth response. Without this, a client that pre-supplies a
// header listed in copy_headers can inject arbitrary values when the auth
// service does not return that header: the MatchNot guard below would
// skip the Set entirely, leaving the original client-controlled value
// intact and forwarding it to the backend.
copyHeaderRoutes = append(copyHeaderRoutes, caddyhttp.Route{
HandlersRaw: []json.RawMessage{caddyconfig.JSONModuleObject(
&headers.Handler{
Request: &headers.HeaderOps{
Delete: []string{to},
},
},
"handler", "headers", nil,
)},
})
handler := &headers.Handler{
Request: &headers.HeaderOps{
Set: http.Header{
@@ -359,6 +359,12 @@ func (h *Handler) doActiveHealthCheckForAllHosts() {
dialInfoUpstream = &Upstream{
Dial: h.HealthChecks.Active.Upstream,
}
} else if upstream.activeHealthCheckPort != 0 {
// health_port overrides the port; addr has already been updated
// with the health port, so use its address for dialing
dialInfoUpstream = &Upstream{
Dial: addr.JoinHostPort(0),
}
}
dialInfo, _ := dialInfoUpstream.fillDialInfo(repl)
+61
View File
@@ -19,7 +19,9 @@ import (
"fmt"
"net/netip"
"strconv"
"sync"
"sync/atomic"
"time"
"github.com/caddyserver/caddy/v2"
"github.com/caddyserver/caddy/v2/modules/caddyhttp"
@@ -132,6 +134,43 @@ func (u *Upstream) fillHost() {
u.Host = host
}
// fillDynamicHost is like fillHost, but stores the host in the separate
// dynamicHosts map rather than the reference-counted UsagePool. Dynamic
// hosts are not reference-counted; instead, they are retained as long as
// they are actively seen and are evicted by a background cleanup goroutine
// after dynamicHostIdleExpiry of inactivity. This preserves health state
// (e.g. passive fail counts) across sequential requests.
func (u *Upstream) fillDynamicHost() {
dynamicHostsMu.Lock()
entry, ok := dynamicHosts[u.String()]
if ok {
entry.lastSeen = time.Now()
dynamicHosts[u.String()] = entry
u.Host = entry.host
} else {
h := new(Host)
dynamicHosts[u.String()] = dynamicHostEntry{host: h, lastSeen: time.Now()}
u.Host = h
}
dynamicHostsMu.Unlock()
// ensure the cleanup goroutine is running
dynamicHostsCleanerOnce.Do(func() {
go func() {
for {
time.Sleep(dynamicHostCleanupInterval)
dynamicHostsMu.Lock()
for addr, entry := range dynamicHosts {
if time.Since(entry.lastSeen) > dynamicHostIdleExpiry {
delete(dynamicHosts, addr)
}
}
dynamicHostsMu.Unlock()
}
}()
})
}
// 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 {
@@ -268,6 +307,28 @@ func GetDialInfo(ctx context.Context) (DialInfo, bool) {
// through config reloads.
var hosts = caddy.NewUsagePool()
// dynamicHosts tracks hosts that were provisioned from dynamic upstream
// sources. Unlike static upstreams which are reference-counted via the
// UsagePool, dynamic upstream hosts are not reference-counted. Instead,
// their last-seen time is updated on each request, and a background
// goroutine evicts entries that have been idle for dynamicHostIdleExpiry.
// This preserves health state (e.g. passive fail counts) across requests
// to the same dynamic backend.
var (
dynamicHosts = make(map[string]dynamicHostEntry)
dynamicHostsMu sync.RWMutex
dynamicHostsCleanerOnce sync.Once
dynamicHostCleanupInterval = 5 * time.Minute
dynamicHostIdleExpiry = time.Hour
)
// dynamicHostEntry holds a Host and the last time it was seen
// in a set of dynamic upstreams returned for a request.
type dynamicHostEntry struct {
host *Host
lastSeen time.Time
}
// dialInfoVarKey is the key used for the variable that holds
// the dial info for the upstream connection.
const dialInfoVarKey = "reverse_proxy.dial_info"
@@ -384,6 +384,9 @@ func (h *HTTPTransport) NewTransport(caddyCtx caddy.Context) (*http.Transport, e
}
// we need to keep track if a proxy is used for a request
proxyWrapper := func(req *http.Request) (*url.URL, error) {
if proxy == nil {
return nil, nil
}
u, err := proxy(req)
if u == nil || err != nil {
return u, err
@@ -412,8 +415,13 @@ func (h *HTTPTransport) NewTransport(caddyCtx caddy.Context) (*http.Transport, e
return nil, fmt.Errorf("making TLS client config: %v", err)
}
// servername has a placeholder, so we need to replace it
if strings.Contains(h.TLS.ServerName, "{") {
serverNameHasPlaceholder := strings.Contains(h.TLS.ServerName, "{")
// We need to use custom DialTLSContext if:
// 1. ServerName has a placeholder that needs to be replaced at request-time, OR
// 2. ProxyProtocol is enabled, because req.URL.Host is modified to include
// client address info with "->" separator which breaks Go's address parsing
if serverNameHasPlaceholder || h.ProxyProtocol != "" {
rt.DialTLSContext = func(ctx context.Context, network, addr string) (net.Conn, error) {
// reuses the dialer from above to establish a plaintext connection
conn, err := dialContext(ctx, network, addr)
@@ -422,9 +430,11 @@ func (h *HTTPTransport) NewTransport(caddyCtx caddy.Context) (*http.Transport, e
}
// but add our own handshake logic
repl := ctx.Value(caddy.ReplacerCtxKey).(*caddy.Replacer)
tlsConfig := rt.TLSClientConfig.Clone()
tlsConfig.ServerName = repl.ReplaceAll(tlsConfig.ServerName, "")
if serverNameHasPlaceholder {
repl := ctx.Value(caddy.ReplacerCtxKey).(*caddy.Replacer)
tlsConfig.ServerName = repl.ReplaceAll(tlsConfig.ServerName, "")
}
// h1 only
if caddyhttp.GetVar(ctx, tlsH1OnlyVarKey) == true {
@@ -438,7 +448,7 @@ func (h *HTTPTransport) NewTransport(caddyCtx caddy.Context) (*http.Transport, e
// complete the handshake before returning the connection
if rt.TLSHandshakeTimeout != 0 {
var cancel context.CancelFunc
ctx, cancel = context.WithTimeout(ctx, rt.TLSHandshakeTimeout)
ctx, cancel = context.WithTimeoutCause(ctx, rt.TLSHandshakeTimeout, fmt.Errorf("HTTP transport TLS handshake %ds timeout", int(rt.TLSHandshakeTimeout.Seconds())))
defer cancel()
}
err = tlsConn.HandshakeContext(ctx)
@@ -1,11 +1,13 @@
package reverseproxy
import (
"context"
"encoding/json"
"fmt"
"reflect"
"testing"
"github.com/caddyserver/caddy/v2"
"github.com/caddyserver/caddy/v2/caddyconfig/caddyfile"
)
@@ -115,3 +117,81 @@ func TestHTTPTransport_RequestHeaderOps_TLS(t *testing.T) {
t.Fatalf("unexpected Host value; want placeholder, got: %s", got)
}
}
// TestHTTPTransport_DialTLSContext_ProxyProtocol verifies that when TLS and
// ProxyProtocol are both enabled, DialTLSContext is set. This is critical because
// ProxyProtocol modifies req.URL.Host to include client info with "->" separator
// (e.g., "[2001:db8::1]:12345->127.0.0.1:443"), which breaks Go's address parsing.
// Without a custom DialTLSContext, Go's HTTP library would fail with
// "too many colons in address" when trying to parse the mangled host.
func TestHTTPTransport_DialTLSContext_ProxyProtocol(t *testing.T) {
ctx, cancel := caddy.NewContext(caddy.Context{Context: context.Background()})
defer cancel()
tests := []struct {
name string
tls *TLSConfig
proxyProtocol string
serverNameHasPlaceholder bool
expectDialTLSContext bool
}{
{
name: "no TLS, no proxy protocol",
tls: nil,
proxyProtocol: "",
expectDialTLSContext: false,
},
{
name: "TLS without proxy protocol",
tls: &TLSConfig{},
proxyProtocol: "",
expectDialTLSContext: false,
},
{
name: "TLS with proxy protocol v1",
tls: &TLSConfig{},
proxyProtocol: "v1",
expectDialTLSContext: true,
},
{
name: "TLS with proxy protocol v2",
tls: &TLSConfig{},
proxyProtocol: "v2",
expectDialTLSContext: true,
},
{
name: "TLS with placeholder ServerName",
tls: &TLSConfig{ServerName: "{http.request.host}"},
proxyProtocol: "",
serverNameHasPlaceholder: true,
expectDialTLSContext: true,
},
{
name: "TLS with placeholder ServerName and proxy protocol",
tls: &TLSConfig{ServerName: "{http.request.host}"},
proxyProtocol: "v2",
serverNameHasPlaceholder: true,
expectDialTLSContext: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
ht := &HTTPTransport{
TLS: tt.tls,
ProxyProtocol: tt.proxyProtocol,
}
rt, err := ht.NewTransport(ctx)
if err != nil {
t.Fatalf("NewTransport() error = %v", err)
}
hasDialTLSContext := rt.DialTLSContext != nil
if hasDialTLSContext != tt.expectDialTLSContext {
t.Errorf("DialTLSContext set = %v, want %v", hasDialTLSContext, tt.expectDialTLSContext)
}
})
}
}
@@ -0,0 +1,391 @@
// 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.
package reverseproxy
import (
"context"
"testing"
"time"
"github.com/caddyserver/caddy/v2"
)
// newPassiveHandler builds a minimal Handler with passive health checks
// configured and a live caddy.Context so the fail-forgetter goroutine can
// be cancelled cleanly. The caller must call cancel() when done.
func newPassiveHandler(t *testing.T, maxFails int, failDuration time.Duration) (*Handler, context.CancelFunc) {
t.Helper()
caddyCtx, cancel := caddy.NewContext(caddy.Context{Context: context.Background()})
h := &Handler{
ctx: caddyCtx,
HealthChecks: &HealthChecks{
Passive: &PassiveHealthChecks{
MaxFails: maxFails,
FailDuration: caddy.Duration(failDuration),
},
},
}
return h, cancel
}
// provisionedStaticUpstream creates a static upstream, registers it in the
// UsagePool, and returns a cleanup func that removes it from the pool.
func provisionedStaticUpstream(t *testing.T, h *Handler, addr string) (*Upstream, func()) {
t.Helper()
u := &Upstream{Dial: addr}
h.provisionUpstream(u, false)
return u, func() { _, _ = hosts.Delete(addr) }
}
// provisionedDynamicUpstream creates a dynamic upstream, registers it in
// dynamicHosts, and returns a cleanup func that removes it.
func provisionedDynamicUpstream(t *testing.T, h *Handler, addr string) (*Upstream, func()) {
t.Helper()
u := &Upstream{Dial: addr}
h.provisionUpstream(u, true)
return u, func() {
dynamicHostsMu.Lock()
delete(dynamicHosts, addr)
dynamicHostsMu.Unlock()
}
}
// --- countFailure behaviour ---
// TestCountFailureNoopWhenNoHealthChecks verifies that countFailure is a no-op
// when HealthChecks is nil.
func TestCountFailureNoopWhenNoHealthChecks(t *testing.T) {
resetDynamicHosts()
h := &Handler{}
u := &Upstream{Dial: "10.1.0.1:80", Host: new(Host)}
h.countFailure(u)
if u.Host.Fails() != 0 {
t.Errorf("expected 0 fails with no HealthChecks config, got %d", u.Host.Fails())
}
}
// TestCountFailureNoopWhenZeroDuration verifies that countFailure is a no-op
// when FailDuration is 0 (the zero value disables passive checks).
func TestCountFailureNoopWhenZeroDuration(t *testing.T) {
resetDynamicHosts()
caddyCtx, cancel := caddy.NewContext(caddy.Context{Context: context.Background()})
defer cancel()
h := &Handler{
ctx: caddyCtx,
HealthChecks: &HealthChecks{
Passive: &PassiveHealthChecks{MaxFails: 1, FailDuration: 0},
},
}
u := &Upstream{Dial: "10.1.0.2:80", Host: new(Host)}
h.countFailure(u)
if u.Host.Fails() != 0 {
t.Errorf("expected 0 fails with zero FailDuration, got %d", u.Host.Fails())
}
}
// TestCountFailureIncrementsCount verifies that countFailure increments the
// fail count on the upstream's Host.
func TestCountFailureIncrementsCount(t *testing.T) {
resetDynamicHosts()
h, cancel := newPassiveHandler(t, 2, time.Minute)
defer cancel()
u := &Upstream{Dial: "10.1.0.3:80", Host: new(Host)}
h.countFailure(u)
if u.Host.Fails() != 1 {
t.Errorf("expected 1 fail after countFailure, got %d", u.Host.Fails())
}
}
// TestCountFailureDecrementsAfterDuration verifies that the fail count is
// decremented back after FailDuration elapses.
func TestCountFailureDecrementsAfterDuration(t *testing.T) {
resetDynamicHosts()
const failDuration = 50 * time.Millisecond
h, cancel := newPassiveHandler(t, 2, failDuration)
defer cancel()
u := &Upstream{Dial: "10.1.0.4:80", Host: new(Host)}
h.countFailure(u)
if u.Host.Fails() != 1 {
t.Fatalf("expected 1 fail immediately after countFailure, got %d", u.Host.Fails())
}
// Wait long enough for the forgetter goroutine to fire.
time.Sleep(3 * failDuration)
if u.Host.Fails() != 0 {
t.Errorf("expected fail count to return to 0 after FailDuration, got %d", u.Host.Fails())
}
}
// TestCountFailureCancelledContextForgets verifies that cancelling the handler
// context (simulating a config unload) also triggers the forgetter to run,
// decrementing the fail count.
func TestCountFailureCancelledContextForgets(t *testing.T) {
resetDynamicHosts()
h, cancel := newPassiveHandler(t, 2, time.Hour) // very long duration
u := &Upstream{Dial: "10.1.0.5:80", Host: new(Host)}
h.countFailure(u)
if u.Host.Fails() != 1 {
t.Fatalf("expected 1 fail immediately after countFailure, got %d", u.Host.Fails())
}
// Cancelling the context should cause the forgetter goroutine to exit and
// decrement the count.
cancel()
time.Sleep(50 * time.Millisecond)
if u.Host.Fails() != 0 {
t.Errorf("expected fail count to be decremented after context cancel, got %d", u.Host.Fails())
}
}
// --- static upstream passive health check ---
// TestStaticUpstreamHealthyWithNoFailures verifies that a static upstream with
// no recorded failures is considered healthy.
func TestStaticUpstreamHealthyWithNoFailures(t *testing.T) {
resetDynamicHosts()
h, cancel := newPassiveHandler(t, 2, time.Minute)
defer cancel()
u, cleanup := provisionedStaticUpstream(t, h, "10.2.0.1:80")
defer cleanup()
if !u.Healthy() {
t.Error("upstream with no failures should be healthy")
}
}
// TestStaticUpstreamUnhealthyAtMaxFails verifies that a static upstream is
// marked unhealthy once its fail count reaches MaxFails.
func TestStaticUpstreamUnhealthyAtMaxFails(t *testing.T) {
resetDynamicHosts()
h, cancel := newPassiveHandler(t, 2, time.Minute)
defer cancel()
u, cleanup := provisionedStaticUpstream(t, h, "10.2.0.2:80")
defer cleanup()
h.countFailure(u)
if !u.Healthy() {
t.Error("upstream should still be healthy after 1 of 2 allowed failures")
}
h.countFailure(u)
if u.Healthy() {
t.Error("upstream should be unhealthy after reaching MaxFails=2")
}
}
// TestStaticUpstreamRecoversAfterFailDuration verifies that a static upstream
// returns to healthy once its failures expire.
func TestStaticUpstreamRecoversAfterFailDuration(t *testing.T) {
resetDynamicHosts()
const failDuration = 50 * time.Millisecond
h, cancel := newPassiveHandler(t, 1, failDuration)
defer cancel()
u, cleanup := provisionedStaticUpstream(t, h, "10.2.0.3:80")
defer cleanup()
h.countFailure(u)
if u.Healthy() {
t.Fatal("upstream should be unhealthy immediately after MaxFails failure")
}
time.Sleep(3 * failDuration)
if !u.Healthy() {
t.Errorf("upstream should recover to healthy after FailDuration, Fails=%d", u.Host.Fails())
}
}
// TestStaticUpstreamHealthPersistedAcrossReprovisioning verifies that static
// upstreams share a Host via the UsagePool, so a second call to provisionUpstream
// for the same address (as happens on config reload) sees the accumulated state.
func TestStaticUpstreamHealthPersistedAcrossReprovisioning(t *testing.T) {
resetDynamicHosts()
h, cancel := newPassiveHandler(t, 2, time.Minute)
defer cancel()
u1, cleanup1 := provisionedStaticUpstream(t, h, "10.2.0.4:80")
defer cleanup1()
h.countFailure(u1)
h.countFailure(u1)
// Simulate a second handler instance referencing the same upstream
// (e.g. after a config reload that keeps the same backend address).
u2, cleanup2 := provisionedStaticUpstream(t, h, "10.2.0.4:80")
defer cleanup2()
if u1.Host != u2.Host {
t.Fatal("expected both Upstream structs to share the same *Host via UsagePool")
}
if u2.Healthy() {
t.Error("re-provisioned upstream should still see the prior fail count and be unhealthy")
}
}
// --- dynamic upstream passive health check ---
// TestDynamicUpstreamHealthyWithNoFailures verifies that a freshly provisioned
// dynamic upstream is healthy.
func TestDynamicUpstreamHealthyWithNoFailures(t *testing.T) {
resetDynamicHosts()
h, cancel := newPassiveHandler(t, 2, time.Minute)
defer cancel()
u, cleanup := provisionedDynamicUpstream(t, h, "10.3.0.1:80")
defer cleanup()
if !u.Healthy() {
t.Error("dynamic upstream with no failures should be healthy")
}
}
// TestDynamicUpstreamUnhealthyAtMaxFails verifies that a dynamic upstream is
// marked unhealthy once its fail count reaches MaxFails.
func TestDynamicUpstreamUnhealthyAtMaxFails(t *testing.T) {
resetDynamicHosts()
h, cancel := newPassiveHandler(t, 2, time.Minute)
defer cancel()
u, cleanup := provisionedDynamicUpstream(t, h, "10.3.0.2:80")
defer cleanup()
h.countFailure(u)
if !u.Healthy() {
t.Error("dynamic upstream should still be healthy after 1 of 2 allowed failures")
}
h.countFailure(u)
if u.Healthy() {
t.Error("dynamic upstream should be unhealthy after reaching MaxFails=2")
}
}
// TestDynamicUpstreamFailCountPersistedBetweenRequests is the core regression
// test: it simulates two sequential (non-concurrent) requests to the same
// dynamic upstream. Before the fix, the UsagePool entry would be deleted
// between requests, wiping the fail count. Now it should survive.
func TestDynamicUpstreamFailCountPersistedBetweenRequests(t *testing.T) {
resetDynamicHosts()
h, cancel := newPassiveHandler(t, 2, time.Minute)
defer cancel()
// --- first request ---
u1 := &Upstream{Dial: "10.3.0.3:80"}
h.provisionUpstream(u1, true)
h.countFailure(u1)
if u1.Host.Fails() != 1 {
t.Fatalf("expected 1 fail after first request, got %d", u1.Host.Fails())
}
// Simulate end of first request: no delete from any pool (key difference
// vs. the old behaviour where hosts.Delete was deferred).
// --- second request: brand-new *Upstream struct, same dial address ---
u2 := &Upstream{Dial: "10.3.0.3:80"}
h.provisionUpstream(u2, true)
if u1.Host != u2.Host {
t.Fatal("expected both requests to share the same *Host pointer from dynamicHosts")
}
if u2.Host.Fails() != 1 {
t.Errorf("expected fail count to persist across requests, got %d", u2.Host.Fails())
}
// A second failure now tips it over MaxFails=2.
h.countFailure(u2)
if u2.Healthy() {
t.Error("upstream should be unhealthy after accumulated failures across requests")
}
// Cleanup.
dynamicHostsMu.Lock()
delete(dynamicHosts, "10.3.0.3:80")
dynamicHostsMu.Unlock()
}
// TestDynamicUpstreamRecoveryAfterFailDuration verifies that a dynamic
// upstream's fail count expires and it returns to healthy.
func TestDynamicUpstreamRecoveryAfterFailDuration(t *testing.T) {
resetDynamicHosts()
const failDuration = 50 * time.Millisecond
h, cancel := newPassiveHandler(t, 1, failDuration)
defer cancel()
u, cleanup := provisionedDynamicUpstream(t, h, "10.3.0.4:80")
defer cleanup()
h.countFailure(u)
if u.Healthy() {
t.Fatal("upstream should be unhealthy immediately after MaxFails failure")
}
time.Sleep(3 * failDuration)
// Re-provision (as a new request would) to get fresh *Upstream with policy set.
u2 := &Upstream{Dial: "10.3.0.4:80"}
h.provisionUpstream(u2, true)
if !u2.Healthy() {
t.Errorf("dynamic upstream should recover to healthy after FailDuration, Fails=%d", u2.Host.Fails())
}
}
// TestDynamicUpstreamMaxRequestsFromUnhealthyRequestCount verifies that
// UnhealthyRequestCount is copied into MaxRequests so Full() works correctly.
func TestDynamicUpstreamMaxRequestsFromUnhealthyRequestCount(t *testing.T) {
resetDynamicHosts()
caddyCtx, cancel := caddy.NewContext(caddy.Context{Context: context.Background()})
defer cancel()
h := &Handler{
ctx: caddyCtx,
HealthChecks: &HealthChecks{
Passive: &PassiveHealthChecks{
UnhealthyRequestCount: 3,
},
},
}
u, cleanup := provisionedDynamicUpstream(t, h, "10.3.0.5:80")
defer cleanup()
if u.MaxRequests != 3 {
t.Errorf("expected MaxRequests=3 from UnhealthyRequestCount, got %d", u.MaxRequests)
}
// Should not be full with fewer requests than the limit.
_ = u.Host.countRequest(2)
if u.Full() {
t.Error("upstream should not be full with 2 of 3 allowed requests")
}
_ = u.Host.countRequest(1)
if !u.Full() {
t.Error("upstream should be full at UnhealthyRequestCount concurrent requests")
}
}
@@ -0,0 +1,257 @@
package reverseproxy
import (
"errors"
"io"
"net"
"net/http"
"net/http/httptest"
"strings"
"sync"
"testing"
"go.uber.org/zap"
"github.com/caddyserver/caddy/v2"
"github.com/caddyserver/caddy/v2/modules/caddyhttp"
)
// prepareTestRequest injects the context values that ServeHTTP and
// proxyLoopIteration require (caddy.ReplacerCtxKey, VarsCtxKey, etc.) using
// the same helper that the real HTTP server uses.
//
// A zero-value Server is passed so that caddyhttp.ServerCtxKey is set to a
// non-nil pointer; reverseProxy dereferences it to check ShouldLogCredentials.
func prepareTestRequest(req *http.Request) *http.Request {
repl := caddy.NewReplacer()
return caddyhttp.PrepareRequest(req, repl, nil, &caddyhttp.Server{})
}
// closeOnCloseReader is an io.ReadCloser whose Close method actually makes
// subsequent reads fail, mimicking the behaviour of a real HTTP request body
// (as opposed to io.NopCloser, whose Close is a no-op and would mask the bug
// we are testing).
type closeOnCloseReader struct {
mu sync.Mutex
r *strings.Reader
closed bool
}
func newCloseOnCloseReader(s string) *closeOnCloseReader {
return &closeOnCloseReader{r: strings.NewReader(s)}
}
func (c *closeOnCloseReader) Read(p []byte) (int, error) {
c.mu.Lock()
defer c.mu.Unlock()
if c.closed {
return 0, errors.New("http: invalid Read on closed Body")
}
return c.r.Read(p)
}
func (c *closeOnCloseReader) Close() error {
c.mu.Lock()
defer c.mu.Unlock()
c.closed = true
return nil
}
// deadUpstreamAddr returns a TCP address that is guaranteed to refuse
// connections: we bind a listener, note its address, close it immediately,
// and return the address. Any dial to that address will get ECONNREFUSED.
func deadUpstreamAddr(t *testing.T) string {
t.Helper()
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("failed to create dead upstream listener: %v", err)
}
addr := ln.Addr().String()
ln.Close()
return addr
}
// testTransport wraps http.Transport to:
// 1. Set the URL scheme to "http" when it is empty (matching what
// HTTPTransport.SetScheme does in production; cloneRequest strips the
// scheme intentionally so a plain *http.Transport would fail with
// "unsupported protocol scheme").
// 2. Wrap dial errors as DialError so that tryAgain correctly identifies them
// as safe-to-retry regardless of request method (as HTTPTransport does in
// production via its custom dialer).
type testTransport struct{ *http.Transport }
func (t testTransport) RoundTrip(req *http.Request) (*http.Response, error) {
if req.URL.Scheme == "" {
req.URL.Scheme = "http"
}
resp, err := t.Transport.RoundTrip(req)
if err != nil {
// Wrap dial errors as DialError to match production behaviour.
// Without this wrapping, tryAgain treats ECONNREFUSED on a POST
// request as non-retryable (only GET is retried by default when
// the error is not a DialError).
var opErr *net.OpError
if errors.As(err, &opErr) && opErr.Op == "dial" {
return nil, DialError{err}
}
}
return resp, err
}
// minimalHandler returns a Handler with only the fields required by ServeHTTP
// set directly, bypassing Provision (which requires a full Caddy runtime).
// RoundRobinSelection is used so that successive iterations of the proxy loop
// advance through the upstream pool in a predictable order.
func minimalHandler(retries int, upstreams ...*Upstream) *Handler {
return &Handler{
logger: zap.NewNop(),
Transport: testTransport{&http.Transport{}},
Upstreams: upstreams,
LoadBalancing: &LoadBalancing{
Retries: retries,
SelectionPolicy: &RoundRobinSelection{},
// RetryMatch intentionally nil: dial errors are always retried
// regardless of RetryMatch or request method.
},
// ctx, connections, connectionsMu, events: zero/nil values are safe
// for the code paths exercised by these tests (TryInterval=0 so
// ctx.Done() is never consulted; no WebSocket hijacking; no passive
// health-check event emission).
}
}
// TestDialErrorBodyRetry verifies that a POST request whose body has NOT been
// pre-buffered via request_buffers can still be retried after a dial error.
//
// Before the fix, a dial error caused Go's transport to close the shared body
// (via cloneRequest's shallow copy), so the retry attempt would read from an
// already-closed io.ReadCloser and produce:
//
// http: invalid Read on closed Body → HTTP 502
//
// After the fix the handler wraps the body in noCloseBody when retries are
// configured, preventing the transport's Close() from propagating to the
// shared body. Since dial errors never read any bytes, the body remains at
// position 0 for the retry.
func TestDialErrorBodyRetry(t *testing.T) {
// Good upstream: echoes the request body with 200 OK.
goodServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
body, err := io.ReadAll(r.Body)
if err != nil {
http.Error(w, "read body: "+err.Error(), http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusOK)
_, _ = w.Write(body)
}))
t.Cleanup(goodServer.Close)
const requestBody = "hello, retry"
tests := []struct {
name string
method string
body string
retries int
wantStatus int
wantBody string
}{
{
// Core regression case: POST with a body, no request_buffers,
// dial error on first upstream → retry to second upstream succeeds.
name: "POST body retried after dial error",
method: http.MethodPost,
body: requestBody,
retries: 1,
wantStatus: http.StatusOK,
wantBody: requestBody,
},
{
// Dial errors are always retried regardless of method, but there
// is no body to re-read, so GET has always worked. Keep it as a
// sanity check that we did not break the no-body path.
name: "GET without body retried after dial error",
method: http.MethodGet,
body: "",
retries: 1,
wantStatus: http.StatusOK,
wantBody: "",
},
{
// Without any retry configuration the handler must give up on the
// first dial error and return a 502. Confirms no wrapping occurs
// in the no-retry path.
name: "no retries configured returns 502 on dial error",
method: http.MethodPost,
body: requestBody,
retries: 0,
wantStatus: http.StatusBadGateway,
wantBody: "",
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
dead := deadUpstreamAddr(t)
// Build the upstream pool. RoundRobinSelection starts its
// counter at 0 and increments before returning, so with a
// two-element pool it picks index 1 first, then index 0.
// Put the good upstream at index 0 and the dead one at
// index 1 so that:
// attempt 1 → pool[1] = dead → DialError (ECONNREFUSED)
// attempt 2 → pool[0] = good → 200
upstreams := []*Upstream{
{Host: new(Host), Dial: goodServer.Listener.Addr().String()},
{Host: new(Host), Dial: dead},
}
if tc.retries == 0 {
// For the "no retries" case use only the dead upstream so
// there is nowhere to retry to.
upstreams = []*Upstream{
{Host: new(Host), Dial: dead},
}
}
h := minimalHandler(tc.retries, upstreams...)
// Use closeOnCloseReader so that Close() truly prevents further
// reads, matching real http.body semantics. io.NopCloser would
// mask the bug because its Close is a no-op.
var bodyReader io.ReadCloser
if tc.body != "" {
bodyReader = newCloseOnCloseReader(tc.body)
}
req := httptest.NewRequest(tc.method, "http://example.com/", bodyReader)
if bodyReader != nil {
// httptest.NewRequest wraps the reader in NopCloser; replace
// it with our close-aware reader so Close() is propagated.
req.Body = bodyReader
req.ContentLength = int64(len(tc.body))
}
req = prepareTestRequest(req)
rec := httptest.NewRecorder()
err := h.ServeHTTP(rec, req, caddyhttp.HandlerFunc(func(w http.ResponseWriter, r *http.Request) error {
return nil
}))
// For error cases (e.g. 502) ServeHTTP returns a HandlerError
// rather than writing the status itself.
gotStatus := rec.Code
if err != nil {
if herr, ok := err.(caddyhttp.HandlerError); ok {
gotStatus = herr.StatusCode
}
}
if gotStatus != tc.wantStatus {
t.Errorf("status: got %d, want %d (err=%v)", gotStatus, tc.wantStatus, err)
}
if tc.wantBody != "" && rec.Body.String() != tc.wantBody {
t.Errorf("body: got %q, want %q", rec.Body.String(), tc.wantBody)
}
})
}
}
+77 -25
View File
@@ -32,6 +32,7 @@ import (
"strconv"
"strings"
"sync"
"sync/atomic"
"time"
"go.uber.org/zap"
@@ -46,6 +47,31 @@ import (
"github.com/caddyserver/caddy/v2/modules/caddyhttp/rewrite"
)
// inFlightRequests uses sync.Map with atomic.Int64 for lock-free updates on the hot path
var inFlightRequests sync.Map
func incInFlightRequest(address string) {
v, _ := inFlightRequests.LoadOrStore(address, new(atomic.Int64))
v.(*atomic.Int64).Add(1)
}
func decInFlightRequest(address string) {
if v, ok := inFlightRequests.Load(address); ok {
if v.(*atomic.Int64).Add(-1) <= 0 {
inFlightRequests.Delete(address)
}
}
}
func getInFlightRequests() map[string]int64 {
copyMap := make(map[string]int64)
inFlightRequests.Range(func(key, value any) bool {
copyMap[key.(string)] = value.(*atomic.Int64).Load()
return true
})
return copyMap
}
func init() {
caddy.RegisterModule(Handler{})
}
@@ -366,7 +392,7 @@ func (h *Handler) Provision(ctx caddy.Context) error {
// set up upstreams
for _, u := range h.Upstreams {
h.provisionUpstream(u)
h.provisionUpstream(u, false)
}
if h.HealthChecks != nil {
@@ -456,18 +482,31 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request, next caddyht
reqHost := clonedReq.Host
reqHeader := clonedReq.Header
// If the cloned request body was fully buffered, keep a reference to its
// buffer so we can reuse it across retries and return it to the pool
// once were done.
// When retries are configured and there is a body, wrap it in
// io.NopCloser to prevent Go's transport from closing it on dial
// errors. cloneRequest does a shallow copy, so clonedReq.Body and
// r.Body share the same io.ReadCloser — a dial-failure Close()
// would kill the original body for all subsequent retry attempts.
// The real body is closed by the HTTP server when the handler
// returns.
//
// If the body was already fully buffered (via request_buffers),
// we also extract the buffer so the retry loop can replay it
// from the beginning on each attempt. (see #6259, #7546)
var bufferedReqBody *bytes.Buffer
if reqBodyBuf, ok := clonedReq.Body.(bodyReadCloser); ok && reqBodyBuf.body == nil && reqBodyBuf.buf != nil {
bufferedReqBody = reqBodyBuf.buf
reqBodyBuf.buf = nil
defer func() {
bufferedReqBody.Reset()
bufPool.Put(bufferedReqBody)
}()
if clonedReq.Body != nil && h.LoadBalancing != nil &&
(h.LoadBalancing.Retries > 0 || h.LoadBalancing.TryDuration > 0) {
if reqBodyBuf, ok := clonedReq.Body.(bodyReadCloser); ok && reqBodyBuf.body == nil && reqBodyBuf.buf != nil {
bufferedReqBody = reqBodyBuf.buf
reqBodyBuf.buf = nil
clonedReq.Body = io.NopCloser(bytes.NewReader(bufferedReqBody.Bytes()))
defer func() {
bufferedReqBody.Reset()
bufPool.Put(bufferedReqBody)
}()
} else {
clonedReq.Body = io.NopCloser(clonedReq.Body)
}
}
start := time.Now()
@@ -537,18 +576,11 @@ func (h *Handler) proxyLoopIteration(r *http.Request, origReq *http.Request, w h
} else {
upstreams = dUpstreams
for _, dUp := range dUpstreams {
h.provisionUpstream(dUp)
h.provisionUpstream(dUp, true)
}
if c := h.logger.Check(zapcore.DebugLevel, "provisioned dynamic upstreams"); c != nil {
c.Write(zap.Int("count", len(dUpstreams)))
}
defer func() {
// these upstreams are dynamic, so they are only used for this iteration
// of the proxy loop; be sure to let them go away when we're done with them
for _, upstream := range dUpstreams {
_, _ = hosts.Delete(upstream.String())
}
}()
}
}
@@ -904,8 +936,16 @@ func (h Handler) addForwardedHeaders(req *http.Request) error {
// Go standard library which was used as the foundation.)
func (h *Handler) reverseProxy(rw http.ResponseWriter, req *http.Request, origReq *http.Request, repl *caddy.Replacer, di DialInfo, next caddyhttp.Handler) error {
_ = di.Upstream.Host.countRequest(1)
// Increment the in-flight request count
incInFlightRequest(di.Address)
//nolint:errcheck
defer di.Upstream.Host.countRequest(-1)
defer func() {
di.Upstream.Host.countRequest(-1)
// Decrement the in-flight request count
decInFlightRequest(di.Address)
}()
// point the request to this upstream
h.directRequest(req, di)
@@ -1278,16 +1318,28 @@ func (h *Handler) directRequest(req *http.Request, di DialInfo) {
// add client address to the host to let transport differentiate requests from different clients
if ppt, ok := h.Transport.(ProxyProtocolTransport); ok && ppt.ProxyProtocolEnabled() {
if proxyProtocolInfo, ok := caddyhttp.GetVar(req.Context(), proxyProtocolInfoVarKey).(ProxyProtocolInfo); ok {
reqHost = proxyProtocolInfo.AddrPort.String() + "->" + reqHost
// encode the request so it plays well with h2 transport, it's unnecessary for h1 but anyway
// The issue is that h2 transport will use the address to determine if new connections are needed
// to roundtrip requests but the without escaping, new connections are constantly created and closed until
// file descriptors are exhausted.
// see: https://github.com/caddyserver/caddy/issues/7529
reqHost = url.QueryEscape(proxyProtocolInfo.AddrPort.String() + "->" + reqHost)
}
}
req.URL.Host = reqHost
}
func (h Handler) provisionUpstream(upstream *Upstream) {
// create or get the host representation for this upstream
upstream.fillHost()
func (h Handler) provisionUpstream(upstream *Upstream, dynamic bool) {
// create or get the host representation for this upstream;
// dynamic upstreams are tracked in a separate map with last-seen
// timestamps so their health state persists across requests without
// being reference-counted (and thus discarded between requests).
if dynamic {
upstream.fillDynamicHost()
} else {
upstream.fillHost()
}
// give it the circuit breaker, if any
upstream.cb = h.CB
@@ -312,7 +312,7 @@ func (r *RoundRobinSelection) Select(pool UpstreamPool, _ *http.Request, _ http.
if n == 0 {
return nil
}
for i := uint32(0); i < n; i++ {
for range n {
robin := atomic.AddUint32(&r.robin, 1)
host := pool[robin%n]
if host.Available() {
+1 -1
View File
@@ -536,7 +536,7 @@ func maskBytes(key [4]byte, pos int, b []byte) int {
// Mask one word at a time.
n := (len(b) / wordSize) * wordSize
for i := 0; i < n; i += wordSize {
*(*uintptr)(unsafe.Pointer(uintptr(unsafe.Pointer(&b[0])) + uintptr(i))) ^= kw
*(*uintptr)(unsafe.Add(unsafe.Pointer(&b[0]), i)) ^= kw
}
// Mask one byte at a time for remaining bytes.
+1
View File
@@ -247,6 +247,7 @@ func (rewr Rewrite) Rewrite(r *http.Request, repl *caddy.Replacer) bool {
} else {
r.URL.Path = path
}
r.URL.RawPath = "" // force recomputing when EscapedPath() is called
}
if qsStart >= 0 {
r.URL.RawQuery = newQuery
@@ -224,6 +224,11 @@ func TestRewrite(t *testing.T) {
input: newRequest(t, "GET", "/foo#fragFirst?c=d"),
expect: newRequest(t, "GET", "/bar#fragFirst?c=d"),
},
{
rule: Rewrite{URI: "/api/admin/panel"},
input: newRequest(t, "GET", "/api/admin%2Fpanel"),
expect: newRequest(t, "GET", "/api/admin/panel"),
},
{
rule: Rewrite{StripPathPrefix: "/prefix"},
+26 -11
View File
@@ -97,7 +97,10 @@ type Route struct {
MatcherSets MatcherSets `json:"-"`
Handlers []MiddlewareHandler `json:"-"`
middleware []Middleware
middleware []Middleware
metrics *Metrics
metricsCtx caddy.Context
handlerName string
}
// Empty returns true if the route has all zero/default values.
@@ -162,12 +165,20 @@ func (r *Route) ProvisionHandlers(ctx caddy.Context, metrics *Metrics) error {
r.Handlers = append(r.Handlers, handler.(MiddlewareHandler))
}
// Store metrics info for route-level instrumentation (applied once
// per route in wrapRoute, instead of per-handler which was redundant).
r.metrics = metrics
r.metricsCtx = ctx
if len(r.Handlers) > 0 {
r.handlerName = caddy.GetModuleName(r.Handlers[0])
}
// Make ProvisionHandlers idempotent by clearing the middleware field
r.middleware = []Middleware{}
// pre-compile the middleware handler chain
for _, midhandler := range r.Handlers {
r.middleware = append(r.middleware, wrapMiddleware(ctx, midhandler, metrics))
r.middleware = append(r.middleware, wrapMiddleware(ctx, midhandler))
}
return nil
}
@@ -298,6 +309,16 @@ func wrapRoute(route Route) Middleware {
nextCopy = route.middleware[i](nextCopy)
}
// Apply metrics instrumentation once for the entire route,
// rather than wrapping each individual handler. This avoids
// redundant metrics collection that caused significant CPU
// overhead (see issue #4644).
if route.metrics != nil {
nextCopy = newMetricsInstrumentedRoute(
route.metricsCtx, route.handlerName, nextCopy, route.metrics,
)
}
return nextCopy.ServeHTTP(rw, req)
})
}
@@ -306,20 +327,14 @@ func wrapRoute(route Route) Middleware {
// wrapMiddleware wraps mh such that it can be correctly
// appended to a list of middleware in preparation for
// compiling into a handler chain.
func wrapMiddleware(ctx caddy.Context, mh MiddlewareHandler, metrics *Metrics) Middleware {
handlerToUse := mh
if metrics != nil {
// wrap the middleware with metrics instrumentation
handlerToUse = newMetricsInstrumentedHandler(ctx, caddy.GetModuleName(mh), mh, metrics)
}
func wrapMiddleware(ctx caddy.Context, mh MiddlewareHandler) Middleware {
return func(next Handler) Handler {
return HandlerFunc(func(w http.ResponseWriter, r *http.Request) error {
// EXPERIMENTAL: Trace each module that gets invoked
if server, ok := r.Context().Value(ServerCtxKey).(*Server); ok && server != nil {
server.logTrace(handlerToUse)
server.logTrace(mh)
}
return handlerToUse.ServeHTTP(w, r, next)
return mh.ServeHTTP(w, r, next)
})
}
}
+1 -1
View File
@@ -486,7 +486,7 @@ func (s *Server) serveHTTP(w http.ResponseWriter, r *http.Request) error {
// code to any HTTP/1.1 request message that lacks a Host header field and to any
// request message that contains more than one Host header field line or a Host
// header field with an invalid field value."
if r.Host == "" {
if r.ProtoMajor == 1 && r.ProtoMinor == 1 && r.Host == "" {
return HandlerError{
Err: errors.New("rfc9112 forbids empty Host"),
StatusCode: http.StatusBadRequest,
+10 -1
View File
@@ -312,10 +312,12 @@ func (m MatchVarsRE) MatchWithError(r *http.Request) (bool, error) {
repl := r.Context().Value(caddy.ReplacerCtxKey).(*caddy.Replacer)
for key, val := range m {
var varValue any
var fromPlaceholder bool
if strings.HasPrefix(key, "{") &&
strings.HasSuffix(key, "}") &&
strings.Count(key, "{") == 1 {
varValue, _ = repl.Get(strings.Trim(key, "{}"))
fromPlaceholder = true
} else {
varValue = vars[key]
}
@@ -334,7 +336,14 @@ func (m MatchVarsRE) MatchWithError(r *http.Request) (bool, error) {
varStr = fmt.Sprintf("%v", vv)
}
valExpanded := repl.ReplaceAll(varStr, "")
// Only expand placeholders in values from literal variable names
// (e.g. map outputs). Values resolved from placeholder keys are
// already final and must not be re-expanded, as that would allow
// user input like {env.SECRET} to be evaluated.
valExpanded := varStr
if !fromPlaceholder {
valExpanded = repl.ReplaceAll(varStr, "")
}
if match := val.Match(valExpanded, repl); match {
return match, nil
}
+14 -1
View File
@@ -40,6 +40,7 @@ import (
"github.com/caddyserver/caddy/v2"
"github.com/caddyserver/caddy/v2/modules/caddyhttp"
"github.com/caddyserver/caddy/v2/modules/caddypki"
"github.com/caddyserver/caddy/v2/modules/caddytls"
)
func init() {
@@ -304,7 +305,19 @@ func (ash Handler) openDatabase() (*db.AuthDB, error) {
// makeClient creates an ACME client which will use a custom
// resolver instead of net.DefaultResolver.
func (ash Handler) makeClient() (acme.Client, error) {
for _, v := range ash.Resolvers {
// If no local resolvers are configured, check for global resolvers from TLS app
resolversToUse := ash.Resolvers
if len(resolversToUse) == 0 {
tlsAppIface, err := ash.ctx.App("tls")
if err == nil {
tlsApp := tlsAppIface.(*caddytls.TLS)
if len(tlsApp.Resolvers) > 0 {
resolversToUse = tlsApp.Resolvers
}
}
}
for _, v := range resolversToUse {
addr, err := caddy.ParseNetworkAddressWithDefaults(v, "udp", 53)
if err != nil {
return nil, err
+56 -6
View File
@@ -243,22 +243,49 @@ func (ap *AutomationPolicy) Provision(tlsApp *TLS) error {
}
}
// build certmagic.Config and attach it to the policy
storage := ap.storage
if storage == nil {
storage = tlsApp.ctx.Storage()
}
cfg, err := ap.makeCertMagicConfig(tlsApp, issuers, storage)
if err != nil {
return err
}
certCacheMu.RLock()
ap.magic = certmagic.New(certCache, cfg)
certCacheMu.RUnlock()
// give issuers a chance to see the config pointer
for _, issuer := range ap.magic.Issuers {
if annoying, ok := issuer.(ConfigSetter); ok {
annoying.SetConfig(ap.magic)
}
}
return nil
}
// makeCertMagicConfig constructs a certmagic.Config for this policy using the
// provided issuers and storage. It encapsulates common logic shared between
// Provision and RebuildCertMagic so we don't duplicate code.
func (ap *AutomationPolicy) makeCertMagicConfig(tlsApp *TLS, issuers []certmagic.Issuer, storage certmagic.Storage) (certmagic.Config, error) {
// key source
keyType := ap.KeyType
if keyType != "" {
var err error
keyType, err = caddy.NewReplacer().ReplaceOrErr(ap.KeyType, true, true)
if err != nil {
return fmt.Errorf("invalid key type %s: %s", ap.KeyType, err)
return certmagic.Config{}, fmt.Errorf("invalid key type %s: %s", ap.KeyType, err)
}
if _, ok := supportedCertKeyTypes[keyType]; !ok {
return fmt.Errorf("unrecognized key type: %s", keyType)
return certmagic.Config{}, fmt.Errorf("unrecognized key type: %s", keyType)
}
}
keySource := certmagic.StandardKeyGenerator{
KeyType: supportedCertKeyTypes[keyType],
}
storage := ap.storage
if storage == nil {
storage = tlsApp.ctx.Storage()
}
@@ -277,7 +304,7 @@ func (ap *AutomationPolicy) Provision(tlsApp *TLS) error {
if noProtections {
if !ap.hadExplicitManagers {
// no managers, no explicitly-configured permission module, this is a config error
return fmt.Errorf("on-demand TLS cannot be enabled without a permission module to prevent abuse; please refer to documentation for details")
return certmagic.Config{}, fmt.Errorf("on-demand TLS cannot be enabled without a permission module to prevent abuse; please refer to documentation for details")
}
// allow on-demand to be enabled but only for the purpose of the Managers; issuance won't be allowed from Issuers
tlsApp.logger.Warn("on-demand TLS can only get certificates from the configured external manager(s) because no ask endpoint / permission module is specified")
@@ -334,7 +361,7 @@ func (ap *AutomationPolicy) Provision(tlsApp *TLS) error {
}
}
template := certmagic.Config{
cfg := certmagic.Config{
MustStaple: ap.MustStaple,
RenewalWindowRatio: ap.RenewalWindowRatio,
KeySource: keySource,
@@ -349,8 +376,31 @@ func (ap *AutomationPolicy) Provision(tlsApp *TLS) error {
Issuers: issuers,
Logger: tlsApp.logger,
}
return cfg, nil
}
// IsProvisioned reports whether the automation policy has been
// provisioned. A provisioned policy has an initialized CertMagic
// instance (i.e. ap.magic != nil).
func (ap *AutomationPolicy) IsProvisioned() bool { return ap.magic != nil }
// RebuildCertMagic rebuilds the policy's CertMagic configuration from the
// policy's already-populated fields (Issuers, Managers, storage, etc.) and
// replaces the internal CertMagic instance. This is a lightweight
// alternative to calling Provision because it does not re-provision
// modules or re-run module Provision; instead, it constructs a new
// certmagic.Config and calls SetConfig on issuers so they receive updated
// templates (for example, alternate HTTP/TLS ports supplied by the HTTP
// app). RebuildCertMagic should only be called when the policy's required
// fields are already populated.
func (ap *AutomationPolicy) RebuildCertMagic(tlsApp *TLS) error {
cfg, err := ap.makeCertMagicConfig(tlsApp, ap.Issuers, ap.storage)
if err != nil {
return err
}
certCacheMu.RLock()
ap.magic = certmagic.New(certCache, template)
ap.magic = certmagic.New(certCache, cfg)
certCacheMu.RUnlock()
// sometimes issuers may need the parent certmagic.Config in
+8 -1
View File
@@ -123,8 +123,15 @@ type TLS struct {
//
// EXPERIMENTAL: Subject to change.
DNSRaw json.RawMessage `json:"dns,omitempty" caddy:"namespace=dns.providers inline_key=name"`
dns any // technically, it should be any/all of the libdns interfaces (RecordSetter, RecordAppender, etc.)
// The default DNS resolvers to use for TLS-related DNS operations, specifically
// for ACME DNS challenges and ACME server DNS validations.
// If not specified, the system default resolvers will be used.
//
// EXPERIMENTAL: Subject to change.
Resolvers []string `json:"resolvers,omitempty"`
dns any // technically, it should be any/all of the libdns interfaces (RecordSetter, RecordAppender, etc.)
certificateLoaders []CertificateLoader
automateNames map[string]struct{}
ctx caddy.Context
+184 -23
View File
@@ -63,7 +63,7 @@ func (m *fileMode) UnmarshalJSON(b []byte) error {
// MarshalJSON satisfies json.Marshaler.
func (m *fileMode) MarshalJSON() ([]byte, error) {
return []byte(fmt.Sprintf("\"%04o\"", *m)), nil
return fmt.Appendf(nil, "\"%04o\"", *m), nil
}
// parseFileMode parses a file mode string,
@@ -90,6 +90,15 @@ type FileWriter struct {
// 0600 by default.
Mode fileMode `json:"mode,omitempty"`
// DirMode controls permissions for any directories created to reach Filename.
// Default: 0700 (current behavior).
//
// Special values:
// - "inherit" → copy the nearest existing parent directory's perms (with r→x normalization)
// - "from_file" → derive from the file Mode (with r→x), e.g. 0644 → 0755, 0600 → 0700
// Numeric octal strings (e.g. "0755") are also accepted. Subject to process umask.
DirMode string `json:"dir_mode,omitempty"`
// Roll toggles log rolling or rotation, which is
// enabled by default.
Roll *bool `json:"roll,omitempty"`
@@ -113,9 +122,16 @@ type FileWriter struct {
// See https://github.com/DeRuina/timberjack#%EF%B8%8F-rotation-notes--warnings for caveats
RollAt []string `json:"roll_at,omitempty"`
// Whether to compress rolled files. Default: true
// Whether to compress rolled files.
// Default: true.
// Deprecated: Use RollCompression instead, setting it to "none".
RollCompress *bool `json:"roll_gzip,omitempty"`
// RollCompression selects the compression algorithm for rolled files.
// Accepted values: "none", "gzip", "zstd".
// Default: gzip
RollCompression string `json:"roll_compression,omitempty"`
// Whether to use local timestamps in rolled filenames.
// Default: false
RollLocalTime bool `json:"roll_local_time,omitempty"`
@@ -177,11 +193,33 @@ func (fw FileWriter) OpenWriter() (io.WriteCloser, error) {
// roll log files as a sensible default to avoid disk space exhaustion
roll := fw.Roll == nil || *fw.Roll
// create the file if it does not exist; create with the configured mode, or default
// to restrictive if not set. (timberjack will reuse the file mode across log rotation)
if err := os.MkdirAll(filepath.Dir(fw.Filename), 0o700); err != nil {
return nil, err
// Ensure directory exists before opening the file.
dirPath := filepath.Dir(fw.Filename)
switch strings.ToLower(strings.TrimSpace(fw.DirMode)) {
case "", "0":
// Preserve current behavior: locked-down directories by default.
if err := os.MkdirAll(dirPath, 0o700); err != nil {
return nil, err
}
case "inherit":
if err := mkdirAllInherit(dirPath); err != nil {
return nil, err
}
case "from_file":
if err := mkdirAllFromFile(dirPath, os.FileMode(fw.Mode)); err != nil {
return nil, err
}
default:
dm, err := parseFileMode(fw.DirMode)
if err != nil {
return nil, fmt.Errorf("dir_mode: %w", err)
}
if err := os.MkdirAll(dirPath, dm); err != nil {
return nil, err
}
}
// create/open the file
file, err := os.OpenFile(fw.Filename, os.O_WRONLY|os.O_APPEND|os.O_CREATE, modeIfCreating)
if err != nil {
return nil, err
@@ -223,27 +261,104 @@ func (fw FileWriter) OpenWriter() (io.WriteCloser, error) {
if fw.RollKeepDays == 0 {
fw.RollKeepDays = 90
}
// Determine compression algorithm to use. Priority:
// 1) explicit RollCompression (none|gzip|zstd)
// 2) if RollCompress is unset or true -> gzip
// 3) if RollCompress is false -> none
var compression string
if fw.RollCompression != "" {
compression = strings.ToLower(strings.TrimSpace(fw.RollCompression))
if compression != "none" && compression != "gzip" && compression != "zstd" {
return nil, fmt.Errorf("invalid roll_compression: %s", fw.RollCompression)
}
} else {
if fw.RollCompress == nil || *fw.RollCompress {
compression = "gzip"
} else {
compression = "none"
}
}
return &timberjack.Logger{
Filename: fw.Filename,
MaxSize: fw.RollSizeMB,
MaxAge: fw.RollKeepDays,
MaxBackups: fw.RollKeep,
LocalTime: fw.RollLocalTime,
Compress: *fw.RollCompress,
Compression: compression,
RotationInterval: fw.RollInterval,
RotateAtMinutes: fw.RollAtMinutes,
RotateAt: fw.RollAt,
BackupTimeFormat: fw.BackupTimeFormat,
FileMode: os.FileMode(fw.Mode),
}, nil
}
// normalizeDirPerm ensures that read bits also have execute bits set.
func normalizeDirPerm(p os.FileMode) os.FileMode {
if p&0o400 != 0 {
p |= 0o100
}
if p&0o040 != 0 {
p |= 0o010
}
if p&0o004 != 0 {
p |= 0o001
}
return p
}
// mkdirAllInherit creates missing dirs using the nearest existing parent's
// permissions, normalized with r→x.
func mkdirAllInherit(dir string) error {
if fi, err := os.Stat(dir); err == nil && fi.IsDir() {
return nil
}
cur := dir
var parent string
for {
next := filepath.Dir(cur)
if next == cur {
parent = next
break
}
if fi, err := os.Stat(next); err == nil {
if !fi.IsDir() {
return fmt.Errorf("path component %s exists and is not a directory", next)
}
parent = next
break
}
cur = next
}
perm := os.FileMode(0o700)
if fi, err := os.Stat(parent); err == nil && fi.IsDir() {
perm = fi.Mode().Perm()
}
perm = normalizeDirPerm(perm)
return os.MkdirAll(dir, perm)
}
// mkdirAllFromFile creates missing dirs using the file's mode (with r→x) so
// 0644 → 0755, 0600 → 0700, etc.
func mkdirAllFromFile(dir string, fileMode os.FileMode) error {
if fi, err := os.Stat(dir); err == nil && fi.IsDir() {
return nil
}
perm := normalizeDirPerm(fileMode.Perm()) | 0o200 // ensure owner write on dir so files can be created
return os.MkdirAll(dir, perm)
}
// UnmarshalCaddyfile sets up the module from Caddyfile tokens. Syntax:
//
// file <filename> {
// mode <mode>
// dir_mode <mode|inherit|from_file>
// roll_disabled
// roll_size <size>
// roll_uncompressed
// roll_compression <none|gzip|zstd>
// roll_local_time
// roll_keep <num>
// roll_keep_for <days>
@@ -284,6 +399,22 @@ func (fw *FileWriter) UnmarshalCaddyfile(d *caddyfile.Dispenser) error {
}
fw.Mode = fileMode(mode)
case "dir_mode":
var val string
if !d.AllArgs(&val) {
return d.ArgErr()
}
val = strings.TrimSpace(val)
switch strings.ToLower(val) {
case "inherit", "from_file":
fw.DirMode = val
default:
if _, err := parseFileMode(val); err != nil {
return d.Errf("parsing dir_mode: %v", err)
}
fw.DirMode = val
}
case "roll_disabled":
var f bool
fw.Roll = &f
@@ -309,6 +440,19 @@ func (fw *FileWriter) UnmarshalCaddyfile(d *caddyfile.Dispenser) error {
return d.ArgErr()
}
case "roll_compression":
var comp string
if !d.AllArgs(&comp) {
return d.ArgErr()
}
comp = strings.ToLower(strings.TrimSpace(comp))
switch comp {
case "none", "gzip", "zstd":
fw.RollCompression = comp
default:
return d.Errf("parsing roll_compression: must be 'none', 'gzip' or 'zstd'")
}
case "roll_local_time":
fw.RollLocalTime = true
if d.NextArg() {
@@ -352,31 +496,48 @@ func (fw *FileWriter) UnmarshalCaddyfile(d *caddyfile.Dispenser) error {
fw.RollInterval = duration
case "roll_minutes":
var minutesArrayStr string
if !d.AllArgs(&minutesArrayStr) {
// Accept either a single comma-separated argument or
// multiple space-separated arguments. Collect all
// remaining args on the line and split on commas.
args := d.RemainingArgs()
if len(args) == 0 {
return d.ArgErr()
}
minutesStr := strings.Split(minutesArrayStr, ",")
minutes := make([]int, len(minutesStr))
for i := range minutesStr {
ms := strings.Trim(minutesStr[i], " ")
m, err := strconv.Atoi(ms)
if err != nil {
return d.Errf("parsing roll_minutes number: %v", err)
var minutes []int
for _, arg := range args {
parts := strings.SplitSeq(arg, ",")
for p := range parts {
ms := strings.TrimSpace(p)
if ms == "" {
return d.Errf("parsing roll_minutes: empty value")
}
m, err := strconv.Atoi(ms)
if err != nil {
return d.Errf("parsing roll_minutes number: %v", err)
}
minutes = append(minutes, m)
}
minutes[i] = m
}
fw.RollAtMinutes = minutes
case "roll_at":
var timeArrayStr string
if !d.AllArgs(&timeArrayStr) {
// Accept either a single comma-separated argument or
// multiple space-separated arguments. Collect all
// remaining args on the line and split on commas.
args := d.RemainingArgs()
if len(args) == 0 {
return d.ArgErr()
}
timeStr := strings.Split(timeArrayStr, ",")
times := make([]string, len(timeStr))
for i := range timeStr {
times[i] = strings.Trim(timeStr[i], " ")
var times []string
for _, arg := range args {
parts := strings.SplitSeq(arg, ",")
for p := range parts {
ts := strings.TrimSpace(p)
if ts == "" {
return d.Errf("parsing roll_at: empty value")
}
times = append(times, ts)
}
}
fw.RollAt = times
+222
View File
@@ -385,3 +385,225 @@ func TestFileModeModification(t *testing.T) {
t.Errorf("file mode is %v, want %v", st.Mode(), want)
}
}
func TestDirMode_Inherit(t *testing.T) {
m := syscall.Umask(0)
defer syscall.Umask(m)
parent := t.TempDir()
if err := os.Chmod(parent, 0o755); err != nil {
t.Fatal(err)
}
targetDir := filepath.Join(parent, "a", "b")
fw := &FileWriter{
Filename: filepath.Join(targetDir, "test.log"),
DirMode: "inherit",
Mode: 0o640,
Roll: func() *bool { f := false; return &f }(),
}
w, err := fw.OpenWriter()
if err != nil {
t.Fatal(err)
}
_ = w.Close()
st, err := os.Stat(targetDir)
if err != nil {
t.Fatal(err)
}
if got := st.Mode().Perm(); got != 0o755 {
t.Fatalf("dir perm = %o, want 0755", got)
}
}
func TestDirMode_FromFile(t *testing.T) {
m := syscall.Umask(0)
defer syscall.Umask(m)
base := t.TempDir()
dir1 := filepath.Join(base, "logs1")
fw1 := &FileWriter{
Filename: filepath.Join(dir1, "app.log"),
DirMode: "from_file",
Mode: 0o644, // => dir 0755
Roll: func() *bool { f := false; return &f }(),
}
w1, err := fw1.OpenWriter()
if err != nil {
t.Fatal(err)
}
_ = w1.Close()
st1, err := os.Stat(dir1)
if err != nil {
t.Fatal(err)
}
if got := st1.Mode().Perm(); got != 0o755 {
t.Fatalf("dir perm = %o, want 0755", got)
}
dir2 := filepath.Join(base, "logs2")
fw2 := &FileWriter{
Filename: filepath.Join(dir2, "app.log"),
DirMode: "from_file",
Mode: 0o600, // => dir 0700
Roll: func() *bool { f := false; return &f }(),
}
w2, err := fw2.OpenWriter()
if err != nil {
t.Fatal(err)
}
_ = w2.Close()
st2, err := os.Stat(dir2)
if err != nil {
t.Fatal(err)
}
if got := st2.Mode().Perm(); got != 0o700 {
t.Fatalf("dir perm = %o, want 0700", got)
}
}
func TestDirMode_ExplicitOctal(t *testing.T) {
m := syscall.Umask(0)
defer syscall.Umask(m)
base := t.TempDir()
dest := filepath.Join(base, "logs3")
fw := &FileWriter{
Filename: filepath.Join(dest, "app.log"),
DirMode: "0750",
Mode: 0o640,
Roll: func() *bool { f := false; return &f }(),
}
w, err := fw.OpenWriter()
if err != nil {
t.Fatal(err)
}
_ = w.Close()
st, err := os.Stat(dest)
if err != nil {
t.Fatal(err)
}
if got := st.Mode().Perm(); got != 0o750 {
t.Fatalf("dir perm = %o, want 0750", got)
}
}
func TestDirMode_Default0700(t *testing.T) {
m := syscall.Umask(0)
defer syscall.Umask(m)
base := t.TempDir()
dest := filepath.Join(base, "logs4")
fw := &FileWriter{
Filename: filepath.Join(dest, "app.log"),
Mode: 0o640,
Roll: func() *bool { f := false; return &f }(),
}
w, err := fw.OpenWriter()
if err != nil {
t.Fatal(err)
}
_ = w.Close()
st, err := os.Stat(dest)
if err != nil {
t.Fatal(err)
}
if got := st.Mode().Perm(); got != 0o700 {
t.Fatalf("dir perm = %o, want 0700", got)
}
}
func TestDirMode_UmaskInteraction(t *testing.T) {
_ = syscall.Umask(0o022) // typical umask; restore after
defer syscall.Umask(0)
base := t.TempDir()
dest := filepath.Join(base, "logs5")
fw := &FileWriter{
Filename: filepath.Join(dest, "app.log"),
DirMode: "0755",
Mode: 0o644,
Roll: func() *bool { f := false; return &f }(),
}
w, err := fw.OpenWriter()
if err != nil {
t.Fatal(err)
}
_ = w.Close()
st, err := os.Stat(dest)
if err != nil {
t.Fatal(err)
}
// 0755 &^ 0022 still 0755 for dirs; this just sanity-checks we didn't get stricter unexpectedly
if got := st.Mode().Perm(); got != 0o755 {
t.Fatalf("dir perm = %o, want 0755 (considering umask)", got)
}
}
func TestCaddyfile_DirMode_Inherit(t *testing.T) {
d := caddyfile.NewTestDispenser(`
file /var/log/app.log {
dir_mode inherit
mode 0640
}`)
var fw FileWriter
if err := fw.UnmarshalCaddyfile(d); err != nil {
t.Fatal(err)
}
if fw.DirMode != "inherit" {
t.Fatalf("got %q", fw.DirMode)
}
if fw.Mode != 0o640 {
t.Fatalf("mode = %o", fw.Mode)
}
}
func TestCaddyfile_DirMode_FromFile(t *testing.T) {
d := caddyfile.NewTestDispenser(`
file /var/log/app.log {
dir_mode from_file
mode 0600
}`)
var fw FileWriter
if err := fw.UnmarshalCaddyfile(d); err != nil {
t.Fatal(err)
}
if fw.DirMode != "from_file" {
t.Fatalf("got %q", fw.DirMode)
}
if fw.Mode != 0o600 {
t.Fatalf("mode = %o", fw.Mode)
}
}
func TestCaddyfile_DirMode_Octal(t *testing.T) {
d := caddyfile.NewTestDispenser(`
file /var/log/app.log {
dir_mode 0755
}`)
var fw FileWriter
if err := fw.UnmarshalCaddyfile(d); err != nil {
t.Fatal(err)
}
if fw.DirMode != "0755" {
t.Fatalf("got %q", fw.DirMode)
}
}
func TestCaddyfile_DirMode_Invalid(t *testing.T) {
d := caddyfile.NewTestDispenser(`
file /var/log/app.log {
dir_mode nope
}`)
var fw FileWriter
if err := fw.UnmarshalCaddyfile(d); err == nil {
t.Fatal("expected error for invalid dir_mode")
}
}
@@ -53,3 +53,41 @@ func TestFileCreationMode(t *testing.T) {
t.Fatalf("file mode is %v, want rw for user", st.Mode().Perm())
}
}
func TestDirMode_Windows_CreateSucceeds(t *testing.T) {
dir, err := os.MkdirTemp("", "caddytest")
if err != nil {
t.Fatalf("failed to create tempdir: %v", err)
}
defer os.RemoveAll(dir)
tests := []struct {
name string
dirMode string
}{
{"inherit", "inherit"},
{"from_file", "from_file"},
{"octal", "0755"},
{"default", ""},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
subdir := path.Join(dir, "logs-"+tt.name)
fw := &FileWriter{
Filename: path.Join(subdir, "test.log"),
DirMode: tt.dirMode,
Mode: 0o600,
}
w, err := fw.OpenWriter()
if err != nil {
t.Fatalf("failed to open writer: %v", err)
}
defer w.Close()
if _, err := os.Stat(fw.Filename); err != nil {
t.Fatalf("expected file to exist: %v", err)
}
})
}
}