Files
mc-proxy/internal/l7/serve_test.go
Kyle Isom b6c96ad42f Add integration tests for multi-hop, gRPC trailers, and HTTP/1.1
Multi-hop integration tests (server package):
- TestMultiHopProxyProtocol: full edge→origin deployment with two
  mc-proxy instances. Edge uses L4 passthrough with send_proxy_protocol,
  origin has proxy_protocol listener with L7 route. Verifies the real
  client IP (127.0.0.1) flows through PROXY protocol into the origin's
  X-Forwarded-For header on the h2c backend.
- TestMultiHopFirewallBlocksRealIP: origin firewall blocks an IP from
  the PROXY header while allowing the TCP peer (edge proxy). Verifies
  the backend is never reached.

L7 package integration tests:
- TestL7LargeResponse: 1 MB response through the reverse proxy.
- TestL7GRPCTrailers: HTTP/2 trailer propagation (Grpc-Status,
  Grpc-Message) through the reverse proxy, validating gRPC
  compatibility.
- TestL7HTTP11Fallback: client negotiates HTTP/1.1 only (no h2 ALPN),
  verifies the proxy falls back to HTTP/1.1 serving and still
  forwards to the h2c backend successfully.

Also updates PROGRESS.md to mark all five phases complete.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 14:02:45 -07:00

529 lines
14 KiB
Go

