Files
caddy/modules/caddyhttp/app.go
c7b36dc55e caddyhttp, reverseproxy: experimental WebTransport passthrough (#7669)
* caddy: pass ListenQUIC options as a struct

Collapse the TLS config, packet-conn wrappers, and Allow0RTT arguments
into ListenQUICOptions so the signature matches Listen's ctx/portOffset/
ListenConfig prefix. Zero-value opts keep the historical default of
allowing 0-RTT. The API remains experimental.

* caddyhttp: advertise WebTransport in HTTP/3 SETTINGS

Add github.com/quic-go/webtransport-go dep (built on quic-go, same
maintainers) and call webtransport.ConfigureHTTP3Server on the
http3.Server. This advertises WebTransport enablement in SETTINGS,
enables HTTP/3 DATAGRAMs, and stashes the *quic.Conn in each request's
context — a prerequisite for a later WebTransport-aware handler or
reverse-proxy transport to call webtransport.Server.Upgrade. Also
enable QUIC stream reset partial delivery, required by webtransport-go.

No user-visible behavior change: clients that don't speak WebTransport
ignore the extra SETTINGS, and no handler yet calls Upgrade.

Extract the http3.Server construction into buildHTTP3Server so the
SETTINGS assertions can be unit-tested without a live UDP listener.

* caddyhttp: add UnwrapResponseWriterAs helper

Go's type assertion `x.(T)` does not follow Unwrap() http.ResponseWriter
chains. Caddy wraps the writer multiple times (logging recorder,
intercept, encode, etc.), so code that needs interfaces implemented only
by the raw writer owned by the HTTP server — for example the
http3.Settingser/HTTPStreamer interfaces that webtransport.Server.Upgrade
type-asserts — cannot see through those wrappers.

UnwrapResponseWriterAs walks the Unwrap() chain and returns the first
writer that satisfies the requested interface (or the zero value if none
do). Mirrors the traversal http.ResponseController performs internally.

Used by upcoming WebTransport handler and reverse-proxy transport.

* caddyhttp: add terminating WebTransport handler

Introduces http.handlers.webtransport, an EXPERIMENTAL handler that
terminates an incoming WebTransport session on top of Caddy's HTTP/3
server and echoes bytes on each bidirectional stream. Primary use case
is as a test upstream for the forthcoming WebTransport reverse-proxy
transport; it also serves as the minimal proof that the server-side
WebTransport wiring works end-to-end.

Plumbing changes:

  * caddyhttp.Server gains a *webtransport.Server field alongside
    h3server. It's built in buildWebTransportServer(), wrapping the
    existing http3.Server. Exposed via WebTransportServer() any on the
    Server, so the caddyhttp public API does not name the upstream
    webtransport-go type (per AGENTS.md).

  * serveHTTP3 now runs a custom accept loop (serveH3AcceptLoop) that
    dispatches each accepted QUIC connection to
    webtransport.Server.ServeQUICConn instead of
    http3.Server.ServeListener. The WebTransport server transparently
    forwards non-WT streams to the underlying http3 request handler
    (cost: one varint peek per stream), so behavior for non-WT clients
    is unchanged.

  * ListenQUIC enables EnableDatagrams and
    EnableStreamResetPartialDelivery on the QUIC listener config.
    These are capability bits negotiated during the QUIC handshake and
    are prerequisites for any WebTransport session; they do not force
    usage so non-WT H3 traffic is unaffected.

  * Stop path closes wtServer after h3server.Shutdown to clean up any
    remaining WebTransport session state.

The handler uses caddyhttp.UnwrapResponseWriterAs to reach the naked
http3.Settingser/HTTPStreamer writer through Caddy's wrapping chain
before calling webtransport.Server.Upgrade.

Includes unit tests for request-shape detection plus an integration
test (caddytest/integration/webtransport_test.go) that spins up a
Caddy HTTP/3 server with the handler, dials it with a real
webtransport.Dialer, and asserts end-to-end bidirectional-stream
echo.

* reverseproxy: add WebTransport upstream dialer helper

dialUpstreamWebTransport is a thin wrapper around webtransport.Dialer.Dial
that sets the QUIC config flags WebTransport requires (EnableDatagrams,
EnableStreamResetPartialDelivery) and forwards request headers on the
Extended CONNECT. Intended as an internal building block for the
upcoming WebTransport reverse-proxy transport; not yet wired into
ServeHTTP.

Unit-tested against an in-process webtransport.Server with a freshly
minted self-signed certificate. Covers: successful dial, header
forwarding, and connection-refused against an unbound port.

* reverseproxy: add WebTransport session pump

runWebTransportPump bridges two WebTransport sessions so every
bidirectional stream, unidirectional stream, and datagram opened on one
side is mirrored on the other. Uses six goroutines (bidi both ways, uni
both ways, datagrams both ways) and blocks until both sessions end.

Close propagation: when either session ends, the peer is closed via
CloseWithError. The code/message are read from the closing session's
stored close state (by probing AcceptStream with a short timeout),
since Receive{Datagram,UniStream} return the underlying stream error
rather than the SessionError and can win the propagation race. Close
propagation is best-effort for client-initiated close through a
Dialer-dedicated QUIC conn: webtransport-go tears down the QUIC
connection immediately after CloseWithError, so the upstream may
observe a QUIC ApplicationError before the WT_CLOSE_SESSION capsule is
parsed. The pump still closes the peer session; only the specific
error code may not survive.

Not yet wired into ServeHTTP.

Tests: topology of client -> frontend -> upstream where frontend runs
the pump. Exercises bidi both ways, uni client-to-upstream, datagram
round-trip, CloseWithError propagation both ways, and a basic
goroutine-leak check.

* reverseproxy: wire WebTransport pump into ServeHTTP

Extends the http reverse-proxy transport with a webtransport boolean
that opts the upstream into WebTransport passthrough. Must be combined
with versions: ["3"]; WebTransport rides on HTTP/3 exclusively.

When enabled, Handler.ServeHTTP detects Extended CONNECT with
:protocol=webtransport early — before any of the normal round-trip
machinery — and branches to serveWebTransport, which:

  1. Pulls the *webtransport.Server off caddyhttp.Server (via
     WebTransportServer()) and errors out cleanly if HTTP/3 isn't
     enabled on the frontend.
  2. Picks a single upstream through the configured load-balancer.
     No retries: a failed dial closes the client session and returns.
  3. Walks the response-writer Unwrap() chain to reach the raw http3
     writer and calls webtransport.Server.Upgrade to terminate the
     incoming session.
  4. Uses dialUpstreamWebTransport to open a session to the selected
     upstream, forwarding request headers on the Extended CONNECT.
  5. Runs runWebTransportPump between the two sessions and blocks
     until both close.

The transport's wtTLSConfig is built at Provision time from the
existing TLS config (same path h3Transport already uses) and reused
for every session.

Tests: adds TestWebTransport_ReverseProxyEndToEnd which spins up a
single Caddy instance with two HTTP/3 servers — one proxy on :9443,
one terminating echo upstream on :9444 — and drives a real
webtransport.Dialer through the proxy to assert end-to-end
bidirectional-stream echo.

* reverseproxy: add webtransport Caddyfile subdirective

Adds a `webtransport` subdirective to the `transport http {}` block of
reverse_proxy that sets the new WebTransport bool on the transport.
Takes no arguments; exclusivity with versions 3 is enforced at
Provision time so parse order doesn't matter.

Example:
    reverse_proxy https://backend:9443 {
        transport http {
            versions 3
            webtransport
            tls_insecure_skip_verify
        }
    }

Includes a Caddyfile-to-JSON adapt test round-tripping the new
subdirective.

* reverseproxy: apply standard request preparation to WebTransport CONNECT

