ipn/ipnlocal: add HTTP/2 Content-Type tests for serve reverse proxy (#19905)
Adds two tests exercising the HTTP/2-inbound -> plaintext HTTP/1.1 backend path through serve's reverseProxy and through the full serveWebHandler entry point (with a funnel serveHTTPContext). Updates #19866 Signed-off-by: Brendan Creane <bcreane@gmail.com>
This commit is contained in:
@@ -15,6 +15,7 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"mime"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/netip"
|
||||
@@ -22,6 +23,7 @@ import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
@@ -1361,6 +1363,227 @@ func TestServeGRPCProxy(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestServeProxyHTTP2PreservesContentType is a repro for tailscale/tailscale#19866.
|
||||
// A client POSTs over HTTP/2 to a serve listener configured to reverse-proxy to a
|
||||
// plaintext HTTP/1.1 backend; the test asserts the backend sees the Content-Type
|
||||
// header that the client sent.
|
||||
func TestServeProxyHTTP2PreservesContentType(t *testing.T) {
|
||||
var (
|
||||
gotHeader http.Header
|
||||
gotMethod string
|
||||
gotProto string
|
||||
gotBody string
|
||||
)
|
||||
backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
gotHeader = r.Header.Clone()
|
||||
gotMethod = r.Method
|
||||
gotProto = r.Proto
|
||||
b, _ := io.ReadAll(r.Body)
|
||||
gotBody = string(b)
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
defer backend.Close()
|
||||
backendURL := must.Get(url.Parse(backend.URL))
|
||||
|
||||
lb := newTestBackend(t)
|
||||
rp := &reverseProxy{
|
||||
logf: t.Logf,
|
||||
url: backendURL,
|
||||
backend: backend.URL,
|
||||
lb: lb,
|
||||
}
|
||||
|
||||
// Mirror serveWebHandler's behavior for a mount of "/" with a non-"/" path:
|
||||
// it wraps the proxy in http.StripPrefix("", rp). That's a no-op for "/mcp"
|
||||
// but worth exercising in case StripPrefix interacts weirdly with HTTP/2.
|
||||
frontHandler := http.StripPrefix("", http.HandlerFunc(rp.ServeHTTP))
|
||||
front := httptest.NewUnstartedServer(frontHandler)
|
||||
front.EnableHTTP2 = true
|
||||
front.StartTLS()
|
||||
defer front.Close()
|
||||
|
||||
const reqBody = `{"jsonrpc":"2.0","id":1,"method":"initialize","params":{}}`
|
||||
|
||||
type variant struct {
|
||||
name string
|
||||
http1Only bool
|
||||
contentType string
|
||||
setAccept bool
|
||||
setContentLen bool // if false, body is a Reader with unknown length (chunked)
|
||||
dropContentLen bool
|
||||
emptyBody bool
|
||||
}
|
||||
variants := []variant{
|
||||
{name: "baseline_http2", contentType: "application/json", setAccept: true, setContentLen: true},
|
||||
{name: "http1_baseline", http1Only: true, contentType: "application/json", setAccept: true, setContentLen: true},
|
||||
{name: "no_accept", contentType: "application/json", setContentLen: true},
|
||||
{name: "charset_param", contentType: "application/json; charset=utf-8", setAccept: true, setContentLen: true},
|
||||
{name: "chunked", contentType: "application/json", setAccept: true, setContentLen: false},
|
||||
{name: "drop_content_length", contentType: "application/json", setAccept: true, dropContentLen: true},
|
||||
{name: "empty_body", contentType: "application/json", setAccept: true, emptyBody: true},
|
||||
}
|
||||
|
||||
for _, v := range variants {
|
||||
t.Run(v.name, func(t *testing.T) {
|
||||
gotHeader = nil
|
||||
gotMethod = ""
|
||||
gotProto = ""
|
||||
gotBody = ""
|
||||
|
||||
var body io.Reader
|
||||
switch {
|
||||
case v.emptyBody:
|
||||
body = http.NoBody
|
||||
case v.setContentLen:
|
||||
body = strings.NewReader(reqBody)
|
||||
default:
|
||||
// io.Reader that isn't a *bytes.Reader / *strings.Reader / *bytes.Buffer
|
||||
// so net/http does not auto-set Content-Length.
|
||||
body = struct{ io.Reader }{strings.NewReader(reqBody)}
|
||||
}
|
||||
|
||||
url := front.URL + "/mcp"
|
||||
req, err := http.NewRequest("POST", url, body)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
req.Header.Set("Content-Type", v.contentType)
|
||||
if v.setAccept {
|
||||
req.Header.Set("Accept", "application/json, text/event-stream")
|
||||
}
|
||||
if v.dropContentLen {
|
||||
req.ContentLength = -1
|
||||
}
|
||||
|
||||
client := front.Client()
|
||||
if v.http1Only {
|
||||
// Force the client onto HTTP/1.1 by removing h2 from ALPN.
|
||||
tr := client.Transport.(*http.Transport).Clone()
|
||||
if tr.TLSClientConfig != nil {
|
||||
tr.TLSClientConfig = tr.TLSClientConfig.Clone()
|
||||
tr.TLSClientConfig.NextProtos = []string{"http/1.1"}
|
||||
}
|
||||
tr.ForceAttemptHTTP2 = false
|
||||
client = &http.Client{Transport: tr}
|
||||
}
|
||||
|
||||
res, err := client.Do(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer res.Body.Close()
|
||||
io.Copy(io.Discard, res.Body)
|
||||
|
||||
wantProtoMajor := 2
|
||||
if v.http1Only {
|
||||
wantProtoMajor = 1
|
||||
}
|
||||
if res.ProtoMajor != wantProtoMajor {
|
||||
t.Fatalf("front-end proto = %s, want HTTP/%d.x", res.Proto, wantProtoMajor)
|
||||
}
|
||||
if res.StatusCode != http.StatusOK {
|
||||
t.Fatalf("backend returned status %d, want 200", res.StatusCode)
|
||||
}
|
||||
if gotMethod != "POST" {
|
||||
t.Errorf("backend method = %q, want POST", gotMethod)
|
||||
}
|
||||
t.Logf("backend saw proto=%q content-length=%q transfer-encoding=%v body=%q",
|
||||
gotProto, gotHeader.Get("Content-Length"), gotHeader.Values("Transfer-Encoding"), gotBody)
|
||||
if got, want := gotHeader.Get("Content-Type"), v.contentType; got != want {
|
||||
t.Errorf("Content-Type at backend = %q, want %q (full headers: %v)", got, want, gotHeader)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestServeWebHandlerHTTP2PreservesContentType drives the full
|
||||
// b.serveWebHandler entry point (the actual production code path) via a real
|
||||
// HTTP/2 TLS frontend, with serveHTTPContext.Funnel set to mimic a funnel
|
||||
// request. Repro probe for tailscale/tailscale#19866.
|
||||
func TestServeWebHandlerHTTP2PreservesContentType(t *testing.T) {
|
||||
var gotHeader http.Header
|
||||
backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
gotHeader = r.Header.Clone()
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
defer backend.Close()
|
||||
|
||||
b := newTestBackend(t)
|
||||
|
||||
front := httptest.NewUnstartedServer(http.HandlerFunc(b.serveWebHandler))
|
||||
front.EnableHTTP2 = true
|
||||
realFrontAddr := front.Listener.Addr().String()
|
||||
_, portStr, _ := net.SplitHostPort(realFrontAddr)
|
||||
p, _ := strconv.ParseUint(portStr, 10, 16)
|
||||
frontPort := uint16(p)
|
||||
front.Config.BaseContext = func(_ net.Listener) context.Context {
|
||||
return serveHTTPContextKey.WithValue(context.Background(), &serveHTTPContext{
|
||||
Funnel: &funnelFlow{Host: "example.ts.net"},
|
||||
SrcAddr: netip.MustParseAddrPort("1.2.3.4:1234"),
|
||||
DestPort: frontPort,
|
||||
})
|
||||
}
|
||||
front.StartTLS()
|
||||
defer front.Close()
|
||||
|
||||
conf := &ipn.ServeConfig{
|
||||
Web: map[ipn.HostPort]*ipn.WebServerConfig{
|
||||
ipn.HostPort(net.JoinHostPort("example.ts.net", portStr)): {Handlers: map[string]*ipn.HTTPHandler{
|
||||
"/": {Proxy: backend.URL},
|
||||
}},
|
||||
},
|
||||
}
|
||||
if err := b.SetServeConfig(conf, ""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Custom client: dial example.ts.net:PORT to the real httptest address,
|
||||
// skip cert verification, and send SNI "example.ts.net" so the serveConfig
|
||||
// host lookup works.
|
||||
tr := front.Client().Transport.(*http.Transport).Clone()
|
||||
tr.DialContext = func(ctx context.Context, network, addr string) (net.Conn, error) {
|
||||
var d net.Dialer
|
||||
return d.DialContext(ctx, "tcp", realFrontAddr)
|
||||
}
|
||||
tr.TLSClientConfig = &tls.Config{
|
||||
InsecureSkipVerify: true,
|
||||
ServerName: "example.ts.net",
|
||||
NextProtos: []string{"h2", "http/1.1"},
|
||||
}
|
||||
tr.ForceAttemptHTTP2 = true
|
||||
client := &http.Client{Transport: tr}
|
||||
|
||||
const reqBody = `{"jsonrpc":"2.0","id":1,"method":"initialize","params":{}}`
|
||||
url := "https://example.ts.net:" + portStr + "/mcp"
|
||||
req, err := http.NewRequest("POST", url, strings.NewReader(reqBody))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Accept", "application/json, text/event-stream")
|
||||
|
||||
res, err := client.Do(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer res.Body.Close()
|
||||
body, _ := io.ReadAll(res.Body)
|
||||
|
||||
if res.ProtoMajor != 2 {
|
||||
t.Fatalf("front-end proto = %s, want HTTP/2", res.Proto)
|
||||
}
|
||||
if res.StatusCode != http.StatusOK {
|
||||
t.Fatalf("status %d, want 200; body=%q", res.StatusCode, body)
|
||||
}
|
||||
t.Logf("backend headers (full): %v", gotHeader)
|
||||
if got, want := gotHeader.Get("Content-Type"), "application/json"; got != want {
|
||||
t.Errorf("Content-Type at backend = %q, want %q", got, want)
|
||||
}
|
||||
if got := gotHeader.Get("Tailscale-Funnel-Request"); got != "?1" {
|
||||
t.Errorf("Tailscale-Funnel-Request = %q, want %q (sanity check)", got, "?1")
|
||||
}
|
||||
}
|
||||
|
||||
func TestServeHTTPRedirect(t *testing.T) {
|
||||
b := newTestBackend(t)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user