package l7
import (
"context"
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/tls"
"crypto/x509"
"crypto/x509/pkix"
"encoding/pem"
"fmt"
"io"
"log/slog"
"math/big"
"net"
"net/http"
"net/netip"
"os"
"path/filepath"
"testing"
"time"
"golang.org/x/net/http2"
"golang.org/x/net/http2/h2c"
)
// testCert generates a self-signed TLS certificate for the given hostname
// and writes the cert/key to temporary files, returning their paths.
func testCert(t *testing.T, hostname string) (certPath, keyPath string) {
t.Helper()
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
t.Fatalf("generating key: %v", err)
}
tmpl := &x509.Certificate{
SerialNumber: big.NewInt(1),
Subject: pkix.Name{CommonName: hostname},
DNSNames: []string{hostname},
NotBefore: time.Now().Add(-time.Hour),
NotAfter: time.Now().Add(time.Hour),
KeyUsage: x509.KeyUsageDigitalSignature,
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
}
certDER, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &key.PublicKey, key)
if err != nil {
t.Fatalf("creating certificate: %v", err)
}
dir := t.TempDir()
certPath = filepath.Join(dir, "cert.pem")
keyPath = filepath.Join(dir, "key.pem")
certFile, err := os.Create(certPath)
if err != nil {
t.Fatalf("creating cert file: %v", err)
}
pem.Encode(certFile, &pem.Block{Type: "CERTIFICATE", Bytes: certDER})
certFile.Close()
keyDER, err := x509.MarshalECPrivateKey(key)
if err != nil {
t.Fatalf("marshaling key: %v", err)
}
keyFile, err := os.Create(keyPath)
if err != nil {
t.Fatalf("creating key file: %v", err)
}
pem.Encode(keyFile, &pem.Block{Type: "EC PRIVATE KEY", Bytes: keyDER})
keyFile.Close()
return certPath, keyPath
}
// startH2CBackend starts an h2c (HTTP/2 cleartext) backend server that
// responds with the given status and body. Returns the listener address.
func startH2CBackend(t *testing.T, handler http.Handler) string {
t.Helper()
h2s := &http2.Server{}
srv := &http.Server{
Handler: h2c.NewHandler(handler, h2s),
ReadHeaderTimeout: 5 * time.Second,
}
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("listen: %v", err)
}
t.Cleanup(func() {
srv.Close()
ln.Close()
})
go srv.Serve(ln)
return ln.Addr().String()
}
// dialTLSToProxy dials a TCP connection to the proxy, does a TLS handshake
// with the given serverName (skipping cert verification for self-signed),
// and returns an *http.Client configured to use that connection for HTTP/2.
func dialTLSToProxy(t *testing.T, proxyAddr, serverName string) *http.Client {
t.Helper()
tlsConf := &tls.Config{
ServerName: serverName,
InsecureSkipVerify: true,
NextProtos: []string{"h2"},
}
conn, err := tls.DialWithDialer(
&net.Dialer{Timeout: 5 * time.Second},
"tcp", proxyAddr, tlsConf,
)
if err != nil {
t.Fatalf("TLS dial: %v", err)
}
t.Cleanup(func() { conn.Close() })
// Create an HTTP/2 client transport over this single connection.
tr := &http2.Transport{}
h2conn, err := tr.NewClientConn(conn)
if err != nil {
t.Fatalf("creating h2 client conn: %v", err)
}
return &http.Client{
Transport: &singleConnRoundTripper{cc: h2conn},
}
}
// singleConnRoundTripper is an http.RoundTripper that uses a single HTTP/2
// client connection.
type singleConnRoundTripper struct {
cc *http2.ClientConn
}
func (s *singleConnRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) {
return s.cc.RoundTrip(req)
}
// serveL7Route starts l7.Serve in a goroutine for a single connection.
// Returns when the goroutine completes.
func serveL7Route(t *testing.T, conn net.Conn, peeked []byte, route RouteConfig) {
t.Helper()
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
clientAddr := netip.MustParseAddrPort("203.0.113.50:12345")
ctx := context.Background()
go func() {
l7Err := Serve(ctx, conn, peeked, route, clientAddr, logger)
if l7Err != nil {
t.Logf("l7.Serve: %v", l7Err)
}
}()
}
func TestL7H2CBackend(t *testing.T) {
certPath, keyPath := testCert(t, "l7.test")
// Start an h2c backend.
backendAddr := startH2CBackend(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("X-Backend", "ok")
fmt.Fprintf(w, "hello from backend, path=%s", r.URL.Path)
}))
// Start a TCP listener for the L7 proxy.
proxyLn, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("proxy listen: %v", err)
}
defer proxyLn.Close()
route := RouteConfig{
Backend: backendAddr,
TLSCert: certPath,
TLSKey: keyPath,
ConnectTimeout: 5 * time.Second,
}
// Accept one connection and run L7 serve.
go func() {
conn, err := proxyLn.Accept()
if err != nil {
return
}
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
clientAddr := netip.MustParseAddrPort("203.0.113.50:12345")
// No peeked bytes — the client is connecting directly with TLS.
Serve(context.Background(), conn, nil, route, clientAddr, logger)
}()
// Connect as an HTTP/2 TLS client.
client := dialTLSToProxy(t, proxyLn.Addr().String(), "l7.test")
resp, err := client.Get(fmt.Sprintf("https://l7.test/foo"))
if err != nil {
t.Fatalf("GET: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
t.Fatalf("status = %d, want 200", resp.StatusCode)
}
body, _ := io.ReadAll(resp.Body)
if got := string(body); got != "hello from backend, path=/foo" {
t.Fatalf("body = %q, want %q", got, "hello from backend, path=/foo")
}
if resp.Header.Get("X-Backend") != "ok" {
t.Fatalf("X-Backend header missing or wrong")
}
}
func TestL7ForwardingHeaders(t *testing.T) {
certPath, keyPath := testCert(t, "headers.test")
// Backend that echoes the forwarding headers.
backendAddr := startH2CBackend(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
fmt.Fprintf(w, "xff=%s xfp=%s xri=%s",
r.Header.Get("X-Forwarded-For"),
r.Header.Get("X-Forwarded-Proto"),
r.Header.Get("X-Real-IP"),
)
}))
proxyLn, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("proxy listen: %v", err)
}
defer proxyLn.Close()
route := RouteConfig{
Backend: backendAddr,
TLSCert: certPath,
TLSKey: keyPath,
ConnectTimeout: 5 * time.Second,
}
go func() {
conn, err := proxyLn.Accept()
if err != nil {
return
}
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
clientAddr := netip.MustParseAddrPort("203.0.113.50:12345")
Serve(context.Background(), conn, nil, route, clientAddr, logger)
}()
client := dialTLSToProxy(t, proxyLn.Addr().String(), "headers.test")
resp, err := client.Get("https://headers.test/")
if err != nil {
t.Fatalf("GET: %v", err)
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
want := "xff=203.0.113.50 xfp=https xri=203.0.113.50"
if string(body) != want {
t.Fatalf("body = %q, want %q", body, want)
}
}
func TestL7BackendUnreachable(t *testing.T) {
certPath, keyPath := testCert(t, "unreachable.test")
// Find a port that nothing is listening on.
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("listen: %v", err)
}
deadAddr := ln.Addr().String()
ln.Close()
proxyLn, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("proxy listen: %v", err)
}
defer proxyLn.Close()
route := RouteConfig{
Backend: deadAddr,
TLSCert: certPath,
TLSKey: keyPath,
ConnectTimeout: 1 * time.Second,
}
go func() {
conn, err := proxyLn.Accept()
if err != nil {
return
}
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
clientAddr := netip.MustParseAddrPort("203.0.113.50:12345")
Serve(context.Background(), conn, nil, route, clientAddr, logger)
}()
client := dialTLSToProxy(t, proxyLn.Addr().String(), "unreachable.test")
resp, err := client.Get("https://unreachable.test/")
if err != nil {
t.Fatalf("GET: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusBadGateway {
t.Fatalf("status = %d, want 502", resp.StatusCode)
}
}
func TestL7MultipleRequests(t *testing.T) {
certPath, keyPath := testCert(t, "multi.test")
var reqCount int
backendAddr := startH2CBackend(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
reqCount++
fmt.Fprintf(w, "req=%d path=%s", reqCount, r.URL.Path)
}))
proxyLn, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("proxy listen: %v", err)
}
defer proxyLn.Close()
route := RouteConfig{
Backend: backendAddr,
TLSCert: certPath,
TLSKey: keyPath,
ConnectTimeout: 5 * time.Second,
}
go func() {
conn, err := proxyLn.Accept()
if err != nil {
return
}
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
clientAddr := netip.MustParseAddrPort("203.0.113.50:12345")
Serve(context.Background(), conn, nil, route, clientAddr, logger)
}()
client := dialTLSToProxy(t, proxyLn.Addr().String(), "multi.test")
// Send multiple requests over the same HTTP/2 connection.
for i := range 3 {
path := fmt.Sprintf("/req%d", i)
resp, err := client.Get(fmt.Sprintf("https://multi.test%s", path))
if err != nil {
t.Fatalf("GET %s: %v", path, err)
}
body, _ := io.ReadAll(resp.Body)
resp.Body.Close()
want := fmt.Sprintf("req=%d path=%s", i+1, path)
if string(body) != want {
t.Fatalf("request %d: body = %q, want %q", i, body, want)
}
}
}
func TestL7LargeResponse(t *testing.T) {
certPath, keyPath := testCert(t, "large.test")
// Backend sends a 1 MB response.
largeBody := make([]byte, 1<<20)
for i := range largeBody {
largeBody[i] = byte(i % 256)
}
backendAddr := startH2CBackend(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Write(largeBody)
}))
proxyLn, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("proxy listen: %v", err)
}
defer proxyLn.Close()
route := RouteConfig{
Backend: backendAddr,
TLSCert: certPath,
TLSKey: keyPath,
ConnectTimeout: 5 * time.Second,
}
go func() {
conn, err := proxyLn.Accept()
if err != nil {
return
}
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
Serve(context.Background(), conn, nil, route, netip.MustParseAddrPort("203.0.113.50:12345"), logger)
}()
client := dialTLSToProxy(t, proxyLn.Addr().String(), "large.test")
resp, err := client.Get("https://large.test/")
if err != nil {
t.Fatalf("GET: %v", err)
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
if len(body) != len(largeBody) {
t.Fatalf("got %d bytes, want %d", len(body), len(largeBody))
}
}
func TestL7GRPCTrailers(t *testing.T) {
certPath, keyPath := testCert(t, "trailers.test")
// Backend that sets HTTP trailers (used by gRPC for status).
backendAddr := startH2CBackend(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Trailer", "Grpc-Status, Grpc-Message")
w.Header().Set("Content-Type", "application/grpc")
w.WriteHeader(200)
// Flush to send headers.
if f, ok := w.(http.Flusher); ok {
f.Flush()
}
// Set trailers.
w.Header().Set("Grpc-Status", "0")
w.Header().Set("Grpc-Message", "OK")
}))
proxyLn, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("proxy listen: %v", err)
}
defer proxyLn.Close()
route := RouteConfig{
Backend: backendAddr,
TLSCert: certPath,
TLSKey: keyPath,
ConnectTimeout: 5 * time.Second,
}
go func() {
conn, err := proxyLn.Accept()
if err != nil {
return
}
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
Serve(context.Background(), conn, nil, route, netip.MustParseAddrPort("203.0.113.50:12345"), logger)
}()
client := dialTLSToProxy(t, proxyLn.Addr().String(), "trailers.test")
req, _ := http.NewRequest("POST", "https://trailers.test/grpc.test.Service/Method", nil)
req.Header.Set("Content-Type", "application/grpc")
resp, err := client.Do(req)
if err != nil {
t.Fatalf("POST: %v", err)
}
defer resp.Body.Close()
// Read body to trigger trailer delivery.
io.ReadAll(resp.Body)
// Verify trailers were forwarded through the proxy.
grpcStatus := resp.Trailer.Get("Grpc-Status")
if grpcStatus != "0" {
t.Fatalf("Grpc-Status trailer = %q, want %q", grpcStatus, "0")
}
grpcMessage := resp.Trailer.Get("Grpc-Message")
if grpcMessage != "OK" {
t.Fatalf("Grpc-Message trailer = %q, want %q", grpcMessage, "OK")
}
}
func TestL7HTTP11Fallback(t *testing.T) {
certPath, keyPath := testCert(t, "http11.test")
backendAddr := startH2CBackend(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
fmt.Fprintf(w, "proto=%s", r.Proto)
}))
proxyLn, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("proxy listen: %v", err)
}
defer proxyLn.Close()
route := RouteConfig{
Backend: backendAddr,
TLSCert: certPath,
TLSKey: keyPath,
ConnectTimeout: 5 * time.Second,
}
go func() {
conn, err := proxyLn.Accept()
if err != nil {
return
}
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
Serve(context.Background(), conn, nil, route, netip.MustParseAddrPort("203.0.113.50:12345"), logger)
}()
// Connect with HTTP/1.1 only (no h2 ALPN).
tlsConf := &tls.Config{
ServerName: "http11.test",
InsecureSkipVerify: true,
NextProtos: []string{"http/1.1"},
}
tr := &http.Transport{TLSClientConfig: tlsConf}
client := &http.Client{Transport: tr}
resp, err := client.Get(fmt.Sprintf("https://%s/", proxyLn.Addr().String()))
if err != nil {
t.Fatalf("GET: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
t.Fatalf("status = %d, want 200", resp.StatusCode)
}
body, _ := io.ReadAll(resp.Body)
// The backend sees HTTP/2 (proxied via h2c) regardless of client protocol.
// Just verify we got a response — the protocol the backend sees depends
// on the h2c transport.
if len(body) == 0 {
t.Fatal("empty response body")
}
}