The WebTransport proxy path previously bypassed the request-preparation
pipeline that normal reverse-proxy traffic runs through. Reuse it so
`header_up`, `X-Forwarded-For`/`Host`/`Proto`, `Via`, `Rewrite`, the
`{http.reverse_proxy.upstream.*}` placeholders, dynamic upstreams,
`countFailure`, and the `{http.reverse_proxy.duration{_ms}}` timing
placeholder all behave the same as on the regular path.

Retries, `handle_response`, and response-header ops are intentionally
not run here — a WebTransport session has no HTTP response body to
post-process and is not idempotent. Integration test exercises the
header-forwarding contract end-to-end through a standalone (non-Caddy)
WebTransport upstream so the forwarded Extended CONNECT can be
inspected.

* reverseproxy: dial WebTransport upstream before upgrading client

Reorder serveWebTransport so the upstream is dialed first. If the
upstream is unreachable or refuses the CONNECT, a proper 5xx is returned
from the handler — the client's Dial() surfaces the real status instead
of a successful upgrade followed by an opaque session close.

Also apply `h.Headers.Response` (gated by `Require`, if configured)
against the upstream response status/headers; the ops run on the
client-visible response headers, which webtransport.Server.Upgrade
flushes with the 200 OK. If the client-side upgrade fails after the
upstream dial succeeded, close the upstream session cleanly.

Integration test drives a dial to an unbound loopback port and asserts
the client sees a 5xx status instead of a bare session close.

* reverseproxy: track WebTransport sessions in upstream in-flight counters

Bracket the pump's lifetime with Host.countRequest(±1) and
incInFlightRequest/decInFlightRequest so WT sessions participate in the
same accounting as the normal proxy path:

- MaxRequests gating (Upstream.Full) now blocks WT sessions past the
  cap, instead of silently failing open.
- LeastConn / FirstAvailable selection sees WT load, instead of seeing
  busy upstreams as idle.
- Admin /reverse_proxy/upstreams reports WT sessions under num_requests.

Integration test holds an upstream session open via a standalone WT
server, polls the admin API to assert num_requests increments during
the session and drops back to 0 after close.

* reverseproxy: extract shared upstream-selection helpers

The WebTransport proxy path in serveWebTransport duplicated the
dynamic-upstream-fallback block and the {http.reverse_proxy.upstream.*}
replacer-variable block from proxyLoopIteration. Francis flagged this
as a maintenance burden in review of #7669.

Extract two helpers:

  * resolveUpstreams(r) returns the candidate upstream set — dynamic
    when configured (with provisioning + fallback-on-error), static
    otherwise. Caller runs the LB selection policy, since the two call
    sites diverge on how selection failure is reported (retry loop vs.
    fast 502 for long-lived WT sessions).

  * setUpstreamReplacerVars(repl, up, di) publishes the seven
    placeholders describing the selected upstream.

Both are used by proxyLoopIteration and serveWebTransport with
identical semantics to the inlined code they replace. No behavior
change for either path.

* reverseproxy: inline WebTransport protocol const and writer interface

Francis pointed out in review of #7669 that importing the whole
modules/caddyhttp/webtransport package solely to pull in one constant
and one interface wasn't worthwhile.

Move both into webtransport_transport.go as unexported identifiers
(webtransportProtocol, webtransportWriter). This removes reverseproxy's
dependency on the caddywt package and clears the way for moving the
echo handler itself out of the production module tree.

No behavior change.

* caddyhttp: move WebTransport echo handler to integration tests

Francis pointed out in review of #7669 that the echo handler — which
exists solely as a test upstream for the WebTransport reverse-proxy
tests — should not be a full-fledged module registered in every Caddy
binary. Mirroring the mockdns_test.go pattern, move it into a _test.go
file under caddytest/integration/.

The module ID http.handlers.webtransport is now registered only when
the integration test binary is built, which is when
caddytest/integration/webtransport_test.go references it by ID string
in its JSON configs. Production Caddy builds no longer include it.

Changes:
  * New file: caddytest/integration/webtransport_echo_test.go —
    contains the WebTransportEcho handler, its types and interface
    guards, the isWebTransportEchoUpgrade helper, and the unit tests
    that used to live in the deleted package's handler_test.go.
  * Deleted: modules/caddyhttp/webtransport/ (handler.go + handler_test.go).
  * Removed the blank import from modules/caddyhttp/standard/imports.go.

The Protocol const and Writer interface that this package used to
export were inlined into reverseproxy's own files in a preceding
commit, so nothing else depends on the deleted package.

* caddyhttp, reverseproxy: gate WebTransport behind enable_webtransport server flag

steadytao raised an architectural concern in review of #7669: the PR
put experimental WebTransport handling directly into Caddy's core
HTTP/3 accept path, so every HTTP/3 deployment paid for the feature
whether or not they used it.

Collapse the enablement surface to a single server-level opt-in that
matches Caddy's existing precedent for protocol-level features
(`protocols`, `allow_0rtt`, `enable_full_duplex`), and detect the
request shape at the handler the same way `reverse_proxy` detects a
WebSocket upgrade today — no per-handler config flag.

Core HTTP/3 path changes (modules/caddyhttp/server.go):
  * New `EnableWebTransport bool` field on Server, marked EXPERIMENTAL.
  * buildHTTP3Server now only calls webtransport.ConfigureHTTP3Server
    and sets EnableStreamResetPartialDelivery when the flag is true.
    When false, the constructed http3.Server is bit-for-bit identical
    to the pre-WebTransport implementation.
  * wtServer is constructed only when the flag is true.
  * serveH3AcceptLoop falls back to http3.Server.ServeListener when
    the flag is false — no varint peek, no per-connection dispatch.

Caddyfile wiring (caddyconfig/httpcaddyfile/serveroptions.go):
  * New `enable_webtransport` global server option, modeled on
    `enable_full_duplex`.

Reverse-proxy simplifications (modules/caddyhttp/reverseproxy/):
  * Removed HTTPTransport.WebTransport field and its Provision-time
    exclusivity check (no longer needed; H3 is validated separately).
  * Removed the `webtransport` Caddyfile subdirective under
    `transport http { }` — this neutralizes the prior commit that
    introduced it.
  * Removed Handler.webtransportEnabled cache. ServeHTTP now branches
    on isWebTransportExtendedConnect(r) alone, matching how the
    WebSocket upgrade branch works.
  * serveWebTransport gains fail-fast guards with clear errors when
    the parent server has enable_webtransport=false or when the
    handler's transport does not include HTTP/3.

Tests:
  * Existing TestServer_BuildHTTP3ServerEnablesWebTransport now sets
    EnableWebTransport=true explicitly; new
    TestServer_BuildHTTP3ServerWithoutWebTransport locks in the
    regression guard that flag-off produces the pre-PR http3.Server.
  * Integration tests updated: enable_webtransport: true added to
    every H3 server block; "webtransport": true dropped from the
    reverse_proxy transport JSON (auto-detected now).
  * Caddyfile adapt test for the deleted `webtransport` subdirective
    is removed; `enable_webtransport` is added to the existing
    global_server_options_single adapt test alongside its peers.

No runtime behavior change when enable_webtransport is false. Diff
against master on the core HTTP/3 path is effectively zero in that
configuration.

* caddyhttp: micro-benchmark HTTP/3 server construction with/without WebTransport

Adds a hermetic pair of benchmarks on buildHTTP3Server to provide
quantitative evidence for the claim that deployments with
enable_webtransport=false pay no cost for the WebTransport feature.

Results on Apple M4, go1.25, -count=5:

  BenchmarkBuildHTTP3Server_WebTransportOff   ~70 ns/op   392 B/op   3 allocs/op
  BenchmarkBuildHTTP3Server_WebTransportOn   ~144 ns/op   600 B/op   6 allocs/op

