@marcel91200 / caddy Mirror · read-only
Fork 0

Fast and extensible multi-platform HTTP/1-2-3 web server with automatic HTTPS

Mirrored from https://github.com/Slazee/caddy.git

Synced Nov 11, 01:00 UTC

master
go 1,408 lines 42.9 KB
Raw
1 // Copyright 2015 Matthew Holt and The Caddy Authors
2 //
3 // Licensed under the Apache License, Version 2.0 (the "License");
4 // you may not use this file except in compliance with the License.
5 // You may obtain a copy of the License at
6 //
7 // http://www.apache.org/licenses/LICENSE-2.0
8 //
9 // Unless required by applicable law or agreed to in writing, software
10 // distributed under the License is distributed on an "AS IS" BASIS,
11 // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12 // See the License for the specific language governing permissions and
13 // limitations under the License.
14
15 package integration
16
17 import (
18 "context"
19 "crypto/ecdsa"
20 "crypto/elliptic"
21 "crypto/rand"
22 "crypto/tls"
23 "crypto/x509"
24 "crypto/x509/pkix"
25 "encoding/json"
26 "fmt"
27 "io"
28 "math/big"
29 "net"
30 "net/http"
31 "net/http/httptest"
32 "os"
33 "strings"
34 "testing"
35 "time"
36
37 "github.com/quic-go/quic-go"
38 "github.com/quic-go/quic-go/http3"
39 "github.com/quic-go/webtransport-go"
40
41 "github.com/caddyserver/caddy/v2/caddytest"
42 )
43
44 const wtProxyListen = "https://127.0.0.1:9443/"
45
46 // TestWebTransport_EchoHandlerBidi spins up Caddy with an HTTP/3 listener
47 // that terminates a WebTransport session via the http.handlers.webtransport
48 // echo handler, then dials it with a real webtransport.Transport and asserts
49 // an end-to-end bidirectional-stream round-trip. This exercises the
50 // serveH3AcceptLoop path (webtransport.Server.ServeQUICConn instead of
51 // http3.Server.ServeListener) and the UnwrapResponseWriterAs helper.
52 func TestWebTransport_EchoHandlerBidi(t *testing.T) {
53 if testing.Short() {
54 t.Skip()
55 }
56 caddytest.NewTester(t).InitServer(wtJSON(`"srv0": `+wtH3Server(":9443", `{"handler": "webtransport"}`)), "json")
57
58 ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
59 defer cancel()
60 rsp, sess := dialWT(t, ctx, nil, nil)
61 defer sess.CloseWithError(0, "")
62 if rsp.StatusCode != http.StatusOK {
63 t.Fatalf("unexpected status: %d", rsp.StatusCode)
64 }
65 echoBidi(t, ctx, sess, "hello webtransport")
66 }
67
68 // TestWebTransport_ReverseProxyEndToEnd spins up a single Caddy instance
69 // running two HTTP/3 servers: one on :9443 acting as the WebTransport
70 // reverse proxy, and one on :9444 acting as the terminating echo
71 // upstream. A real webtransport.Transport dials the proxy; the pump should
72 // bridge to the upstream so bytes written on a bidi stream are echoed.
73 func TestWebTransport_ReverseProxyEndToEnd(t *testing.T) {
74 if testing.Short() {
75 t.Skip()
76 }
77 caddytest.NewTester(t).InitServer(wtJSON(
78 `"proxy": `+wtH3Server(":9443", wtReverseProxyHandler(`"upstreams": [{"dial": "127.0.0.1:9444"}]`))+
79 `, "upstream": `+wtH3Server(":9444", `{"handler": "webtransport"}`),
80 ), "json")
81
82 ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
83 defer cancel()
84 rsp, sess := dialWT(t, ctx, nil, nil)
85 defer sess.CloseWithError(0, "")
86 if rsp.StatusCode != http.StatusOK {
87 t.Fatalf("unexpected status: %d", rsp.StatusCode)
88 }
89 echoBidi(t, ctx, sess, "reverse-proxied via the pump")
90 }
91
92 // TestWebTransport_ReverseProxyForwardsHeaders proves that the WebTransport
93 // proxy path applies the same request-preparation pipeline as the normal
94 // reverse_proxy path: `headers.request.set` lands on the upstream CONNECT,
95 // X-Forwarded-For is added, and a Via header is appended. The upstream here
96 // is a standalone webtransport.Server (not another Caddy) so we can observe
97 // the raw headers of the Extended CONNECT that Caddy forwarded.
98 func TestWebTransport_ReverseProxyForwardsHeaders(t *testing.T) {
99 if testing.Short() {
100 t.Skip()
101 }
102
103 gotHeaders := make(chan http.Header, 1)
104 upstreamAddr, stopUpstream := startStandaloneWebTransport(t, func(sess *webtransport.Session, r *http.Request) {
105 select {
106 case gotHeaders <- r.Header.Clone():
107 default:
108 }
109 _ = sess.CloseWithError(0, "")
110 })
111 t.Cleanup(stopUpstream)
112
113 startWTProxy(t, wtReverseProxyHandler(fmt.Sprintf(`
114 "headers": {"request": {"set": {"X-Caddy-Test": ["caddy-wt-hdr"]}}},
115 "upstreams": [{"dial": "127.0.0.1:%d"}]`, upstreamAddr.Port)))
116
117 ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
118 defer cancel()
119 _, sess := dialWT(t, ctx, nil, nil)
120 defer sess.CloseWithError(0, "")
121
122 select {
123 case hdr := <-gotHeaders:
124 if got := hdr.Get("X-Caddy-Test"); got != "caddy-wt-hdr" {
125 t.Errorf("upstream did not receive `headers.request.set` value; got X-Caddy-Test=%q", got)
126 }
127 if got := hdr.Get("X-Forwarded-For"); !strings.Contains(got, "127.0.0.1") {
128 t.Errorf("upstream did not receive X-Forwarded-For=127.0.0.1; got %q", got)
129 }
130 if got := hdr.Get("Via"); got == "" {
131 t.Errorf("upstream did not receive Via header")
132 }
133 case <-time.After(3 * time.Second):
134 t.Fatal("upstream did not observe forwarded CONNECT headers in time")
135 }
136 }
137
138 // TestWebTransport_ReverseProxyForwardsPreparedHost proves header_up Host
139 // is the CONNECT :authority the upstream sees. Transport.Dial would otherwise
140 // overwrite Host from the dial URL.
141 func TestWebTransport_ReverseProxyForwardsPreparedHost(t *testing.T) {
142 if testing.Short() {
143 t.Skip()
144 }
145
146 gotHost := make(chan string, 1)
147 upstreamAddr, stopUpstream := startStandaloneWebTransport(t, func(sess *webtransport.Session, r *http.Request) {
148 select {
149 case gotHost <- r.Host:
150 default:
151 }
152 _ = sess.CloseWithError(0, "")
153 })
154 t.Cleanup(stopUpstream)
155
156 wantHost := fmt.Sprintf("127.0.0.1:%d", upstreamAddr.Port)
157 startWTProxy(t, wtReverseProxyHandler(fmt.Sprintf(`
158 "headers": {"request": {"set": {"Host": [%q]}}},
159 "upstreams": [{"dial": "127.0.0.1:%d"}]`, wantHost, upstreamAddr.Port)))
160
161 ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
162 defer cancel()
163 _, sess := dialWT(t, ctx, nil, nil)
164 defer sess.CloseWithError(0, "")
165
166 select {
167 case got := <-gotHost:
168 if got != wantHost {
169 t.Errorf("upstream Host = %q, want %q", got, wantHost)
170 }
171 case <-time.After(3 * time.Second):
172 t.Fatal("upstream did not observe Host in time")
173 }
174 }
175
176 // TestWebTransport_ReverseProxyHeaderUpAvailableProtocols proves protocol
177 // negotiation reads the prepared request, so header_up can inject
178 // WT-Available-Protocols even when the client omitted the offer.
179 func TestWebTransport_ReverseProxyHeaderUpAvailableProtocols(t *testing.T) {
180 if testing.Short() {
181 t.Skip()
182 }
183
184 gotHdr := make(chan http.Header, 1)
185 upstreamAddr, stopUpstream := startStandaloneWebTransport(t, func(sess *webtransport.Session, r *http.Request) {
186 select {
187 case gotHdr <- r.Header.Clone():
188 default:
189 }
190 _ = sess.CloseWithError(0, "")
191 }, "moqt-19")
192 t.Cleanup(stopUpstream)
193
194 // Client offers moqt-19 (so it will accept that selection). header_up
195 // replaces the prepared offer with moqt-19 plus a marker token. Parsing
196 // origReq would hide the marker from the upstream.
197 offer := `"moqt-19", "x-caddy-test"`
198 startWTProxy(t, wtReverseProxyHandler(fmt.Sprintf(`
199 "headers": {"request": {"set": {"WT-Available-Protocols": [%q]}}},
200 "upstreams": [{"dial": "127.0.0.1:%d"}]`, offer, upstreamAddr.Port)))
201
202 ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
203 defer cancel()
204 rsp, sess := dialWT(t, ctx, nil, []string{"moqt-19"})
205 defer sess.CloseWithError(0, "")
206 if rsp != nil {
207 defer rsp.Body.Close()
208 }
209 if got := sess.SessionState().ApplicationProtocol; got != "moqt-19" {
210 t.Errorf("client ApplicationProtocol = %q, want moqt-19", got)
211 }
212 select {
213 case hdr := <-gotHdr:
214 if got := hdr.Get("WT-Available-Protocols"); !strings.Contains(got, "x-caddy-test") {
215 t.Errorf("upstream WT-Available-Protocols = %q, want header_up marker x-caddy-test", got)
216 }
217 case <-time.After(3 * time.Second):
218 t.Fatal("upstream did not observe CONNECT headers in time")
219 }
220 }
221
222 // TestWebTransport_ReverseProxyHeaderDown copies non-hop upstream response
223 // headers onto the client 200 and applies header_down.
224 func TestWebTransport_ReverseProxyHeaderDown(t *testing.T) {
225 if testing.Short() {
226 t.Skip()
227 }
228
229 upstreamAddr, stopUpstream := startStandaloneWebTransportBeforeUpgrade(t,
230 func(w http.ResponseWriter, _ *http.Request) {
231 w.Header().Set("X-Upstream", "from-backend")
232 },
233 func(sess *webtransport.Session, _ *http.Request) {
234 _ = sess.CloseWithError(0, "")
235 },
236 )
237 t.Cleanup(stopUpstream)
238
239 startWTProxy(t, wtReverseProxyHandler(fmt.Sprintf(`
240 "headers": {"response": {"set": {"X-Down": ["from-caddy"]}}},
241 "upstreams": [{"dial": "127.0.0.1:%d"}]`, upstreamAddr.Port)))
242
243 ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
244 defer cancel()
245 rsp, sess := dialWT(t, ctx, nil, nil)
246 defer sess.CloseWithError(0, "")
247 if rsp != nil {
248 defer rsp.Body.Close()
249 }
250 if got := rsp.Header.Get("X-Upstream"); got != "from-backend" {
251 t.Errorf("X-Upstream = %q, want from-backend", got)
252 }
253 if got := rsp.Header.Get("X-Down"); got != "from-caddy" {
254 t.Errorf("X-Down = %q, want from-caddy", got)
255 }
256 }
257
258 // TestWebTransport_ReverseProxyAccessLogStatus records a hijacked 200 on
259 // the response recorder so access logs do not report status 0.
260 func TestWebTransport_ReverseProxyAccessLogStatus(t *testing.T) {
261 if testing.Short() {
262 t.Skip()
263 }
264
265 logFile := t.TempDir() + "/access.log"
266 upstreamAddr, stopUpstream := startStandaloneWebTransport(t, func(sess *webtransport.Session, _ *http.Request) {
267 _ = sess.CloseWithError(0, "")
268 })
269 t.Cleanup(stopUpstream)
270
271 cfg := fmt.Sprintf(`{
272 "admin": {"listen": "localhost:2999"},
273 "logging": {
274 "logs": {
275 "default": {
276 "exclude": ["http.log.access"]
277 },
278 "wtaccess": {
279 "writer": {"output": "file", "filename": %q},
280 "encoder": {"format": "json"},
281 "include": ["http.log.access"]
282 }
283 }
284 },
285 "apps": {
286 "http": {
287 "http_port": 9080,
288 "https_port": 9443,
289 "grace_period": 1,
290 "servers": {
291 "proxy": {
292 "listen": [":9443"],
293 "protocols": ["h3"],
294 "webtransport": {},
295 "logs": {"default_logger_name": "wtaccess"},
296 "routes": [{"handle": [%s]}],
297 "tls_connection_policies": [{
298 "certificate_selection": {"any_tag": ["cert0"]},
299 "default_sni": "a.caddy.localhost"
300 }]
301 }
302 }
303 },
304 "tls": {
305 "certificates": {
306 "load_files": [{
307 "certificate": "/a.caddy.localhost.crt",
308 "key": "/a.caddy.localhost.key",
309 "tags": ["cert0"]
310 }]
311 }
312 },
313 "pki": {"certificate_authorities": {"local": {"install_trust": false}}}
314 }
315 }`, logFile, wtReverseProxyHandler(fmt.Sprintf(`"upstreams": [{"dial": "127.0.0.1:%d"}]`, upstreamAddr.Port)))
316
317 caddytest.NewTester(t).InitServer(cfg, "json")
318
319 ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
320 defer cancel()
321 rsp, sess := dialWT(t, ctx, nil, nil)
322 if rsp != nil {
323 _ = rsp.Body.Close()
324 }
325 _ = sess.CloseWithError(0, "")
326
327 deadline := time.Now().Add(3 * time.Second)
328 for {
329 b, err := os.ReadFile(logFile)
330 if err == nil && strings.Contains(string(b), `"status":200`) {
331 return
332 }
333 if time.Now().After(deadline) {
334 t.Fatalf("access log did not record status 200; file=%q err=%v", b, err)
335 }
336 time.Sleep(50 * time.Millisecond)
337 }
338 }
339
340 // TestWebTransport_ReverseProxyUpgradeUsesDownstreamRequest proves that
341 // the client-facing Upgrade uses origReq, not the upstream-directed clone.
342 // header_up rewrites Host to the upstream address while the client sends
343 // Origin matching the downstream Host; CheckOrigin would fail if Upgrade
344 // saw the rewritten Host.
345 func TestWebTransport_ReverseProxyUpgradeUsesDownstreamRequest(t *testing.T) {
346 if testing.Short() {
347 t.Skip()
348 }
349
350 upgraded := make(chan struct{}, 1)
351 upstreamAddr, stopUpstream := startStandaloneWebTransport(t, func(sess *webtransport.Session, _ *http.Request) {
352 select {
353 case upgraded <- struct{}{}:
354 default:
355 }
356 _ = sess.CloseWithError(0, "")
357 })
358 t.Cleanup(stopUpstream)
359
360 startWTProxy(t, wtReverseProxyHandler(fmt.Sprintf(`
361 "headers": {"request": {"set": {"Host": ["127.0.0.1:%d"]}}},
362 "upstreams": [{"dial": "127.0.0.1:%d"}]`, upstreamAddr.Port, upstreamAddr.Port)))
363
364 ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
365 defer cancel()
366 _, sess := dialWT(t, ctx, http.Header{"Origin": []string{"https://127.0.0.1:9443"}}, nil)
367 defer sess.CloseWithError(0, "")
368
369 select {
370 case <-upgraded:
371 case <-time.After(3 * time.Second):
372 t.Fatal("upstream did not observe a WebTransport session in time")
373 }
374 }
375
376 // TestWebTransport_ReverseProxyNegotiatesApplicationProtocol proves the
377 // proxy relays WT-Available-Protocols to the upstream and copies the
378 // upstream's WT-Protocol choice back to the client (draft-ietf-webtrans-http3
379 // §3.3). Without this, a MOQ client such as MediaMTX closes with WT_ALPN_ERROR.
380 func TestWebTransport_ReverseProxyNegotiatesApplicationProtocol(t *testing.T) {
381 if testing.Short() {
382 t.Skip()
383 }
384
385 gotHeaders := make(chan http.Header, 1)
386 upstreamAddr, stopUpstream := startStandaloneWebTransport(t, func(sess *webtransport.Session, r *http.Request) {
387 select {
388 case gotHeaders <- r.Header.Clone():
389 default:
390 }
391 _ = sess.CloseWithError(0, "")
392 }, "moqt-19")
393 t.Cleanup(stopUpstream)
394
395 startWTProxy(t, wtReverseProxyHandler(fmt.Sprintf(`"upstreams": [{"dial": "127.0.0.1:%d"}]`, upstreamAddr.Port)))
396
397 ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
398 defer cancel()
399 rsp, sess := dialWT(t, ctx, nil, []string{"moqt-19"})
400 defer sess.CloseWithError(0, "")
401 if rsp != nil {
402 defer rsp.Body.Close()
403 }
404
405 if got := sess.SessionState().ApplicationProtocol; got != "moqt-19" {
406 t.Errorf("client ApplicationProtocol = %q, want moqt-19", got)
407 }
408 if got := rsp.Header.Get("WT-Protocol"); got == "" {
409 t.Error("client response missing WT-Protocol")
410 }
411
412 select {
413 case hdr := <-gotHeaders:
414 if got := hdr.Get("WT-Available-Protocols"); !strings.Contains(got, "moqt-19") {
415 t.Errorf("upstream WT-Available-Protocols = %q, want to contain moqt-19", got)
416 }
417 case <-time.After(3 * time.Second):
418 t.Fatal("upstream did not observe a WebTransport session in time")
419 }
420 }
421
422 // TestWebTransport_RetryPreservesApplicationProtocol fails the first
423 // upstream dial and retries. Round-robin picks index 1 first. That dial
424 // fails, and the second upstream must still see WT-Available-Protocols.
425 // Deleting the offer on the request header removes it for every later
426 // attempt, because the proxy loop reuses the request and does not
427 // recopy headers when no header ops are configured.
428 func TestWebTransport_RetryPreservesApplicationProtocol(t *testing.T) {
429 if testing.Short() {
430 t.Skip()
431 }
432
433 badPort, stopBad := startClosingQUICServer(t)
434 t.Cleanup(stopBad)
435
436 gotHeaders := make(chan http.Header, 1)
437 goodAddr, stopGood := startStandaloneWebTransport(t, func(sess *webtransport.Session, r *http.Request) {
438 select {
439 case gotHeaders <- r.Header.Clone():
440 default:
441 }
442 _ = sess.CloseWithError(0, "")
443 }, "moqt-19")
444 t.Cleanup(stopGood)
445
446 startWTProxy(t, wtReverseProxyHandler(fmt.Sprintf(`
447 "load_balancing": {
448 "selection_policy": {"policy": "round_robin"},
449 "retries": 1
450 },
451 "upstreams": [
452 {"dial": "127.0.0.1:%d"},
453 {"dial": "127.0.0.1:%d"}
454 ]`, goodAddr.Port, badPort)))
455
456 ctx, cancel := context.WithTimeout(context.Background(), 8*time.Second)
457 defer cancel()
458 rsp, sess := dialWT(t, ctx, nil, []string{"moqt-19"})
459 defer sess.CloseWithError(0, "")
460 if rsp != nil {
461 defer rsp.Body.Close()
462 }
463
464 if got := sess.SessionState().ApplicationProtocol; got != "moqt-19" {
465 t.Errorf("client ApplicationProtocol = %q, want moqt-19", got)
466 }
467
468 select {
469 case hdr := <-gotHeaders:
470 if got := hdr.Get("WT-Available-Protocols"); !strings.Contains(got, "moqt-19") {
471 t.Errorf("second upstream WT-Available-Protocols = %q, want to contain moqt-19", got)
472 }
473 case <-time.After(5 * time.Second):
474 t.Fatal("second upstream did not observe a WebTransport session")
475 }
476 }
477
478 // TestWebTransport_ReverseProxyMOQLike is the MediaMTX scenario from #7669:
479 // the client offers moqt-19 AND header_up rewrites Host. Both of elee's
480 // bugs fire together — Upgrade must use origReq (CheckOrigin against the
481 // rewritten Host would 400) and WT-Available-Protocols must be relayed so
482 // the upstream selects moqt-19 instead of closing with WT_ALPN_ERROR.
483 func TestWebTransport_ReverseProxyMOQLike(t *testing.T) {
484 if testing.Short() {
485 t.Skip()
486 }
487
488 gotHeaders := make(chan http.Header, 1)
489 upstreamAddr, stopUpstream := startStandaloneWebTransport(t, func(sess *webtransport.Session, r *http.Request) {
490 select {
491 case gotHeaders <- r.Header.Clone():
492 default:
493 }
494 str, err := sess.AcceptStream(sess.Context())
495 if err != nil {
496 return
497 }
498 _, _ = io.Copy(str, str)
499 _ = str.Close()
500 }, "moqt-19")
501 t.Cleanup(stopUpstream)
502
503 startWTProxy(t, wtReverseProxyHandler(fmt.Sprintf(`
504 "headers": {"request": {"set": {"Host": ["127.0.0.1:%d"]}}},
505 "upstreams": [{"dial": "127.0.0.1:%d"}]`, upstreamAddr.Port, upstreamAddr.Port)))
506
507 ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
508 defer cancel()
509 rsp, sess := dialWT(t, ctx, http.Header{"Origin": []string{"https://127.0.0.1:9443"}}, []string{"moqt-19"})
510 defer sess.CloseWithError(0, "")
511 if rsp != nil {
512 defer rsp.Body.Close()
513 }
514
515 if got := sess.SessionState().ApplicationProtocol; got != "moqt-19" {
516 t.Errorf("client ApplicationProtocol = %q, want moqt-19", got)
517 }
518 echoBidi(t, ctx, sess, "moq-like through the proxy")
519
520 select {
521 case hdr := <-gotHeaders:
522 if got := hdr.Get("WT-Available-Protocols"); !strings.Contains(got, "moqt-19") {
523 t.Errorf("upstream WT-Available-Protocols = %q, want to contain moqt-19", got)
524 }
525 case <-time.After(3 * time.Second):
526 t.Fatal("upstream did not observe a WebTransport session in time")
527 }
528 }
529
530 // TestWebTransport_ReverseProxyExpandsSNIPlaceholder proves that a
531 // placeholder in the transport's tls_server_name (here driven off a request
532 // header) is expanded per session before the WebTransport upstream dial, so
533 // the upstream observes the resolved SNI rather than the literal "{...}"
534 // string. The normal HTTP/3 path handles this via a custom h3Transport.Dial
535 // hook (#7737); the WebTransport path dials through its own Transport and so
536 // must expand the placeholder itself.
537 func TestWebTransport_ReverseProxyExpandsSNIPlaceholder(t *testing.T) {
538 if testing.Short() {
539 t.Skip()
540 }
541
542 gotSNI := make(chan string, 1)
543 upstreamAddr, stopUpstream := startStandaloneWebTransport(t, func(sess *webtransport.Session, r *http.Request) {
544 sni := ""
545 if r.TLS != nil {
546 sni = r.TLS.ServerName
547 }
548 select {
549 case gotSNI <- sni:
550 default:
551 }
552 _ = sess.CloseWithError(0, "")
553 })
554 t.Cleanup(stopUpstream)
555
556 startWTProxy(t, wtReverseProxyHandler(
557 fmt.Sprintf(`"upstreams": [{"dial": "127.0.0.1:%d"}]`, upstreamAddr.Port),
558 `"insecure_skip_verify": true, "server_name": "{http.request.header.X-SNI}"`,
559 ))
560
561 ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
562 defer cancel()
563 _, sess := dialWT(t, ctx, http.Header{"X-Sni": []string{"sni.example.com"}}, nil)
564 defer sess.CloseWithError(0, "")
565
566 select {
567 case got := <-gotSNI:
568 if got != "sni.example.com" {
569 t.Errorf("upstream observed SNI %q; want the expanded placeholder value \"sni.example.com\"", got)
570 }
571 case <-time.After(3 * time.Second):
572 t.Fatal("upstream did not observe a WebTransport session in time")
573 }
574 }
575
576 // TestWebTransport_UpstreamDialFailureSurfaces5xx proves the WT path dials
577 // the upstream BEFORE upgrading the client, so an unreachable upstream
578 // returns a proper 5xx on the client's Dial call (webtransport-go surfaces
579 // it via RequirementsNotMetError or similar with the response attached) —
580 // not a successful Dial followed by an opaque session close.
581 func TestWebTransport_UpstreamDialFailureSurfaces5xx(t *testing.T) {
582 if testing.Short() {
583 t.Skip()
584 }
585 l, err := net.ListenPacket("udp", "127.0.0.1:0")
586 if err != nil {
587 t.Fatal(err)
588 }
589 deadPort := l.LocalAddr().(*net.UDPAddr).Port
590 _ = l.Close()
591
592 startWTProxy(t, wtReverseProxyHandler(fmt.Sprintf(`"upstreams": [{"dial": "127.0.0.1:%d"}]`, deadPort)))
593
594 outer, cancel := context.WithTimeout(context.Background(), 6*time.Second)
595 defer cancel()
596
597 var (
598 rsp *http.Response
599 dialErr error
600 sess *webtransport.Session
601 )
602 deadline := time.Now().Add(3 * time.Second)
603 for {
604 ctx, c := context.WithTimeout(outer, 2*time.Second)
605 rsp, sess, dialErr = newWTDialer(nil).Dial(ctx, wtProxyListen, nil)
606 c()
607 if dialErr != nil {
608 break
609 }
610 // Happy path dial isn't allowed here — the upstream is dead.
611 sess.CloseWithError(0, "")
612 if time.Now().After(deadline) {
613 t.Fatal("expected Dial to fail against unreachable upstream, got success")
614 }
615 time.Sleep(100 * time.Millisecond)
616 }
617
618 // The exact error type varies with webtransport-go versions, but the
619 // response (if attached) should carry a 5xx status — proving the
620 // proxy returned an error status instead of upgrading + closing.
621 t.Logf("observed dial error: %v", dialErr)
622 if rsp != nil && rsp.StatusCode < 500 {
623 t.Errorf("expected 5xx status from proxy on upstream failure; got %d", rsp.StatusCode)
624 }
625 }
626
627 // TestWebTransport_InFlightRequestsTracked proves the WT proxy path
628 // increments upstream.Host.NumRequests for the session's lifetime and
629 // decrements after it ends, so MaxRequests gating, LeastConn/FirstAvailable
630 // LB, and the admin /reverse_proxy/upstreams endpoint reflect WT load.
631 func TestWebTransport_InFlightRequestsTracked(t *testing.T) {
632 if testing.Short() {
633 t.Skip()
634 }
635
636 // Upstream blocks on a release channel until the test finishes probing
637 // the admin API; this keeps the session alive long enough to observe
638 // num_requests > 0.
639 release := make(chan struct{})
640 t.Cleanup(func() {
641 select {
642 case <-release:
643 default:
644 close(release)
645 }
646 })
647 upstreamAddr, stopUpstream := startStandaloneWebTransport(t, func(sess *webtransport.Session, r *http.Request) {
648 <-release
649 _ = sess.CloseWithError(0, "")
650 })
651 t.Cleanup(stopUpstream)
652
653 upstreamDial := fmt.Sprintf("127.0.0.1:%d", upstreamAddr.Port)
654 startWTProxy(t, wtReverseProxyHandler(fmt.Sprintf(`"upstreams": [{"dial": "%s"}]`, upstreamDial)))
655
656 ctx, cancel := context.WithTimeout(context.Background(), 8*time.Second)
657 defer cancel()
658 _, sess := dialWT(t, ctx, nil, nil)
659
660 if !waitForUpstreamRequests(t, upstreamDial, 1, 2*time.Second) {
661 t.Fatal("upstream num_requests never reached >= 1 while session was active")
662 }
663
664 _ = sess.CloseWithError(0, "")
665 close(release)
666
667 if !waitForUpstreamRequests(t, upstreamDial, 0, 2*time.Second) {
668 t.Fatal("upstream num_requests did not drop to 0 after session closed")
669 }
670 }
671
672 // TestWebTransport_EternalGraceKeepsActiveSession holds an in-flight
673 // WebTransport session and reloads Caddy to a config with no HTTP servers.
674 // The default grace period is eternal, so the reload returns and the
675 // session stays up until the client ends it.
676 func TestWebTransport_EternalGraceKeepsActiveSession(t *testing.T) {
677 if testing.Short() {
678 t.Skip()
679 }
680
681 upstreamAddr, stopUpstream := startStandaloneWebTransport(t, func(sess *webtransport.Session, r *http.Request) {
682 <-sess.Context().Done()
683 })
684 t.Cleanup(stopUpstream)
685
686 tester := caddytest.NewTester(t)
687 tester.InitServer(wtJSONEternalGrace(`"proxy": `+wtH3Server(":9443",
688 wtReverseProxyHandler(fmt.Sprintf(`"upstreams": [{"dial": "127.0.0.1:%d"}]`, upstreamAddr.Port)))), "json")
689
690 ctx, cancel := context.WithTimeout(context.Background(), 8*time.Second)
691 defer cancel()
692 _, sess := dialWT(t, ctx, nil, nil)
693
694 reloaded := make(chan struct{})
695 go func() {
696 tester.InitServer(`{"admin": {"listen": "localhost:2999"}}`, "json")
697 close(reloaded)
698 }()
699 select {
700 case <-reloaded:
701 case <-time.After(3 * time.Second):
702 t.Fatal("reload did not return while the WebTransport session was still open")
703 }
704 select {
705 case <-sess.Context().Done():
706 t.Fatal("eternal grace closed the active WebTransport session")
707 case <-time.After(300 * time.Millisecond):
708 }
709
710 _ = sess.CloseWithError(0, "")
711 waitForUDPPortFree(t, 9443)
712 }
713
714 // TestWebTransport_GracePeriodClosesSession holds a session across a reload
715 // with a finite grace period. The session stays open during the window and
716 // is closed once the deadline passes.
717 func TestWebTransport_GracePeriodClosesSession(t *testing.T) {
718 if testing.Short() {
719 t.Skip()
720 }
721
722 upstreamAddr, stopUpstream := startStandaloneWebTransport(t, func(sess *webtransport.Session, r *http.Request) {
723 <-sess.Context().Done()
724 })
725 t.Cleanup(stopUpstream)
726
727 grace := time.Second
728 tester := caddytest.NewTester(t)
729 tester.InitServer(wtJSONWithHTTPPrefix(
730 `"proxy": `+wtH3Server(":9443",
731 wtReverseProxyHandler(fmt.Sprintf(`"upstreams": [{"dial": "127.0.0.1:%d"}]`, upstreamAddr.Port))),
732 fmt.Sprintf(`"grace_period": %d,`, grace.Nanoseconds()),
733 ), "json")
734
735 ctx, cancel := context.WithTimeout(context.Background(), 8*time.Second)
736 defer cancel()
737 _, sess := dialWT(t, ctx, nil, nil)
738 defer sess.CloseWithError(0, "")
739
740 reloadStart := time.Now()
741 go tester.InitServer(`{"admin": {"listen": "localhost:2999"}}`, "json")
742
743 select {
744 case <-sess.Context().Done():
745 t.Fatal("WebTransport session closed before the grace period")
746 case <-time.After(200 * time.Millisecond):
747 }
748
749 select {
750 case <-sess.Context().Done():
751 if time.Since(reloadStart) < grace-300*time.Millisecond {
752 t.Fatalf("session closed after %s, before grace period %s", time.Since(reloadStart), grace)
753 }
754 case <-time.After(grace + time.Second):
755 t.Fatal("WebTransport session was not closed when the grace period ended")
756 }
757 waitForUDPPortFree(t, 9443)
758 }
759
760 // TestWebTransport_ShutdownDrainsPlainHTTP3 holds a WebTransport session and
761 // a plain HTTP/3 request open, then reloads. The HTTP/3 request finishes
762 // during the grace period while the session is still open. The session is
763 // closed when the grace period ends.
764 func TestWebTransport_ShutdownDrainsPlainHTTP3(t *testing.T) {
765 if testing.Short() {
766 t.Skip()
767 }
768
769 started := make(chan struct{})
770 release := make(chan struct{})
771 upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
772 close(started)
773 <-release
774 _, _ = io.WriteString(w, "slow-ok")
775 }))
776 t.Cleanup(upstream.Close)
777
778 wtUpstream, stopWT := startStandaloneWebTransport(t, func(sess *webtransport.Session, r *http.Request) {
779 <-sess.Context().Done()
780 })
781 t.Cleanup(stopWT)
782
783 routes := fmt.Sprintf(`{
784 "match": [{"path": ["/slow"]}],
785 "handle": [{
786 "handler": "reverse_proxy",
787 "upstreams": [{"dial": "%s"}]
788 }]
789 }, {
790 "handle": [%s]
791 }`, upstream.Listener.Addr().String(),
792 wtReverseProxyHandler(fmt.Sprintf(`"upstreams": [{"dial": "127.0.0.1:%d"}]`, wtUpstream.Port)))
793
794 grace := 2 * time.Second
795 tester := caddytest.NewTester(t)
796 tester.InitServer(wtJSONWithHTTPPrefix(
797 `"proxy": `+wtH3ServerRoutes(":9443", routes),
798 fmt.Sprintf(`"grace_period": %d,`, grace.Nanoseconds()),
799 ), "json")
800
801 ctx, cancel := context.WithTimeout(context.Background(), 8*time.Second)
802 defer cancel()
803 _, sess := dialWT(t, ctx, nil, nil)
804 defer sess.CloseWithError(0, "")
805
806 tr := &http3.Transport{
807 TLSClientConfig: &tls.Config{
808 InsecureSkipVerify: true, //nolint:gosec // local CA
809 ServerName: "a.caddy.localhost",
810 NextProtos: []string{http3.NextProtoH3},
811 },
812 QUICConfig: &quic.Config{
813 EnableDatagrams: true,
814 EnableStreamResetPartialDelivery: true,
815 },
816 }
817 t.Cleanup(func() { _ = tr.Close() })
818
819 type h3Result struct {
820 body string
821 err error
822 }
823 got := make(chan h3Result, 1)
824 go func() {
825 req, err := http.NewRequest(http.MethodGet, "https://127.0.0.1:9443/slow", nil)
826 if err != nil {
827 got <- h3Result{err: err}
828 return
829 }
830 resp, err := tr.RoundTrip(req)
831 if err != nil {
832 got <- h3Result{err: err}
833 return
834 }
835 defer resp.Body.Close()
836 b, err := io.ReadAll(resp.Body)
837 if err != nil {
838 got <- h3Result{err: err}
839 return
840 }
841 if resp.StatusCode != http.StatusOK {
842 got <- h3Result{err: fmt.Errorf("status %d", resp.StatusCode)}
843 return
844 }
845 got <- h3Result{body: string(b)}
846 }()
847
848 select {
849 case <-started:
850 case res := <-got:
851 t.Fatalf("plain HTTP/3 request failed before the handler started: %v", res.err)
852 case <-time.After(3 * time.Second):
853 t.Fatal("plain HTTP/3 request did not reach the upstream")
854 }
855
856 reloadStart := time.Now()
857 reloadDone := make(chan struct{})
858 go func() {
859 tester.InitServer(`{"admin": {"listen": "localhost:2999"}}`, "json")
860 close(reloadDone)
861 }()
862
863 select {
864 case <-sess.Context().Done():
865 t.Fatal("WebTransport session closed before the plain HTTP/3 request finished")
866 case <-time.After(200 * time.Millisecond):
867 }
868 close(release)
869
870 select {
871 case res := <-got:
872 if res.err != nil {
873 t.Fatalf("plain HTTP/3 request aborted during grace: %v", res.err)
874 }
875 if res.body != "slow-ok" {
876 t.Fatalf("plain HTTP/3 body = %q", res.body)
877 }
878 case <-time.After(2 * time.Second):
879 t.Fatal("plain HTTP/3 request did not finish during grace")
880 }
881 select {
882 case <-sess.Context().Done():
883 t.Fatal("WebTransport session closed while the grace period was still running")
884 default:
885 }
886
887 select {
888 case <-sess.Context().Done():
889 if time.Since(reloadStart) < grace-300*time.Millisecond {
890 t.Fatalf("session closed after %s, before grace period %s", time.Since(reloadStart), grace)
891 }
892 case <-time.After(grace + time.Second):
893 t.Fatal("WebTransport session was not closed when the grace period ended")
894 }
895
896 _ = tr.Close()
897 select {
898 case <-reloadDone:
899 case <-time.After(time.Second):
900 t.Fatal("reload did not finish after HTTP/3 drained")
901 }
902 waitForUDPPortFree(t, 9443)
903 }
904
905 // TestWebTransport_IdleHTTP3DoesNotPinConfig completes one HTTP/3 request
906 // and leaves the QUIC connection open. Eternal grace must drop that idle
907 // connection on reload instead of retaining the old listener.
908 func TestWebTransport_IdleHTTP3DoesNotPinConfig(t *testing.T) {
909 if testing.Short() {
910 t.Skip()
911 }
912
913 tester := caddytest.NewTester(t)
914 tester.InitServer(wtJSONEternalGrace(`"proxy": `+wtH3Server(":9443",
915 `{"handler":"static_response","body":"ok"}`)), "json")
916
917 tr := &http3.Transport{
918 TLSClientConfig: &tls.Config{
919 InsecureSkipVerify: true, //nolint:gosec // local CA
920 ServerName: "a.caddy.localhost",
921 NextProtos: []string{http3.NextProtoH3},
922 },
923 QUICConfig: &quic.Config{
924 EnableDatagrams: true,
925 EnableStreamResetPartialDelivery: true,
926 },
927 }
928 t.Cleanup(func() { _ = tr.Close() })
929
930 resp, err := tr.RoundTrip(mustNewRequest(t, "https://127.0.0.1:9443/"))
931 if err != nil {
932 t.Fatalf("http/3 request: %v", err)
933 }
934 body, err := io.ReadAll(resp.Body)
935 resp.Body.Close()
936 if err != nil {
937 t.Fatalf("read body: %v", err)
938 }
939 if resp.StatusCode != http.StatusOK || string(body) != "ok" {
940 t.Fatalf("response = %d %q", resp.StatusCode, body)
941 }
942
943 go tester.InitServer(`{"admin": {"listen": "localhost:2999"}}`, "json")
944
945 // The client transport is still open. An idle keepalive must not hold
946 // the packet conn for the whole QUIC idle timeout.
947 waitForUDPPortFree(t, 9443)
948 }
949
950 // TestWebTransport_FallbackRequestSurvivesReload enables WebTransport on a
951 // reused QUIC listener that was created without datagrams. The plain HTTP/3
952 // fallback has to drain an in-flight request across reload.
953 func TestWebTransport_FallbackRequestSurvivesReload(t *testing.T) {
954 if testing.Short() {
955 t.Skip()
956 }
957
958 started := make(chan struct{})
959 release := make(chan struct{})
960 upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
961 close(started)
962 <-release
963 _, _ = io.WriteString(w, "fallback-ok")
964 }))
965 t.Cleanup(upstream.Close)
966
967 grace := 3 * time.Second
968 graceJSON := fmt.Sprintf(`"grace_period": %d,`, grace.Nanoseconds())
969 tester := caddytest.NewTester(t)
970 tester.InitServer(wtJSONWithHTTPPrefix(
971 `"proxy": `+h3ServerNoWebTransport(":9443", `{"handler":"static_response","body":"plain"}`),
972 graceJSON,
973 ), "json")
974
975 routes := fmt.Sprintf(`{
976 "match": [{"path": ["/slow"]}],
977 "handle": [{
978 "handler": "reverse_proxy",
979 "upstreams": [{"dial": "%s"}]
980 }]
981 }`, upstream.Listener.Addr().String())
982 tester.InitServer(wtJSONWithHTTPPrefix(
983 `"proxy": `+wtH3ServerRoutes(":9443", routes),
984 graceJSON,
985 ), "json")
986
987 if serverAdvertisesDatagrams(t) {
988 t.Fatal("reused listener advertises datagrams; fallback path was not taken")
989 }
990
991 tr := &http3.Transport{
992 TLSClientConfig: &tls.Config{
993 InsecureSkipVerify: true, //nolint:gosec // local CA
994 ServerName: "a.caddy.localhost",
995 NextProtos: []string{http3.NextProtoH3},
996 },
997 }
998 t.Cleanup(func() { _ = tr.Close() })
999
1000 type h3Result struct {
1001 body string
1002 err error
1003 }
1004 got := make(chan h3Result, 1)
1005 go func() {
1006 req, err := http.NewRequest(http.MethodGet, "https://127.0.0.1:9443/slow", nil)
1007 if err != nil {
1008 got <- h3Result{err: err}
1009 return
1010 }
1011 resp, err := tr.RoundTrip(req)
1012 if err != nil {
1013 got <- h3Result{err: err}
1014 return
1015 }
1016 defer resp.Body.Close()
1017 b, err := io.ReadAll(resp.Body)
1018 if err != nil {
1019 got <- h3Result{err: err}
1020 return
1021 }
1022 if resp.StatusCode != http.StatusOK {
1023 got <- h3Result{err: fmt.Errorf("status %d", resp.StatusCode)}
1024 return
1025 }
1026 got <- h3Result{body: string(b)}
1027 }()
1028
1029 select {
1030 case <-started:
1031 case res := <-got:
1032 t.Fatalf("fallback request failed before the handler started: %v", res.err)
1033 case <-time.After(3 * time.Second):
1034 t.Fatal("fallback request did not reach the upstream")
1035 }
1036
1037 go tester.InitServer(`{"admin": {"listen": "localhost:2999"}}`, "json")
1038
1039 select {
1040 case res := <-got:
1041 t.Fatalf("fallback request aborted before release: %v", res.err)
1042 case <-time.After(200 * time.Millisecond):
1043 }
1044 close(release)
1045
1046 select {
1047 case res := <-got:
1048 if res.err != nil {
1049 t.Fatalf("fallback request aborted during grace: %v", res.err)
1050 }
1051 if res.body != "fallback-ok" {
1052 t.Fatalf("fallback body = %q", res.body)
1053 }
1054 case <-time.After(2 * time.Second):
1055 t.Fatal("fallback request did not finish during grace")
1056 }
1057 waitForUDPPortFree(t, 9443)
1058 }
1059
1060 func wtH3ServerRoutes(listen, routesJSON string) string {
1061 return fmt.Sprintf(`{
1062 "listen": [%q],
1063 "protocols": ["h3"],
1064 "webtransport": {},
1065 "routes": [%s],
1066 "tls_connection_policies": [
1067 {
1068 "certificate_selection": {"any_tag": ["cert0"]},
1069 "default_sni": "a.caddy.localhost"
1070 }
1071 ]
1072 }`, listen, routesJSON)
1073 }
1074
1075 func startWTProxy(t *testing.T, handlerJSON string) {
1076 t.Helper()
1077 caddytest.NewTester(t).InitServer(wtJSON(`"proxy": `+wtH3Server(":9443", handlerJSON)), "json")
1078 }
1079
1080 func wtJSON(serversJSON string) string {
1081 return wtJSONWithHTTPPrefix(serversJSON, `"grace_period": 1,`)
1082 }
1083
1084 // wtJSONEternalGrace omits grace_period. Shutdown waits until active
1085 // connections finish, which is the default. A WebTransport session stays
1086 // open until the client ends it.
1087 func wtJSONEternalGrace(serversJSON string) string {
1088 return wtJSONWithHTTPPrefix(serversJSON, "")
1089 }
1090
1091 func wtJSONWithHTTPPrefix(serversJSON, httpPrefix string) string {
1092 return fmt.Sprintf(`{
1093 "admin": {"listen": "localhost:2999"},
1094 "apps": {
1095 "http": {
1096 "http_port": 9080,
1097 "https_port": 9443,
1098 %s
1099 "servers": { %s }
1100 },
1101 "tls": {
1102 "certificates": {
1103 "load_files": [
1104 {
1105 "certificate": "/a.caddy.localhost.crt",
1106 "key": "/a.caddy.localhost.key",
1107 "tags": ["cert0"]
1108 }
1109 ]
1110 }
1111 },
1112 "pki": {"certificate_authorities": {"local": {"install_trust": false}}}
1113 }
1114 }`, httpPrefix, serversJSON)
1115 }
1116
1117 func h3ServerNoWebTransport(listen, handlerJSON string) string {
1118 return fmt.Sprintf(`{
1119 "listen": [%q],
1120 "protocols": ["h3"],
1121 "routes": [{"handle": [%s]}],
1122 "tls_connection_policies": [
1123 {
1124 "certificate_selection": {"any_tag": ["cert0"]},
1125 "default_sni": "a.caddy.localhost"
1126 }
1127 ]
1128 }`, listen, handlerJSON)
1129 }
1130
1131 func serverAdvertisesDatagrams(t *testing.T) bool {
1132 t.Helper()
1133 ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
1134 defer cancel()
1135 conn, err := quic.DialAddr(ctx, "127.0.0.1:9443", &tls.Config{
1136 InsecureSkipVerify: true, //nolint:gosec // local CA
1137 ServerName: "a.caddy.localhost",
1138 NextProtos: []string{http3.NextProtoH3},
1139 }, nil)
1140 if err != nil {
1141 t.Fatalf("datagram probe: %v", err)
1142 }
1143 defer conn.CloseWithError(0, "")
1144 return conn.ConnectionState().SupportsDatagrams.Remote
1145 }
1146
1147 func mustNewRequest(t *testing.T, url string) *http.Request {
1148 t.Helper()
1149 req, err := http.NewRequest(http.MethodGet, url, nil)
1150 if err != nil {
1151 t.Fatalf("new request: %v", err)
1152 }
1153 return req
1154 }
1155
1156 func wtH3Server(listen, handlerJSON string) string {
1157 return fmt.Sprintf(`{
1158 "listen": [%q],
1159 "protocols": ["h3"],
1160 "webtransport": {},
1161 "routes": [{"handle": [%s]}],
1162 "tls_connection_policies": [
1163 {
1164 "certificate_selection": {"any_tag": ["cert0"]},
1165 "default_sni": "a.caddy.localhost"
1166 }
1167 ]
1168 }`, listen, handlerJSON)
1169 }
1170
1171 func wtReverseProxyHandler(extraJSON string, tlsExtra ...string) string {
1172 tls := `"insecure_skip_verify": true`
1173 if len(tlsExtra) > 0 && tlsExtra[0] != "" {
1174 tls = tlsExtra[0]
1175 }
1176 body := fmt.Sprintf(`"handler": "reverse_proxy",
1177 "transport": {
1178 "protocol": "http",
1179 "versions": ["3"],
1180 "tls": {%s}
1181 }`, tls)
1182 if extraJSON != "" {
1183 body += ",\n " + extraJSON
1184 }
1185 return "{" + body + "}"
1186 }
1187
1188 func newWTDialer(protocols []string) *webtransport.Transport {
1189 return &webtransport.Transport{
1190 TLSClientConfig: &tls.Config{
1191 InsecureSkipVerify: true, //nolint:gosec // local CA
1192 ServerName: "a.caddy.localhost",
1193 NextProtos: []string{http3.NextProtoH3},
1194 },
1195 QUICConfig: &quic.Config{
1196 EnableDatagrams: true,
1197 EnableStreamResetPartialDelivery: true,
1198 },
1199 ApplicationProtocols: protocols,
1200 }
1201 }
1202
1203 func dialWT(t *testing.T, ctx context.Context, hdr http.Header, protocols []string) (*http.Response, *webtransport.Session) {
1204 t.Helper()
1205 dialer := newWTDialer(protocols)
1206 deadline := time.Now().Add(3 * time.Second)
1207 for {
1208 rsp, sess, err := dialer.Dial(ctx, wtProxyListen, hdr)
1209 if err == nil {
1210 return rsp, sess
1211 }
1212 if time.Now().After(deadline) {
1213 t.Fatalf("webtransport dial failed after retries: %v", err)
1214 }
1215 time.Sleep(100 * time.Millisecond)
1216 }
1217 }
1218
1219 func echoBidi(t *testing.T, ctx context.Context, sess *webtransport.Session, payload string) {
1220 t.Helper()
1221 str, err := sess.OpenStreamSync(ctx)
1222 if err != nil {
1223 t.Fatalf("open stream: %v", err)
1224 }
1225 if _, err := io.WriteString(str, payload); err != nil {
1226 t.Fatalf("write: %v", err)
1227 }
1228 if err := str.Close(); err != nil {
1229 t.Fatalf("close send: %v", err)
1230 }
1231 got, err := io.ReadAll(str)
1232 if err != nil {
1233 t.Fatalf("read: %v", err)
1234 }
1235 if string(got) != payload {
1236 t.Fatalf("echo mismatch:\n got: %q\n want: %q", strings.TrimSpace(string(got)), payload)
1237 }
1238 }
1239
1240 // waitForUpstreamRequests polls the admin /reverse_proxy/upstreams endpoint
1241 // until the entry for dial has exactly wantRequests in-flight, or timeout.
1242 // Returns true on match.
1243 func waitForUpstreamRequests(t *testing.T, dial string, wantRequests int, timeout time.Duration) bool {
1244 t.Helper()
1245 deadline := time.Now().Add(timeout)
1246 for time.Now().Before(deadline) {
1247 rsp, err := http.Get("http://localhost:2999/reverse_proxy/upstreams")
1248 if err != nil {
1249 time.Sleep(50 * time.Millisecond)
1250 continue
1251 }
1252 var entries []struct {
1253 Address string `json:"address"`
1254 NumRequests int `json:"num_requests"`
1255 }
1256 err = json.NewDecoder(rsp.Body).Decode(&entries)
1257 _ = rsp.Body.Close()
1258 if err != nil {
1259 time.Sleep(50 * time.Millisecond)
1260 continue
1261 }
1262 for _, e := range entries {
1263 if e.Address == dial && e.NumRequests == wantRequests {
1264 return true
1265 }
1266 }
1267 time.Sleep(50 * time.Millisecond)
1268 }
1269 return false
1270 }
1271
1272 // waitForUDPPortFree returns once addr can be bound. Shutdown keeps the
1273 // packet conn open until WebTransport accept loops finish, and the next
1274 // test listens on the same port.
1275 func waitForUDPPortFree(t *testing.T, port int) {
1276 t.Helper()
1277 deadline := time.Now().Add(3 * time.Second)
1278 for time.Now().Before(deadline) {
1279 conn, err := net.ListenUDP("udp", &net.UDPAddr{IP: net.ParseIP("127.0.0.1"), Port: port})
1280 if err == nil {
1281 _ = conn.Close()
1282 return
1283 }
1284 time.Sleep(20 * time.Millisecond)
1285 }
1286 t.Fatalf("UDP port %d still in use", port)
1287 }
1288
1289 // startClosingQUICServer accepts QUIC handshakes and closes them. The
1290 // WebTransport dial fails quickly, so a retry can run without waiting
1291 // out a handshake timeout.
1292 func startClosingQUICServer(t *testing.T) (int, func()) {
1293 t.Helper()
1294 ln, err := quic.ListenAddr("127.0.0.1:0", newSelfSignedTLSConfig(t, "localhost"), &quic.Config{
1295 EnableDatagrams: true,
1296 EnableStreamResetPartialDelivery: true,
1297 })
1298 if err != nil {
1299 t.Fatal(err)
1300 }
1301 ctx, cancel := context.WithCancel(context.Background())
1302 go func() {
1303 for {
1304 conn, err := ln.Accept(ctx)
1305 if err != nil {
1306 return
1307 }
1308 _ = conn.CloseWithError(0, "unavailable")
1309 }
1310 }()
1311 return ln.Addr().(*net.UDPAddr).Port, func() {
1312 cancel()
1313 _ = ln.Close()
1314 }
1315 }
1316
1317 // startStandaloneWebTransport starts a webtransport.Server on a random UDP
1318 // port with a self-signed cert. handler runs after a successful Upgrade.
1319 // Returns the listener addr and a shutdown func.
1320 func startStandaloneWebTransport(t *testing.T, handler func(s *webtransport.Session, r *http.Request), protocols ...string) (*net.UDPAddr, func()) {
1321 return startStandaloneWebTransportBeforeUpgrade(t, nil, handler, protocols...)
1322 }
1323
1324 func startStandaloneWebTransportBeforeUpgrade(t *testing.T, beforeUpgrade func(http.ResponseWriter, *http.Request), handler func(s *webtransport.Session, r *http.Request), protocols ...string) (*net.UDPAddr, func()) {
1325 t.Helper()
1326 tlsCfg := newSelfSignedTLSConfig(t, "localhost")
1327
1328 mux := http.NewServeMux()
1329 h3 := &http3.Server{
1330 TLSConfig: tlsCfg,
1331 Handler: mux,
1332 QUICConfig: &quic.Config{
1333 EnableDatagrams: true,
1334 EnableStreamResetPartialDelivery: true,
1335 },
1336 }
1337 webtransport.ConfigureHTTP3Server(h3)
1338 wtServer := &webtransport.Server{
1339 H3: h3,
1340 ApplicationProtocols: protocols,
1341 // Test backends are not browsers; skip same-origin checks so
1342 // header_up Host rewrites used by proxy tests do not 400 the
1343 // upstream Upgrade.
1344 CheckOrigin: func(*http.Request) bool { return true },
1345 }
1346 mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
1347 if beforeUpgrade != nil {
1348 beforeUpgrade(w, r)
1349 }
1350 sess, err := wtServer.Upgrade(w, r)
1351 if err != nil {
1352 t.Logf("standalone WebTransport upgrade failed: %v", err)
1353 w.WriteHeader(http.StatusBadRequest)
1354 return
1355 }
1356 handler(sess, r)
1357 })
1358
1359 udpAddr, err := net.ResolveUDPAddr("udp", "127.0.0.1:0")
1360 if err != nil {
1361 t.Fatal(err)
1362 }
1363 conn, err := net.ListenUDP("udp", udpAddr)
1364 if err != nil {
1365 t.Fatal(err)
1366 }
1367 servErr := make(chan error, 1)
1368 go func() { servErr <- wtServer.Serve(conn) }()
1369 shutdown := func() {
1370 _ = wtServer.Close()
1371 <-servErr
1372 _ = conn.Close()
1373 }
1374 return conn.LocalAddr().(*net.UDPAddr), shutdown
1375 }
1376
1377 // newSelfSignedTLSConfig produces a self-signed TLS config suitable for
1378 // 127.0.0.1 and the given common name, with the H3 ALPN advertised.
1379 func newSelfSignedTLSConfig(t *testing.T, cn string) *tls.Config {
1380 t.Helper()
1381 priv, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
1382 if err != nil {
1383 t.Fatal(err)
1384 }
1385 tmpl := x509.Certificate{
1386 SerialNumber: big.NewInt(1),
1387 Subject: pkix.Name{CommonName: cn},
1388 NotBefore: time.Now().Add(-time.Minute),
1389 NotAfter: time.Now().Add(time.Hour),
1390 KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageCertSign,
1391 ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
1392 DNSNames: []string{cn},
1393 IPAddresses: []net.IP{net.ParseIP("127.0.0.1"), net.ParseIP("::1")},
1394 IsCA: true,
1395 }
1396 der, err := x509.CreateCertificate(rand.Reader, &tmpl, &tmpl, priv.Public(), priv)
1397 if err != nil {
1398 t.Fatal(err)
1399 }
1400 cert, err := x509.ParseCertificate(der)
1401 if err != nil {
1402 t.Fatal(err)
1403 }
1404 return &tls.Config{
1405 Certificates: []tls.Certificate{{Certificate: [][]byte{der}, PrivateKey: priv, Leaf: cert}},
1406 NextProtos: []string{http3.NextProtoH3},
1407 }
1408 }