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"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"mime"
|
"mime"
|
||||||
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"net/netip"
|
"net/netip"
|
||||||
@@ -22,6 +23,7 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"reflect"
|
"reflect"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"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) {
|
func TestServeHTTPRedirect(t *testing.T) {
|
||||||
b := newTestBackend(t)
|
b := newTestBackend(t)
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user