The Off path is about half the cost on every dimension, confirming
that the work skipped when the flag is false is the
webtransport.ConfigureHTTP3Server call plus EnableStreamResetPartialDelivery.
Absolute cost is a one-time per-server setup so either branch is
negligible in practice, but the asymmetry locks in a regression guard:
a future refactor that accidentally re-enables the WT configuration
unconditionally would show up as a jump in the Off numbers.

This benchmark does not exercise the per-stream dispatch cost inside
webtransport.Server.ServeQUICConn — that would require a full QUIC
setup to measure in isolation and is follow-up work.

* reverseproxy: collapse WebTransport handler into main proxy loop

The WT path duplicated upstream resolution, LB selection, header ops,
replacer vars, and in-flight counters. Route WT through the shared
ServeHTTP -> proxyLoopIteration -> reverseProxy flow and swap RoundTrip
for a small webTransportHijack that only does WT-specific work (writer
unwrap, upstream dial, client upgrade, pump).

Rename roundtripSucceededError -> terminalError. The existing name
described when it was emitted (after a successful round-trip); the
new name describes its contract with the retry loop (stop looping,
propagate error unchanged). The WebTransport upgrade case is a second
natural caller for that same signal.

Comes with two behavior improvements that fall out of the collapse:
  - WT upstream dial failures now surface as DialError, so the loop
    can fail over across upstreams like normal proxies (today: 502).
  - Passive health checks apply to WT dials (dial-failure countFailure
    and UnhealthyLatency on dial duration) via the shared path.

Addresses reviewer feedback that the duplicated setup was a
maintenance risk.

* reverseproxy: inline upstream-resolution helpers back into the proxy loop

The earlier #7669 revision extracted resolveUpstreams and
setUpstreamReplacerVars to share between the WebTransport path and the
normal proxy path. A later commit collapsed the WebTransport path into
the main proxy loop, so the helpers became single-callers — they no
longer earn their abstraction.

Inline both back into proxyLoopIteration. The dynamic-upstream block
absorbs the CachingUpstreamSource cache-reset logic added by #7662,
which was preserved through the rebase.

No behavior change.

* caddyhttp: clarify WebTransportServer doc covers both disabled paths

WebTransportServer returns nil for two distinct configurations:
EnableWebTransport=false on the server, and HTTP/3 not in use. The
prior doc only mentioned the latter. Spell out both, per review.

* reverseproxy: drop nil-logger guard in runWebTransportPump

Production and test callers both pass a non-nil *zap.Logger. The
defensive nil check silently swapped in a no-op logger, which can mask
a real bug at the call site. Document the precondition in the doc
comment instead, per review.

* reverseproxy: use sync.WaitGroup.Go in WebTransport pump

Replace the wg.Add(6) + six "go func() { defer wg.Done(); fn() }()"
calls with sync.WaitGroup.Go (Go 1.25+, the project's minimum
version). Same number of goroutines; less boilerplate.

* caddyhttp: drop defensive checks in UnwrapResponseWriterAs

Per review, the nil-pointer guard and the next == w self-reference
guard defended against shapes that no in-tree ResponseWriter wrapper
produces. Drop both, simplifying the loop. Also remove the
self-reference test that exercised the now-removed guard.

* reverseproxy: use r.URL.RequestURI() to build the WebTransport upstream URL

Replace buildWebTransportUpstreamURL with an inline call to
r.URL.RequestURI() at the single call site. RequestURI handles the
empty-path → "/" fixup and query suffixing already, and uses
EscapedPath so encoded path segments (e.g. %2F) are preserved on the
upstream — fixing a small correctness gap where the previous helper
read r.URL.Path (decoded).

Aligns with how Caddy's normal proxy path lets stdlib render the
outbound request line via URL.RequestURI semantics.

* reverseproxy: use wg.Go in spliceBidi

* reverseproxy: expand SNI placeholders on the WebTransport upstream dial

