Files

618 lines
20 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 reverseproxy
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"reflect"
"runtime"
"strings"
"sync"
"testing"
"time"
"github.com/caddyserver/caddy/v2"
)
// trailerTestBody models net/http populating the original request's trailers
// during Read, including readers which return final bytes together with EOF.
type trailerTestBody struct {
io.Reader
finish func()
err error
closed bool
}
func (b *trailerTestBody) Read(p []byte) (int, error) {
if b.closed {
return 0, errors.New("body closed")
}
n, err := b.Reader.Read(p)
if err == io.EOF {
if b.finish != nil {
b.finish()
b.finish = nil
}
if b.err != nil {
err = b.err
}
}
return n, err
}
func (b *trailerTestBody) Close() error { b.closed = true; return nil }
type finalBytesReader struct{ *strings.Reader }
func (r finalBytesReader) Read(p []byte) (int, error) {
n, err := r.Reader.Read(p)
if r.Len() == 0 {
err = io.EOF
}
return n, err
}
func TestRequestTrailersEOF(t *testing.T) {
for _, limit := range []int64{0, 2, 32, -1} {
for _, declared := range []bool{false, true} {
for _, finalBytes := range []bool{false, true} {
t.Run(fmt.Sprintf("buffer=%d/declared=%t/final-bytes=%t", limit, declared, finalBytes), func(t *testing.T) {
req := prepareTestRequest(httptest.NewRequest(http.MethodPut, "http://example.com/", nil))
req.ContentLength = -1
req.ProtoMajor = 2
if declared {
req.Trailer = http.Header{"Content-Digest": nil, "X-Checksum": nil, "X-Absent": nil}
}
want := http.Header{"Content-Digest": {"digest-one", "digest-two"}, "X-Checksum": {"checksum"}}
if declared {
want["X-Absent"] = nil
}
var reader io.Reader = strings.NewReader("hello")
if finalBytes {
reader = finalBytesReader{strings.NewReader("hello")}
}
body := &trailerTestBody{Reader: reader, finish: func() { req.Trailer = want.Clone() }}
req.Body = body
h := Handler{RequestBuffers: limit}
out, err := h.prepareRequest(req, caddy.NewReplacer())
if err != nil {
t.Fatal(err)
}
defer out.Body.Close()
if limit == 0 && body.finish == nil {
t.Fatal("prepareRequest consumed streaming body")
}
got, err := io.ReadAll(out.Body)
if err != nil || string(got) != "hello" {
t.Fatalf("body=%q err=%v", got, err)
}
for range 2 {
n, err := out.Body.Read(make([]byte, 8))
if n != 0 || err != io.EOF {
t.Fatalf("repeated read: n=%d err=%v", n, err)
}
}
if !reflect.DeepEqual(out.Trailer, want) {
t.Fatalf("trailers=%v, want %v", out.Trailer, want)
}
out.Trailer["Content-Digest"][0] = "outbound change"
out.Trailer.Set("X-New", "outbound only")
if !reflect.DeepEqual(req.Trailer, want) {
t.Fatalf("outbound changes mutated incoming trailers: %v", req.Trailer)
}
})
}
}
}
}
func TestRequestTrailersReadError(t *testing.T) {
for _, readErr := range []error{io.ErrUnexpectedEOF, context.Canceled} {
for _, limit := range []int64{0, 2, 32, -1} {
t.Run(fmt.Sprintf("%v/buffer=%d", readErr, limit), func(t *testing.T) {
req := prepareTestRequest(httptest.NewRequest(http.MethodPut, "http://example.com/", nil))
req.ContentLength = -1
req.Trailer = http.Header{"Content-Digest": nil}
req.Body = &trailerTestBody{Reader: strings.NewReader("partial"), err: readErr, finish: func() {
req.Trailer.Set("Content-Digest", "must not forward without EOF")
}}
out, err := (Handler{RequestBuffers: limit}).prepareRequest(req, caddy.NewReplacer())
if err != nil {
t.Fatal(err)
}
defer out.Body.Close()
_, err = io.ReadAll(out.Body)
// Unlimited buffering currently discards read errors; do not change
// that existing policy here. It must still not publish trailers.
if limit >= 0 && !errors.Is(err, readErr) {
t.Fatalf("read error=%v, want %v", err, readErr)
}
if len(out.Trailer.Values("Content-Digest")) != 0 {
t.Fatalf("published trailers after failed read: %v", out.Trailer)
}
})
}
}
}
type trailerObservation struct {
Body string
Trailer http.Header
Header string
Proto string
}
func TestRequestTrailersNetwork(t *testing.T) {
for _, network := range []string{"tcp", "unix"} {
t.Run(network, func(t *testing.T) {
if network == "unix" && runtime.GOOS == "windows" {
t.Skip("Unix sockets are not supported by this test on Windows")
}
backend := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
body, err := io.ReadAll(r.Body)
if err != nil {
http.Error(w, err.Error(), 500)
return
}
_ = json.NewEncoder(w).Encode(trailerObservation{string(body), r.Trailer, r.Header.Get("X-Control"), r.Proto})
}))
dial := backend.Listener.Addr().String()
if network == "unix" {
_ = backend.Listener.Close()
// Keep the socket path below the platform's sockaddr_un limit.
dir, err := os.MkdirTemp("", "caddy-trailers-")
if err != nil {
t.Fatal(err)
}
defer os.RemoveAll(dir)
backend.Listener, err = net.Listen("unix", filepath.Join(dir, "upstream.sock"))
if err != nil {
t.Fatal(err)
}
dial = "unix/" + backend.Listener.Addr().String()
}
backend.Start()
defer backend.Close()
for _, limit := range []int64{0, 2, 1 << 20, -1} {
t.Run(fmt.Sprintf("buffer=%d", limit), func(t *testing.T) {
ctx, cancel := caddy.NewContext(caddy.Context{Context: context.Background()})
defer cancel()
transport := &HTTPTransport{}
var err error
transport.Transport, err = transport.NewTransport(ctx)
if err != nil {
t.Fatal(err)
}
defer transport.Transport.CloseIdleConnections()
h := minimalHandler(0, &Upstream{Host: new(Host), Dial: dial})
h.Transport = transport
h.RequestBuffers = limit
front := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("X-Client-Protocol", r.Proto)
if r.ProtoMajor == 1 && len(r.TransferEncoding) != 1 {
t.Errorf("HTTP/1 request is not chunked: %v", r.TransferEncoding)
}
if err := h.ServeHTTP(w, prepareTestRequest(r), nil); err != nil {
http.Error(w, err.Error(), 502)
}
}))
front.EnableHTTP2 = true
front.StartTLS()
defer front.Close()
for _, proto := range []int{1, 2} {
tr := front.Client().Transport.(*http.Transport).Clone()
tr.ForceAttemptHTTP2 = proto == 2
tr.Protocols = new(http.Protocols)
tr.Protocols.SetHTTP1(proto == 1)
tr.Protocols.SetHTTP2(proto == 2)
tr.TLSClientConfig.NextProtos = nil
client := &http.Client{Transport: tr, Timeout: 5 * time.Second}
defer tr.CloseIdleConnections()
for _, body := range []string{"", "hello, trailers"} {
for _, trailers := range []http.Header{
nil,
{"Content-Digest": {"first", "second"}, "X-Checksum": {"checksum"}, "X-Absent": nil},
} {
req, err := http.NewRequest(http.MethodPut, front.URL, io.NopCloser(strings.NewReader(body)))
if err != nil {
t.Fatal(err)
}
req.ContentLength = -1
req.Trailer = trailers.Clone()
req.Header.Set("X-Control", "ordinary header")
res, err := client.Do(req)
if err != nil {
t.Fatal(err)
}
var got trailerObservation
err = json.NewDecoder(res.Body).Decode(&got)
_ = res.Body.Close()
if err != nil {
t.Fatalf("decode status=%d: %v", res.StatusCode, err)
}
wantProto := fmt.Sprintf("HTTP/%d.%d", proto, 1)
if proto == 2 {
wantProto = "HTTP/2.0"
}
if res.Proto != wantProto || res.Header.Get("X-Client-Protocol") != wantProto {
t.Fatalf("negotiated protocol: response=%s request=%s want=%s", res.Proto, res.Header.Get("X-Client-Protocol"), wantProto)
}
if res.StatusCode != 200 || got.Body != body || got.Header != "ordinary header" || got.Proto != "HTTP/1.1" {
t.Fatalf("status=%d observation=%+v", res.StatusCode, got)
}
// An announced but absent field can be represented as nil
// or one empty value by the HTTP/1 transport.
if len(trailers) == 0 {
if len(got.Trailer) != 0 {
t.Fatalf("invented trailers: %v", got.Trailer)
}
} else {
want := trailers.Clone()
delete(want, "X-Absent")
if got.Trailer.Get("X-Absent") != "" {
t.Fatalf("absent trailer has value: %v", got.Trailer)
}
delete(got.Trailer, "X-Absent")
if !reflect.DeepEqual(got.Trailer, want) {
t.Fatalf("protocol=%s body=%q trailers=%v want=%v", wantProto, body, got.Trailer, want)
}
}
}
}
}
})
}
})
}
}
// trailerRoundTripper makes retry failures deterministic without relying on
// racing the operating system to reserve an unused port.
type trailerRoundTripper func(*http.Request) (*http.Response, error)
func (f trailerRoundTripper) RoundTrip(r *http.Request) (*http.Response, error) { return f(r) }
func TestRequestTrailersRetry(t *testing.T) {
for _, tc := range []struct {
name string
limit int64
dialFailure bool
partial bool
}{
{name: "dial failure", dialFailure: true},
{name: "dial failure with partial buffering", limit: 2, dialFailure: true},
{name: "fully buffered replay", limit: 32},
{name: "unlimited buffered replay", limit: -1},
{name: "consumed stream cannot replay", partial: true},
{name: "partially buffered stream cannot replay", limit: 2, partial: true},
} {
t.Run(tc.name, func(t *testing.T) {
const payload = "hello, retry trailers"
want := http.Header{"Content-Digest": {"first", "second"}, "X-Checksum": {"checksum"}}
observed := make(chan trailerObservation, 1)
backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
body, err := io.ReadAll(r.Body)
if err != nil {
http.Error(w, err.Error(), 500)
return
}
observed <- trailerObservation{Body: string(body), Trailer: r.Trailer}
w.WriteHeader(http.StatusOK)
}))
defer backend.Close()
h := minimalHandler(1, &Upstream{Host: new(Host), Dial: backend.Listener.Addr().String()})
h.RequestBuffers = tc.limit
transport := testTransport{&http.Transport{}}
defer transport.CloseIdleConnections()
attempts := 0
h.Transport = trailerRoundTripper(func(r *http.Request) (*http.Response, error) {
attempts++
if attempts == 1 {
if !tc.dialFailure {
if tc.partial {
if _, err := io.ReadFull(r.Body, make([]byte, 3)); err != nil {
t.Errorf("initial partial read: %v", err)
}
} else {
got, err := io.ReadAll(r.Body)
if err != nil || string(got) != payload || !reflect.DeepEqual(r.Trailer, want) {
t.Errorf("initial attempt: body=%q trailers=%v err=%v", got, r.Trailer, err)
}
}
}
_ = r.Body.Close()
err := errors.New("controlled first-attempt failure")
if tc.dialFailure {
return nil, DialError{err}
}
return nil, err
}
if tc.partial {
_, err := io.ReadAll(r.Body)
if err == nil {
t.Error("partially consumed request body became replayable")
}
return nil, errors.New("consumed body is not replayable")
}
return transport.RoundTrip(r)
})
req := prepareTestRequest(httptest.NewRequest(http.MethodGet, "http://example.com/", nil))
req.ContentLength = -1
req.Trailer = http.Header{"Content-Digest": nil, "X-Checksum": nil}
req.Body = &trailerTestBody{Reader: strings.NewReader(payload), finish: func() { req.Trailer = want.Clone() }}
defer req.Body.Close()
ctx, cancel := context.WithTimeout(req.Context(), 5*time.Second)
defer cancel()
req = req.WithContext(ctx)
rec := httptest.NewRecorder()
err := h.ServeHTTP(rec, req, nil)
if attempts != 2 {
t.Fatalf("attempts=%d, want 2", attempts)
}
if tc.partial {
if err == nil {
t.Fatal("partially consumed body was accepted for replay")
}
select {
case got := <-observed:
t.Fatalf("unexpected successful request: %+v", got)
default:
}
return
}
if err != nil {
t.Fatal(err)
}
select {
case got := <-observed:
if got.Body != payload || !reflect.DeepEqual(got.Trailer, want) {
t.Fatalf("replay=%+v, want body=%q trailers=%v", got, payload, want)
}
default:
t.Fatal("upstream did not receive the replay")
}
})
}
}
func TestRequestTrailersStreaming(t *testing.T) {
for _, limit := range []int64{0, 2} {
t.Run(fmt.Sprintf("buffer=%d", limit), func(t *testing.T) {
prefixRead := make(chan struct{})
backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
prefix := make([]byte, 5)
if _, err := io.ReadFull(r.Body, prefix); err != nil {
http.Error(w, err.Error(), 500)
return
}
close(prefixRead)
rest, err := io.ReadAll(r.Body)
if err != nil {
http.Error(w, err.Error(), 500)
return
}
_ = json.NewEncoder(w).Encode(trailerObservation{Body: string(prefix) + string(rest), Trailer: r.Trailer})
}))
defer backend.Close()
h := minimalHandler(0, &Upstream{Host: new(Host), Dial: backend.Listener.Addr().String()})
defer h.Transport.(testTransport).CloseIdleConnections()
h.RequestBuffers = limit
front := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if err := h.ServeHTTP(w, prepareTestRequest(r), nil); err != nil {
http.Error(w, err.Error(), 502)
}
}))
defer front.Close()
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
reader, writer := io.Pipe()
defer reader.Close()
defer writer.Close()
writeDone := make(chan error, 1)
go func() {
_, err := io.WriteString(writer, "hello")
if err == nil {
select {
case <-prefixRead:
_, err = io.WriteString(writer, ", streamed")
case <-ctx.Done():
err = ctx.Err()
}
}
_ = writer.CloseWithError(err)
writeDone <- err
}()
req, err := http.NewRequestWithContext(ctx, http.MethodPut, front.URL, reader)
if err != nil {
t.Fatal(err)
}
req.Trailer = http.Header{"Content-Digest": {"stream digest"}}
client := front.Client()
client.Timeout = 5 * time.Second
res, err := client.Do(req)
if err != nil {
t.Fatal(err)
}
defer res.Body.Close()
var got trailerObservation
if err := json.NewDecoder(res.Body).Decode(&got); err != nil {
t.Fatal(err)
}
if got.Body != "hello, streamed" || !reflect.DeepEqual(got.Trailer, req.Trailer) {
t.Fatalf("streamed request=%+v", got)
}
if err := <-writeDone; err != nil {
t.Fatal(err)
}
})
}
}
// trailerCloseTracker lets tests wait for transport cleanup, including its
// background request writer, without polling goroutine counts.
type trailerCloseTracker struct {
io.ReadCloser
once sync.Once
readOnce sync.Once
closed chan struct{}
readDone chan struct{}
}
func (b *trailerCloseTracker) Read(p []byte) (int, error) {
n, err := b.ReadCloser.Read(p)
if err != nil {
b.readOnce.Do(func() { close(b.readDone) })
}
return n, err
}
func (b *trailerCloseTracker) Close() error {
err := b.ReadCloser.Close()
b.once.Do(func() { close(b.closed) })
return err
}
func TestRequestTrailersEarlyResponse(t *testing.T) {
backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if err := http.NewResponseController(w).EnableFullDuplex(); err != nil {
http.Error(w, err.Error(), 500)
return
}
// Ensure the upload has started, then respond while the rest is blocked.
if _, err := io.ReadFull(r.Body, make([]byte, 1)); err != nil {
http.Error(w, err.Error(), 500)
return
}
w.WriteHeader(http.StatusForbidden)
_ = http.NewResponseController(w).Flush()
}))
defer backend.Close()
h := minimalHandler(0, &Upstream{Host: new(Host), Dial: backend.Listener.Addr().String()})
defer h.Transport.(testTransport).CloseIdleConnections()
reader, writer := io.Pipe()
defer reader.Close()
defer writer.Close()
req := prepareTestRequest(httptest.NewRequest(http.MethodPut, "http://example.com/", reader))
body := &trailerCloseTracker{ReadCloser: reader, closed: make(chan struct{}), readDone: make(chan struct{})}
req.Body = body
req.ContentLength = -1
req.Trailer = http.Header{"Content-Digest": nil}
ctx, cancel := context.WithTimeout(req.Context(), 5*time.Second)
defer cancel()
req = req.WithContext(ctx)
writeDone := make(chan error, 1)
go func() {
_, err := io.WriteString(writer, "x")
writeDone <- err
}()
rec := httptest.NewRecorder()
if err := h.ServeHTTP(rec, req, nil); err != nil {
t.Fatal(err)
}
if rec.Code != http.StatusForbidden {
t.Fatalf("status=%d", rec.Code)
}
if err := <-writeDone; err != nil {
t.Fatal(err)
}
// The HTTP server owns the incoming body and closes it after the
// proxy returns. Model that ownership here; the transport may still be
// reading the unfinished upload when an early response arrives.
_ = req.Body.Close()
select {
case <-body.readDone:
case <-ctx.Done():
t.Fatal("interrupted upload read did not stop")
}
if req.Trailer.Get("Content-Digest") != "" {
t.Fatal("early response invented trailers")
}
}
func TestRequestTrailersCancellation(t *testing.T) {
started := make(chan struct{})
backendDone := make(chan struct{})
backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
defer close(backendDone)
if _, err := io.ReadFull(r.Body, make([]byte, 1)); err != nil {
return
}
close(started)
_, err := io.Copy(io.Discard, r.Body)
if err == nil {
t.Error("canceled upstream body unexpectedly reached EOF")
}
if r.Trailer.Get("Content-Digest") != "" {
t.Error("cancellation published trailer values")
}
}))
defer backend.Close()
h := minimalHandler(0, &Upstream{Host: new(Host), Dial: backend.Listener.Addr().String()})
defer h.Transport.(testTransport).CloseIdleConnections()
reader, writer := io.Pipe()
defer reader.Close()
defer writer.Close()
body := &trailerCloseTracker{ReadCloser: reader, closed: make(chan struct{}), readDone: make(chan struct{})}
req := prepareTestRequest(httptest.NewRequest(http.MethodPut, "http://example.com/", body))
req.ContentLength = -1
req.Trailer = http.Header{"Content-Digest": nil}
timeout, stop := context.WithTimeout(req.Context(), 5*time.Second)
defer stop()
ctx, cancel := context.WithCancel(timeout)
defer cancel()
req = req.WithContext(ctx)
writeDone := make(chan error, 1)
go func() { _, err := io.WriteString(writer, "x"); writeDone <- err }()
rec := httptest.NewRecorder()
done := make(chan error, 1)
go func() { done <- h.ServeHTTP(rec, req, nil) }()
select {
case <-started:
case <-timeout.Done():
t.Fatal("upstream never received upload prefix")
}
cancel()
// A real client disconnect also breaks the incoming socket read. Canceling
// only a synthetic request context cannot unblock an unrelated io.Pipe.
_ = writer.CloseWithError(context.Canceled)
select {
case err := <-done:
if err != nil || rec.Code != 499 {
t.Fatalf("cancellation: status=%d error=%v", rec.Code, err)
}
case <-timeout.Done():
t.Fatal("proxy did not stop after cancellation")
}
for _, ch := range []<-chan struct{}{body.closed, body.readDone, backendDone} {
select {
case <-ch:
case <-timeout.Done():
t.Fatal("upload cleanup did not complete")
}
}
if err := <-writeDone; err != nil {
t.Fatal(err)
}
}