The normal HTTP/3 path expands placeholders in tls_server_name at dial
time via a custom h3Transport.Dial hook (#7737). The WebTransport path
dials through its own webtransport.Dialer and bypasses that hook, so a
placeholder like {http.request.host} reached the QUIC ClientHello
literally. Expand it in webTransportHijack before dialing, cloning the
shared TLS config so concurrent sessions are unaffected.

Adds an integration test asserting the upstream observes the resolved
SNI rather than the literal "{...}" string.

* reverseproxy: bump webtransport-go to v0.11.0 (WebTransport draft-15)

webtransport-go v0.11.0 implements draft-15, which renames the Extended
CONNECT :protocol token from "webtransport" to "webtransport-h3" (the
server keeps accepting the legacy token for older clients). Detect both
tokens in the reverse-proxy upgrade check and in the test echo handler.

Pulls quic-go v0.59.1 -> v0.60.0, required by webtransport-go v0.11.0;
its only breaking change is requiring Go 1.25 (already met), with no API
changes affecting Caddy core. Also picks up the v0.11.0 fix for a
stream-close race on concurrent session close, relevant to the long-lived
pump.

* caddyhttp: log WebTransport QUIC connection serve errors

* caddy: gate QUIC WebTransport capabilities behind an explicit ListenQUIC parameter

* caddyhttp: fall back to plain HTTP/3 when a reused QUIC listener lacks WebTransport

* caddyhttp: enable WebTransport via a webtransport config object

Replace the enable_webtransport bool with a presence-gated
WebTransportConfig object so later options can be added without
renaming the key. JSON is "webtransport": {}, Caddyfile is
webtransport (optional empty block).

* reverseproxy: use origReq for the client WebTransport upgrade

Upgrade is the client-facing session: CheckOrigin and the CONNECT
shape must come from the downstream request. The upstream-directed
clone remains used for the dial URL, header_up, and SNI expansion.

* reverseproxy: relay WebTransport application protocol negotiation

Parse WT-Available-Protocols from the downstream request, set it on
the upstream Dialer, and copy WT-Protocol from the upstream 200 onto
the client response. The shared webtransport.Server does not select
a protocol of its own, so MOQ/MediaMTX clients receive the upstream's
choice instead of WT_ALPN_ERROR.

* caddytest: skip origin check on standalone WebTransport backends

header_up Host rewrites used by proxy tests would 400 the upstream
Upgrade via CheckOrigin. Test backends are not browsers.

* caddytest: cover MediaMTX Host-rewrite + moqt-19 through the WT proxy

Both of elee's #7669 reports fire together: header_up rewrites Host
(Upgrade must use origReq) and the client offers moqt-19 (the proxy
must relay WT-Available-Protocols / WT-Protocol). A bidi echo after
negotiation proves the session is usable, not just headers.

* caddyhttp: break self-referential Unwrap loops

A wrapper whose Unwrap returns itself would spin forever. Restore the
cycle-break mholt applied on #7669, without bringing back the nil-pointer
guard Weidi asked to drop.

* caddyhttp: close WebTransport sessions before HTTP/3 shutdown

ServeQUICConn waits for active WT sessions, and with the default
eternal grace period HTTP/3 Shutdown would hang until every client
disconnected. Close the WebTransport server first so stop/reload
can drain.

* caddyhttp: serve plain HTTP/3 when a reused listener lacks WebTransport

A pooled QUIC listener created without DATAGRAMs cannot upgrade WT
sessions. Falling back on the WT-configured http3.Server still
advertised SETTINGS the socket cannot honor. Build a separate plain
HTTP/3 server for that listener and assert it omits WT SETTINGS.

* caddyhttp: visit Unwrap writers by pointer identity

Comparing writers with == panics on uncomparable types and only
catches a one-node cycle. Track pointer identity, bound the walk,
and add tests for an uncomparable self-unwrap and an A→B→A cycle.

RecordHijackedStatus lets Upgrade write through the naked HTTP/3
writer while the access-log recorder still observes the 200.

* reverseproxy: honor prepared Host, header_down, and WT bookkeeping

Dialer.Dial always sets Host from the URL, so header_up Host never
reached the upstream. Dial the real address via DialAddr and put
the prepared Host on the CONNECT.

Parse WT-Available-Protocols from the prepared request so header_up
applies to negotiation. Copy filtered upstream response headers
before header_down, record the hijacked 200 for access logs, and
run the same latency / circuit-breaker / passive-health path as
a normal round-trip.

* caddytest: cover WT reload, access-log status, Host, and header ops

Reload with an eternal grace period must close an in-flight session.
Access logs must record 200 after Upgrade. header_up Host and
WT-Available-Protocols must reach the upstream; header_down and
non-hop upstream headers must reach the client.

* reverseproxy: build WebTransport against quic-go v0.63

webtransport-go v0.13.0 renames Dialer to Transport. v0.11.1 does not
compile against quic-go v0.63.

* reverseproxy: close the upstream body when a WebTransport dial fails

Dial can return a response with an error when the peer rejects the
CONNECT. Close that body before the error is returned.

* caddyhttp: close WebTransport sessions without closing the HTTP/3 server

webtransport.Server.Close also closes the shared HTTP/3 server and aborts
every QUIC connection before the grace period applies. Close the registered
sessions, then let HTTP/3 shutdown drain ordinary requests.

* chore: modernise

Signed-off-by: Zen Dodd <mail@steadytao.com>

* reverseproxy: preserve WT-Available-Protocols across upstream retries

The proxy loop reuses the request and only recopies headers when header
ops are configured. Deleting the offer on that map hid it from the next
upstream.

* caddyhttp: close WebTransport sessions when the grace period ends

Eternal grace leaves an active session until the peer ends it. A
configured grace period closes it at the deadline, while the socket
is still up, so the close capsule can be delivered.

* reverseproxy: use terminalError when refusing an Incremental response

The proxy-loop collapse renamed roundtripSucceededError to terminalError.
Master's Incremental refusal still returned the old type, so the rebased
package did not compile.

* caddyhttp: don't let idle HTTP/3 conns pin WebTransport shutdown

- Count in-flight requests on ServeQUICConn connections and close idle ones when shutdown starts
- Delay that close by a second so a just-finished response can be read
- Retain plain HTTP/3 fallbacks for listeners without datagrams and shut them down with the same grace period

* reverseproxy: wait for WebTransport stream workers

- Join bidi and uni splice workers after the accept loops return
- Cancel the opposite copy on a hard splice error

---------

Signed-off-by: Zen Dodd <mail@steadytao.com>
Co-authored-by: tomholford <tomholford@users.noreply.github.com>
Co-authored-by: Zen Dodd <mail@steadytao.com>
2026-10-12 23:24:10 -04:00

977 lines
37 KiB
Go

// 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 caddyhttp
import (
"cmp"
"context"
"crypto/tls"
"errors"
"fmt"
"log"
"net"
"net/http"
"strconv"
"strings"
"sync"
"time"
"go.uber.org/zap"
"golang.org/x/net/http2"
"github.com/caddyserver/caddy/v2"
"github.com/caddyserver/caddy/v2/modules/caddyevents"
"github.com/caddyserver/caddy/v2/modules/caddytls"
)
func init() {
caddy.RegisterModule(App{})
}
// App is a robust, production-ready HTTP server.
//
// HTTPS is enabled by default if host matchers with qualifying names are used
// in any of routes; certificates are automatically provisioned and renewed.
// Additionally, automatic HTTPS will also enable HTTPS for servers that listen
// only on the HTTPS port but which do not have any TLS connection policies
// defined by adding a good, default TLS connection policy.
//
// Similar to how other popular web servers work, incoming request header fields
// with underscores or dots are ignored/dropped implicitly to mitigate security
// risks. Specific headers to allow can be explicitly configured using
// `expected_underscore_headers` and `expected_dot_headers`.
//
// ### Placeholders
//
// In HTTP routes, additional placeholders are available (replace any `*`):
//
// Placeholder | Description
// ------------|---------------
// `{http.request.body}` | The request body (⚠️ inefficient; use only for debugging); if reading it exceeds a `request_body` `max_size` limit, templates and the `vars` and `vars_regexp` matchers fail the request with HTTP 413 instead of silently returning a truncated body
// `{http.request.body_base64}` | The request body, base64-encoded (⚠️ for debugging); same 413 behavior on `max_size` limits as `{http.request.body}` in templates and the `vars` matchers
// `{http.request.cookie.*}` | HTTP request cookie
// `{http.request.duration}` | Time up to now spent handling the request (after decoding headers from client)
// `{http.request.duration_ms}` | Same as 'duration', but in milliseconds.
// `{http.request.uuid}` | The request unique identifier
// `{http.request.header.*}` | Specific request header field
// `{http.request.host}` | The host part of the request's Host header
// `{http.request.host.labels.*}` | Request host labels (0-based from right); e.g. for foo.example.com: 0=com, 1=example, 2=foo
// `{http.request.hostport}` | The host and port from the request's Host header
// `{http.request.method}` | The request method
// `{http.request.orig_method}` | The request's original method
// `{http.request.orig_uri}` | The request's original URI
// `{http.request.orig_uri.path}` | The request's original path
// `{http.request.orig_uri.path.*}` | Parts of the original path, split by `/` (0-based from left)
// `{http.request.orig_uri.path.dir}` | The request's original directory
// `{http.request.orig_uri.path.file}` | The request's original filename
// `{http.request.orig_uri.query}` | The request's original query string (without `?`)
// `{http.request.orig_uri.prefixed_query}` | The request's original query string with a `?` prefix, if non-empty
// `{http.request.port}` | The port part of the request's Host header
// `{http.request.proto}` | The raw protocol of the request as returned by Go (e.g., HTTP/2.0 or HTTP/3.0)
// `{http.request.proto_name}` | The spec-defined protocol of the request (e.g., HTTP/2 or HTTP/3)
// `{http.request.local.host}` | The host (IP) part of the local address the connection arrived on
// `{http.request.local.port}` | The port part of the local address the connection arrived on
// `{http.request.local}` | The local address the connection arrived on
// `{http.request.remote.host}` | The host (IP) part of the remote client's address, if available (not known with HTTP/3 early data)
// `{http.request.remote.port}` | The port part of the remote client's address
// `{http.request.remote}` | The address of the remote client
// `{http.request.scheme}` | The request scheme, typically `http` or `https`
// `{http.request.tls.version}` | The TLS version name
// `{http.request.tls.cipher_suite}` | The TLS cipher suite
// `{http.request.tls.resumed}` | The TLS connection resumed a previous connection
// `{http.request.tls.proto}` | The negotiated next protocol
// `{http.request.tls.proto_mutual}` | The negotiated next protocol was advertised by the server
// `{http.request.tls.server_name}` | The server name requested by the client, if any
// `{http.request.tls.ech}` | Whether ECH was offered by the client and accepted by the server
// `{http.request.tls.client.fingerprint}` | The SHA256 checksum of the client certificate
// `{http.request.tls.client.public_key}` | The public key of the client certificate.
// `{http.request.tls.client.public_key_sha256}` | The SHA256 checksum of the client's public key.
// `{http.request.tls.client.certificate_pem}` | The PEM-encoded value of the certificate.
// `{http.request.tls.client.certificate_der_base64}` | The base64-encoded value of the certificate.
// `{http.request.tls.client.issuer}` | The issuer DN of the client certificate
// `{http.request.tls.client.serial}` | The serial number of the client certificate
// `{http.request.tls.client.subject}` | The subject DN of the client certificate
// `{http.request.tls.client.san.dns_names.*}` | SAN DNS names(index optional)
// `{http.request.tls.client.san.emails.*}` | SAN email addresses (index optional)
// `{http.request.tls.client.san.ips.*}` | SAN IP addresses (index optional)
// `{http.request.tls.client.san.uris.*}` | SAN URIs (index optional)
// `{http.request.uri}` | The full request URI
// `{http.request.uri_escaped}` | The full request URI with query-style URL encoding applied (using url.QueryEscape)
// `{http.request.uri.path}` | The path component of the request URI
// `{http.request.uri.path_escaped}` | The path component of the request URI with query-style URL encoding applied (using url.QueryEscape)
// `{http.request.uri.path.*}` | Parts of the path, split by `/` (0-based from left)
// `{http.request.uri.path.dir}` | The directory, excluding leaf filename
// `{http.request.uri.path.file}` | The filename of the path, excluding directory
// `{http.request.uri.query}` | The query string (without `?`)
// `{http.request.uri.query_escaped}` | The query string with query-style URL encoding applied (using url.QueryEscape)
// `{http.request.uri.prefixed_query}` | The query string with a `?` prefix, if non-empty
// `{http.request.uri.query.*}` | Individual query string value
// `{http.response.header.*}` | Specific response header field
// `{http.vars.*}` | Custom variables in the HTTP handler chain
// `{http.shutting_down}` | True if the HTTP app is shutting down
// `{http.time_until_shutdown}` | Time until HTTP server shutdown, if scheduled
type App struct {
// HTTPPort specifies the port to use for HTTP (as opposed to HTTPS),
// which is used when setting up HTTP->HTTPS redirects or ACME HTTP
// challenge solvers. Default: 80.
HTTPPort int `json:"http_port,omitempty"`
// HTTPSPort specifies the port to use for HTTPS, which is used when
// solving the ACME TLS-ALPN challenges, or whenever HTTPS is needed
// but no specific port number is given. Default: 443.
HTTPSPort int `json:"https_port,omitempty"`
// GracePeriod is how long to wait for active connections when shutting
// down the servers. During the grace period, no new connections are
// accepted, idle connections are closed, and active connections will
// be given the full length of time to become idle and close.
// Once the grace period is over, connections will be forcefully closed.
// If zero, the grace period is eternal. Default: 0.
GracePeriod caddy.Duration `json:"grace_period,omitempty"`
// ShutdownDelay is how long to wait before initiating the grace
// period. When this app is stopping (e.g. during a config reload or
// process exit), all servers will be shut down. Normally this immediately
// initiates the grace period. However, if this delay is configured, servers
// will not be shut down until the delay is over. During this time, servers
// continue to function normally and allow new connections. At the end, the
// grace period will begin. This can be useful to allow downstream load
// balancers time to move this instance out of the rotation without hiccups.
//
// When shutdown has been scheduled, placeholders {http.shutting_down} (bool)
// and {http.time_until_shutdown} (duration) may be useful for health checks.
ShutdownDelay caddy.Duration `json:"shutdown_delay,omitempty"`
// Servers is the list of servers, keyed by arbitrary names chosen
// at your discretion for your own convenience; the keys do not
// affect functionality.
Servers map[string]*Server `json:"servers,omitempty"`
// If set, metrics observations will be enabled.
// This setting is EXPERIMENTAL and subject to change.
Metrics *Metrics `json:"metrics,omitempty"`
ctx caddy.Context
logger *zap.Logger
tlsApp *caddytls.TLS
// stopped indicates whether the app has stopped
// It can only happen if it has started successfully in the first place.
// Otherwise, Cleanup will call Stop to clean up resources.
stopped bool
// used temporarily between phases 1 and 2 of auto HTTPS
allCertDomains map[string]struct{}
}
// CaddyModule returns the Caddy module information.
func (App) CaddyModule() caddy.ModuleInfo {
return caddy.ModuleInfo{
ID: "http",
New: func() caddy.Module { return new(App) },
}
}
// Provision sets up the app.
func (app *App) Provision(ctx caddy.Context) error {
// store some references
app.logger = ctx.Logger()
app.ctx = ctx
// provision TLS and events apps
tlsAppIface, err := ctx.App("tls")
if err != nil {
return fmt.Errorf("getting tls app: %v", err)
}
app.tlsApp = tlsAppIface.(*caddytls.TLS)
eventsAppIface, err := ctx.App("events")
if err != nil {
return fmt.Errorf("getting events app: %v", err)
}
repl := caddy.NewReplacer()
// this provisions the matchers for each route,
// and prepares auto HTTP->HTTPS redirects, and
// is required before we provision each server
err = app.automaticHTTPSPhase1(ctx, repl)
if err != nil {
return err
}
if app.Metrics != nil {
app.Metrics.init = sync.Once{}
app.Metrics.httpMetrics = &httpMetrics{}
// Scan config for allowed hosts to prevent cardinality explosion
app.Metrics.scanConfigForHosts(app)
if err := app.Metrics.provisionOTLP(ctx); err != nil {
return err
}
}
// prepare each server
oldContext := ctx.Context
for srvName, srv := range app.Servers {
ctx.Context = context.WithValue(oldContext, ServerCtxKey, srv)
srv.name = srvName
srv.tlsApp = app.tlsApp
srv.events = eventsAppIface.(*caddyevents.App)
srv.ctx = ctx
srv.logger = app.logger.Named("log")
srv.errorLogger = app.logger.Named("log.error")
if srv.Metrics != nil {
srv.logger.Warn("per-server 'metrics' is deprecated; use 'metrics' in the root 'http' app instead")
app.Metrics = cmp.Or(app.Metrics, &Metrics{
init: sync.Once{},
httpMetrics: &httpMetrics{},
})
app.Metrics.PerHost = app.Metrics.PerHost || srv.Metrics.PerHost
}
// only enable access logs if configured
if srv.Logs != nil {
srv.Logs.normalizeLoggerNames()
srv.accessLogger = app.logger.Named("log.access")
if srv.Logs.Trace {
srv.traceLogger = app.logger.Named("log.trace")
}
}
// if no protocols configured explicitly, enable all except h2c
if len(srv.Protocols) == 0 {
srv.Protocols = srv.protocolsWithDefaults()
}
if srv.ListenProtocols != nil {
if len(srv.ListenProtocols) != len(srv.Listen) {
return fmt.Errorf("server %s: listener protocols count does not match address count: %d != %d",
srvName, len(srv.ListenProtocols), len(srv.Listen))
}
for i, lnProtocols := range srv.ListenProtocols {
if lnProtocols != nil {
srv.ListenProtocols[i] = srv.listenerProtocolsWithDefaults(lnProtocols)
}
}
}
// limit max header bytes to a more reasonable default than 1MB from Go std lib
// (see https://github.com/php/frankenphp/issues/2459#issuecomment-4655612909)
if srv.MaxHeaderBytes <= 0 {
srv.MaxHeaderBytes = 16 * 1024
}
// if not explicitly configured by the user, disallow TLS
// client auth bypass (domain fronting) which could
// otherwise be exploited by sending an unprotected SNI
// value during a TLS handshake, then putting a protected
// domain in the Host header after establishing connection;
// this is a safe default, but we allow users to override
// it for example in the case of running a proxy where
// domain fronting is desired and access is not restricted
// based on hostname
if srv.StrictSNIHost == nil && srv.hasTLSClientAuth() {
app.logger.Warn("enabling strict SNI-Host enforcement because TLS client auth is configured",
zap.String("server_id", srvName))
trueBool := true
srv.StrictSNIHost = &trueBool
}
// set up the trusted proxies source
for srv.TrustedProxiesRaw != nil {
val, err := ctx.LoadModule(srv, "TrustedProxiesRaw")
if err != nil {
return fmt.Errorf("loading trusted proxies modules: %v", err)
}
srv.trustedProxies = val.(IPRangeSource)
}
// set the default client IP header to read from
if srv.ClientIPHeaders == nil {
srv.ClientIPHeaders = []string{"X-Forwarded-For"}
}
// precompute underscore and dot header allowlist rules
if err := srv.provisionUnderscoreHeaders(); err != nil {
return fmt.Errorf("server %s: %v", srvName, err)
}
if err := srv.provisionDotHeaders(); err != nil {
return fmt.Errorf("server %s: %v", srvName, err)
}
// process each listener address
for i := range srv.Listen {
lnOut, err := repl.ReplaceOrErr(srv.Listen[i], true, true)
if err != nil {
return fmt.Errorf("server %s, listener %d: %v", srvName, i, err)
}
srv.Listen[i] = lnOut
}
// set up each listener modifier
if srv.ListenerWrappersRaw != nil {
vals, err := ctx.LoadModule(srv, "ListenerWrappersRaw")
if err != nil {
return fmt.Errorf("loading listener wrapper modules: %v", err)
}
var hasTLSPlaceholder bool
for i, val := range vals.([]any) {
if _, ok := val.(*tlsPlaceholderWrapper); ok {
if i == 0 {
// putting the tls placeholder wrapper first is nonsensical because
// that is the default, implicit setting: without it, all wrappers
// will go after the TLS listener anyway
return fmt.Errorf("it is unnecessary to specify the TLS listener wrapper in the first position because that is the default")
}
if hasTLSPlaceholder {
return fmt.Errorf("TLS listener wrapper can only be specified once")
}
hasTLSPlaceholder = true
}
srv.listenerWrappers = append(srv.listenerWrappers, val.(caddy.ListenerWrapper))
}
// if any wrappers were configured but the TLS placeholder wrapper is
// absent, prepend it so all defined wrappers come after the TLS
// handshake; this simplifies logic when starting the server, since we
// can simply assume the TLS placeholder will always be there
if !hasTLSPlaceholder && len(srv.listenerWrappers) > 0 {
srv.listenerWrappers = append([]caddy.ListenerWrapper{new(tlsPlaceholderWrapper)}, srv.listenerWrappers...)
}
}
// set up each packet conn modifier
if srv.PacketConnWrappersRaw != nil {
vals, err := ctx.LoadModule(srv, "PacketConnWrappersRaw")
if err != nil {
return fmt.Errorf("loading packet conn wrapper modules: %v", err)
}
// if any wrappers were configured, they come before the QUIC handshake;
// unlike TLS above, there is no QUIC placeholder
for _, val := range vals.([]any) {
srv.packetConnWrappers = append(srv.packetConnWrappers, val.(caddy.PacketConnWrapper))
}
}
// pre-compile the primary handler chain, and be sure to wrap it in our
// route handler so that important security checks are done, etc.
primaryRoute := emptyHandler
if srv.Routes != nil {
err := srv.Routes.ProvisionHandlers(ctx, app.Metrics)
if err != nil {
return fmt.Errorf("server %s: setting up route handlers: %v", srvName, err)
}
primaryRoute = srv.Routes.Compile(emptyHandler)
}
srv.primaryHandlerChain = srv.wrapPrimaryRoute(primaryRoute)
// pre-compile the error handler chain
if srv.Errors != nil {
err := srv.Errors.Routes.Provision(ctx)
if err != nil {
return fmt.Errorf("server %s: setting up error handling routes: %v", srvName, err)
}
srv.errorHandlerChain = srv.Errors.Routes.Compile(errorEmptyHandler)
}
// provision the named routes (they get compiled at runtime)
for name, route := range srv.NamedRoutes {
err := route.Provision(ctx, app.Metrics)
if err != nil {
return fmt.Errorf("server %s: setting up named route '%s' handlers: %v", name, srvName, err)
}
}
// prepare the TLS connection policies
err = srv.TLSConnPolicies.Provision(ctx)
if err != nil {
return fmt.Errorf("server %s: setting up TLS connection policies: %v", srvName, err)
}
// if there is no idle timeout, set a sane default; users have complained
// before that aggressive CDNs leave connections open until the server
// closes them, so if we don't close them it leads to resource exhaustion
if srv.IdleTimeout == 0 {
srv.IdleTimeout = defaultIdleTimeout
}
if srv.ReadHeaderTimeout == 0 {
srv.ReadHeaderTimeout = defaultReadHeaderTimeout // see #6663
}
if srv.ReadIdleTimeout == 0 {
srv.ReadIdleTimeout = defaultReadIdleTimeout
}
if srv.WriteIdleTimeout == 0 {
srv.WriteIdleTimeout = defaultWriteIdleTimeout
}
if srv.MaxWriteChunk == 0 {
srv.MaxWriteChunk = DefaultMaxWriteChunk
}
}
ctx.Context = oldContext
return nil
}
// Validate ensures the app's configuration is valid.
func (app *App) Validate() error {
lnAddrs := make(map[string]string)
for srvName, srv := range app.Servers {
// each server must use distinct listener addresses
for _, addr := range srv.Listen {
listenAddr, err := caddy.ParseNetworkAddress(addr)
if err != nil {
return fmt.Errorf("invalid listener address '%s': %v", addr, err)
}
// check that every address in the port range is unique to this server;
// we do not use <= here because PortRangeSize() adds 1 to EndPort for us
for i := uint(0); i < listenAddr.PortRangeSize(); i++ {
addr := caddy.JoinNetworkAddress(listenAddr.Network, listenAddr.Host, strconv.FormatUint(uint64(listenAddr.StartPort+i), 10))
if sn, ok := lnAddrs[addr]; ok {
return fmt.Errorf("server %s: listener address repeated: %s (already claimed by server '%s')", srvName, addr, sn)
}
lnAddrs[addr] = srvName
}
}
// logger names must not have ports
if srv.Logs != nil {
for host := range srv.Logs.LoggerNames {
if _, _, err := net.SplitHostPort(host); err == nil {
return fmt.Errorf("server %s: logger name must not have a port: %s", srvName, host)
}
}
}
}
return nil
}
func removeTLSALPN(srv *Server, target string) {
for _, cp := range srv.TLSConnPolicies {
// the TLSConfig was already provisioned, so... manually remove it
for i, np := range cp.TLSConfig.NextProtos {
if np == target {
cp.TLSConfig.NextProtos = append(cp.TLSConfig.NextProtos[:i], cp.TLSConfig.NextProtos[i+1:]...)
break
}
}
// remove it from the parent connection policy too, just to keep things tidy
for i, alpn := range cp.ALPN {
if alpn == target {
cp.ALPN = append(cp.ALPN[:i], cp.ALPN[i+1:]...)
break
}
}
}
}
// Start runs the app. It finishes automatic HTTPS if enabled,
// including management of certificates.
func (app *App) Start() error {
// get a logger compatible with http.Server; recovered handler panics are
// routed to ERROR so they remain visible at the default log level (#7923)
serverLogger := serverErrorLogger(app.logger.Named("stdlib"))
for srvName, srv := range app.Servers {
srv.server = &http.Server{
ReadTimeout: time.Duration(srv.ReadTimeout),
ReadHeaderTimeout: time.Duration(srv.ReadHeaderTimeout),
WriteTimeout: time.Duration(srv.WriteTimeout),
IdleTimeout: time.Duration(srv.IdleTimeout),
MaxHeaderBytes: srv.MaxHeaderBytes,
Handler: srv,
ErrorLog: serverLogger,
Protocols: new(http.Protocols),
ConnContext: func(ctx context.Context, c net.Conn) context.Context {
if nc, ok := c.(interface{ tlsNetConn() net.Conn }); ok {
getTlsConStateFunc := sync.OnceValue(func() *tls.ConnectionState {
tlsConnState := nc.tlsNetConn().(connectionStater).ConnectionState()
return &tlsConnState
})
ctx = context.WithValue(ctx, tlsConnectionStateFuncCtxKey, getTlsConStateFunc)
}
return ctx
},
}
// disable HTTP/2, which we enabled by default during provisioning
if !srv.protocol("h2") {
srv.server.TLSNextProto = make(map[string]func(*http.Server, *tls.Conn, http.Handler))
removeTLSALPN(srv, "h2")
}
if !srv.protocol("h1") {
removeTLSALPN(srv, "http/1.1")
}
// configure the http versions the server will serve
if srv.protocol("h1") {
srv.server.Protocols.SetHTTP1(true)
}
if srv.protocol("h2") || srv.protocol("h2c") {
// skip setting h2 because if NextProtos is present, it's list of alpn versions will take precedence.
// it will always be present because http2.ConfigureServer will populate that field
// enabling h2c because some listener wrapper will wrap the connection that is no longer *tls.Conn
// However, we need to handle the case that if the connection is h2c but h2c is not enabled. We identify
// this type of connection by checking if it's behind a TLS listener wrapper or if it implements tls.ConnectionState.
srv.server.Protocols.SetUnencryptedHTTP2(true)
// when h2c is enabled but h2 disabled, we already removed h2 from NextProtos
// the handshake will never succeed with h2
// http2.ConfigureServer will enable the server to handle both h2 and h2c
// TODO: Migrate to http.Server.Protocols once listener-wrapper behaviour is covered.
//nolint:errcheck,staticcheck
http2.ConfigureServer(srv.server, new(http2.Server))
}
// this TLS config is used by the std lib to choose the actual TLS config for connections
// by looking through the connection policies to find the first one that matches
tlsCfg := srv.TLSConnPolicies.TLSConfig(app.ctx)
srv.configureServer(srv.server)
for lnIndex, lnAddr := range srv.Listen {
listenAddr, err := caddy.ParseNetworkAddress(lnAddr)
if err != nil {
return fmt.Errorf("%s: parsing listen address '%s': %v", srvName, lnAddr, err)
}
srv.addresses = append(srv.addresses, listenAddr)
protocols := srv.Protocols
if srv.ListenProtocols != nil && srv.ListenProtocols[lnIndex] != nil {
protocols = srv.ListenProtocols[lnIndex]
}
protocolsUnique := map[string]struct{}{}
for _, protocol := range protocols {
protocolsUnique[protocol] = struct{}{}
}
_, h1ok := protocolsUnique["h1"]
_, h2ok := protocolsUnique["h2"]
_, h2cok := protocolsUnique["h2c"]
_, h3ok := protocolsUnique["h3"]
for portOffset := uint(0); portOffset < listenAddr.PortRangeSize(); portOffset++ {
hostport := listenAddr.JoinHostPort(portOffset)
// enable TLS if there is a policy and if this is not the HTTP port
useTLS := len(srv.TLSConnPolicies) > 0 && int(listenAddr.StartPort+portOffset) != app.httpPort()
if h1ok || h2ok && useTLS || h2cok {
// create the listener for this socket
lnAny, err := listenAddr.Listen(app.ctx, portOffset, net.ListenConfig{
KeepAliveConfig: net.KeepAliveConfig{
Enable: srv.KeepAliveInterval >= 0,
Interval: time.Duration(srv.KeepAliveInterval),
Idle: time.Duration(srv.KeepAliveIdle),
Count: srv.KeepAliveCount,
},
})
if err != nil {
return fmt.Errorf("listening on %s: %v", listenAddr.At(portOffset), err)
}
ln, ok := lnAny.(net.Listener)
if !ok {
return fmt.Errorf("network '%s' cannot handle HTTP/1 or HTTP/2 connections", listenAddr.Network)
}
// wrap listener before TLS (up to the TLS placeholder wrapper)
var lnWrapperIdx int
for i, lnWrapper := range srv.listenerWrappers {
if _, ok := lnWrapper.(*tlsPlaceholderWrapper); ok {
lnWrapperIdx = i + 1 // mark the next wrapper's spot
break
}
ln = lnWrapper.WrapListener(ln)
}
if useTLS {
// create TLS listener - this enables and terminates TLS
ln = tls.NewListener(ln, tlsCfg)
}
// finish wrapping listener where we left off before TLS
for i := lnWrapperIdx; i < len(srv.listenerWrappers); i++ {
ln = srv.listenerWrappers[i].WrapListener(ln)
}
// check if the connection is h2c
ln = &http2Listener{
useTLS: useTLS,
useH1: h1ok,
useH2: h2ok || h2cok,
Listener: ln,
logger: app.logger,
}
// if binding to port 0, the OS chooses a port for us;
// but the user won't know the port unless we print it
if !listenAddr.IsUnixNetwork() && !listenAddr.IsFdNetwork() && listenAddr.StartPort == 0 && listenAddr.EndPort == 0 {
app.logger.Info("port 0 listener",
zap.String("input_address", lnAddr),
zap.String("actual_address", ln.Addr().String()))
}
app.logger.Debug("starting server loop",
zap.String("address", ln.Addr().String()),
zap.Bool("tls", useTLS),
zap.Bool("http3", srv.h3server != nil))
srv.listeners = append(srv.listeners, ln)
//nolint:errcheck
go srv.server.Serve(ln)
}
if h2ok && !useTLS {
// Can only serve h2 with TLS enabled
app.logger.Warn("HTTP/2 skipped because it requires TLS",
zap.String("network", listenAddr.Network),
zap.String("addr", hostport))
}
if h3ok {
// Can't serve HTTP/3 on the same socket as HTTP/1 and 2 because it uses
// a different transport mechanism... which is fine, but the OS doesn't
// differentiate between a SOCK_STREAM file and a SOCK_DGRAM file; they
// are still one file on the system. So even though "unixpacket" and
// "unixgram" are different network types just as "tcp" and "udp" are,
// the OS will not let us use the same file as both STREAM and DGRAM.
if listenAddr.IsUnixNetwork() {
app.logger.Warn("HTTP/3 disabled because Unix can't multiplex STREAM and DGRAM on same socket",
zap.String("file", hostport))
continue
}
if useTLS {
// enable HTTP/3 if configured
app.logger.Info("enabling HTTP/3 listener", zap.String("addr", hostport))
if err := srv.serveHTTP3(listenAddr.At(portOffset), tlsCfg); err != nil {
return err
}
} else {
// Can only serve h3 with TLS enabled
app.logger.Warn("HTTP/3 skipped because it requires TLS",
zap.String("network", listenAddr.Network),
zap.String("addr", hostport))
}
}
}
}
srv.logger.Info("server running",
zap.String("name", srvName),
zap.Strings("protocols", srv.Protocols))
}
// finish automatic HTTPS by finally beginning
// certificate management
err := app.automaticHTTPSPhase2()
if err != nil {
return fmt.Errorf("finalizing automatic HTTPS: %v", err)
}
return nil
}
// stdlibLogPrefixPanic is the prefix Go's net/http server uses when it writes a
// recovered handler panic (and its stack trace) to http.Server.ErrorLog.
// See the deferred recover in net/http.(*conn).serve.
const stdlibLogPrefixPanic = "http: panic serving"
// serverErrorLogger returns a *log.Logger suitable for http.Server.ErrorLog
// that forwards the standard library's server messages to Caddy's structured
// logger. Most of these messages are low-signal and logged at DEBUG, but
// recovered handler panics indicate a real server-side error, so they are
// logged at ERROR to stay visible at the default log level.
func serverErrorLogger(logger *zap.Logger) *log.Logger {
return log.New(stdlibLogRouter{logger: logger}, "", 0)
}
// stdlibLogRouter is an io.Writer that routes standard library http.Server
// error-log messages to the appropriate level on the wrapped structured logger.
type stdlibLogRouter struct {
logger *zap.Logger
}
func (w stdlibLogRouter) Write(p []byte) (int, error) {
// log.Logger always appends a trailing newline; trim it so the structured
// message doesn't carry a dangling blank line.
msg := strings.TrimSuffix(string(p), "\n")
if strings.HasPrefix(msg, stdlibLogPrefixPanic) {
w.logger.Error(msg)
} else {
w.logger.Debug(msg)
}
return len(p), nil
}
// Stop gracefully shuts down the HTTP server.
func (app *App) Stop() error {
return app.stop(caddy.Exiting())
}
func (app *App) stop(exiting bool) error {
ctx := context.Background()
// see if any listeners in our config will be closing or if they are continuing
// through a reload; because if any are closing, we will enforce shutdown delay
var delay bool
scheduledTime := time.Now().Add(time.Duration(app.ShutdownDelay))
if app.ShutdownDelay > 0 {
for _, server := range app.Servers {
for _, na := range server.addresses {
for _, addr := range na.Expand() {
if caddy.ListenerUsage(addr.Network, addr.JoinHostPort(0)) < 2 {
app.logger.Debug("listener closing and shutdown delay is configured", zap.String("address", addr.String()))
server.shutdownAt.Store(&scheduledTime)
delay = true
} else {
app.logger.Debug("shutdown delay configured but listener will remain open", zap.String("address", addr.String()))
}
}
}
}
}
// honor scheduled/delayed shutdown time
if delay {
app.logger.Info("shutdown scheduled",
zap.Duration("delay_duration", time.Duration(app.ShutdownDelay)),
zap.Time("time", scheduledTime))
time.Sleep(time.Duration(app.ShutdownDelay))
}
// enforce grace period if configured
var finishedShutdown sync.WaitGroup
if app.GracePeriod > 0 {
var cancel context.CancelFunc
timeout := time.Duration(app.GracePeriod)
ctx, cancel = context.WithTimeoutCause(ctx, timeout, fmt.Errorf("server graceful shutdown %ds timeout", int(timeout.Seconds())))
defer func() {
// A reload must leave the grace period alive while its requests finish.
go func() {
finishedShutdown.Wait()
cancel()
}()
}()
app.logger.Info("servers shutting down; grace period initiated", zap.Duration("duration", timeout))
} else {
app.logger.Info("servers shutting down with eternal grace period")
}
// goroutines aren't guaranteed to be scheduled right away,
// so we'll use one WaitGroup to wait for all the goroutines
// to start their server shutdowns, and another to wait for
// them to finish; we'll always block for them to start so
// that when we return the caller can be confident* that the
// old servers are no longer accepting new connections
// (* the scheduler might still pause them right before
// calling Shutdown(), but it's unlikely)
var startedShutdown sync.WaitGroup
// these will run in goroutines
stopServer := func(server *Server) {
defer finishedShutdown.Done()
startedShutdown.Done()
// possible if server failed to Start
if server.server == nil {
return
}
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))
}
}
stopH3Server := func(server *Server) {
defer finishedShutdown.Done()
startedShutdown.Done()
if server.h3server == nil {
return
}
// closing quic listeners won't affect accepted connections now
// so like stdlib, close listeners first, but keep the net.PacketConns open
for _, h3ln := range server.quicListeners {
if err := h3ln.Close(); err != nil {
app.logger.Error("http3 listener close",
zap.Error(err))
}
}
// ServeQUICConn connections are not in the HTTP/3 server's set, so
// Shutdown does not wait for them and, with none tracked, it closes
// the server immediately. webtransport.Server.Close would do that
// too. waitWebTransportConns keeps the sockets up until the accept
// loops finish. GracePeriod 0 is eternal: the wait ends when the
// peer leaves, and the session is not force-closed. A finite grace
// period closes sessions at the deadline, while the sockets are
// still up, so the close capsule can reach the client.
// The close below is for a session whose loop already returned.
// Fallback HTTP/3 servers are drained at the same time so an
// active request on a reused non-WebTransport listener gets this
// grace period instead of dying when the packet conn closes.
fbErr := make(chan error, 1)
go func() {
fbErr <- server.shutdownFallbackHTTP3(ctx)
}()
server.waitWebTransportConns(ctx)
server.closeWebTransportSessions()
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))
}
if err := <-fbErr; err != nil {
app.logger.Error("HTTP/3 fallback shutdown",
zap.Error(err),
zap.Strings("addresses", server.Listen))
}
// close the underlying net.PacketConns now
// see the comment for ListenQUIC
for _, h3ln := range server.quicListeners {
if err := h3ln.Close(); err != nil {
app.logger.Error("http3 listener close socket",
zap.Error(err))
}
}
}
for _, server := range app.Servers {
startedShutdown.Add(2)
finishedShutdown.Add(2)
go stopServer(server)
go stopH3Server(server)
}
shutdownDone := make(chan struct{})
pendingServerShutdowns.Store(shutdownDone, struct{}{})
go func() {
finishedShutdown.Wait()
close(shutdownDone)
pendingServerShutdowns.Delete(shutdownDone)
}()
// block until all the goroutines have been run by the scheduler;
// this means that they have likely called Shutdown() by now
startedShutdown.Wait()
// if the process is exiting, we need to block here and wait
// for the grace periods to complete, otherwise the process will
// terminate before the servers are finished shutting down; but
// we don't really need to wait for the grace period to finish
// if the process isn't exiting (but note that frequent config
// reloads with long grace periods for a sustained length of time
// may deplete resources)
if exiting {
finishedShutdown.Wait()
// Responses from earlier configurations must finish before the process exits.
pendingServerShutdowns.Range(func(done, _ any) bool {
select {
case <-done.(chan struct{}):
return true
case <-ctx.Done():
app.logger.Error("waiting for previous server shutdowns", zap.Error(context.Cause(ctx)))
return false
}
})
}
// run stop callbacks now that the server shutdowns are complete
for name, s := range app.Servers {
for _, stopHook := range s.onStopFuncs {
if err := stopHook(ctx); err != nil {
app.logger.Error("server stop hook", zap.String("server", name), zap.Error(err))
}
}
}
// flush and shut down the OTLP metrics exporter (if configured) so any
// last data point reaches the collector before the process exits
if err := app.Metrics.shutdown(ctx); err != nil {
app.logger.Error("shutting down OTLP metrics", zap.Error(err))
}
app.stopped = true
return nil
}
// Cleanup will close remaining listeners if they still remain
// because some of the servers fail to start.
// It simply calls Stop because Stop won't be called when Start fails.
func (app *App) Cleanup() error {
if app.stopped {
return nil
}
return app.Stop()
}
func (app *App) httpPort() int {
if app.HTTPPort == 0 {
return DefaultHTTPPort
}
return app.HTTPPort
}
func (app *App) httpsPort() int {
if app.HTTPSPort == 0 {
return DefaultHTTPSPort
}
return app.HTTPSPort
}
const (
// defaultIdleTimeout is the default HTTP server timeout
// for closing idle connections; useful to avoid resource
// exhaustion behind hungry CDNs, for example (we've had
// several complaints without this).
defaultIdleTimeout = caddy.Duration(5 * time.Minute)
// defaultReadHeaderTimeout is the default timeout for
// reading HTTP headers from clients. Headers are generally
// small, often less than 1 KB, so it shouldn't take a
// long time even on legitimately slow connections or
// busy servers to read it.
defaultReadHeaderTimeout = caddy.Duration(time.Minute)
// defaultReadIdleTimeout and defaultWriteIdleTimeout mitigate
// slowloris-style attacks on the request body and response write.
// Unlike a hard deadline, these are safe defaults even for large
// payloads because the deadline is reset on every successful
// read/write; only a stalled connection is affected.
defaultReadIdleTimeout = caddy.Duration(time.Minute)
defaultWriteIdleTimeout = caddy.Duration(time.Minute)
)
// Reloads outlive their app, so termination must track shutdowns across configurations.
var pendingServerShutdowns sync.Map
// Interface guards
var (
_ caddy.App = (*App)(nil)
_ caddy.Provisioner = (*App)(nil)
_ caddy.Validator = (*App)(nil)
)