Add L7 TLS-terminating HTTP/2 reverse proxy
New internal/l7 package implements TLS termination and HTTP/2 reverse proxying for L7 routes. The proxy terminates the client TLS connection using per-route certificates, then forwards HTTP/2 traffic to backends over h2c (plaintext HTTP/2) or h2 (re-encrypted TLS). PrefixConn replays the peeked ClientHello bytes into crypto/tls.Server so the TLS handshake sees the complete ClientHello despite SNI extraction having already read it. Serve() is the L7 entry point: TLS handshake with route certificate, ALPN negotiation (h2 preferred, HTTP/1.1 fallback), then HTTP reverse proxy via httputil.ReverseProxy. Backend transport uses h2c by default (AllowHTTP + plain TCP dial) or h2-over-TLS when backend_tls is set. Forwarding headers (X-Forwarded-For, X-Forwarded-Proto, X-Real-IP) are injected from the real client IP in the Rewrite function. PROXY protocol v2 is sent to backends when send_proxy_protocol is enabled, using the request context to carry the client address through the HTTP/2 transport's dial function. Server integration: handleConn dispatches to handleL7 when route.Mode is "l7". The L7 handler converts RouteInfo to l7.RouteConfig and delegates to l7.Serve. L7 package tests: PrefixConn (4 tests), h2c backend round-trip, forwarding header injection, backend unreachable (502), multiple HTTP/2 requests over one connection. Server integration tests: L7 route through full server pipeline with TLS client, mixed L4+L7 routes on the same listener verifying both paths work independently. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -13,6 +13,7 @@ import (
|
||||
|
||||
"git.wntrmute.dev/kyle/mc-proxy/internal/config"
|
||||
"git.wntrmute.dev/kyle/mc-proxy/internal/firewall"
|
||||
"git.wntrmute.dev/kyle/mc-proxy/internal/l7"
|
||||
"git.wntrmute.dev/kyle/mc-proxy/internal/proxy"
|
||||
"git.wntrmute.dev/kyle/mc-proxy/internal/proxyproto"
|
||||
"git.wntrmute.dev/kyle/mc-proxy/internal/sni"
|
||||
@@ -298,11 +299,10 @@ func (s *Server) handleConn(ctx context.Context, conn net.Conn, ls *ListenerStat
|
||||
return
|
||||
}
|
||||
|
||||
// Dispatch based on route mode. L7 will be implemented in a later phase.
|
||||
// Dispatch based on route mode.
|
||||
switch route.Mode {
|
||||
case "l7":
|
||||
s.logger.Error("L7 mode not yet implemented", "hostname", hostname)
|
||||
return
|
||||
s.handleL7(ctx, conn, addr, addrPort, hostname, route, peeked)
|
||||
default:
|
||||
s.handleL4(ctx, conn, addr, addrPort, hostname, route, peeked)
|
||||
}
|
||||
@@ -340,3 +340,25 @@ func (s *Server) handleL4(ctx context.Context, conn net.Conn, addr netip.Addr, c
|
||||
"backend_bytes", result.BackendBytes,
|
||||
)
|
||||
}
|
||||
|
||||
// handleL7 handles an L7 (TLS-terminating) connection.
|
||||
func (s *Server) handleL7(ctx context.Context, conn net.Conn, addr netip.Addr, clientAddrPort netip.AddrPort, hostname string, route RouteInfo, peeked []byte) {
|
||||
s.logger.Debug("L7 proxying", "addr", addr, "hostname", hostname, "backend", route.Backend)
|
||||
|
||||
rc := l7.RouteConfig{
|
||||
Backend: route.Backend,
|
||||
TLSCert: route.TLSCert,
|
||||
TLSKey: route.TLSKey,
|
||||
BackendTLS: route.BackendTLS,
|
||||
SendProxyProtocol: route.SendProxyProtocol,
|
||||
ConnectTimeout: s.cfg.Proxy.ConnectTimeout.Duration,
|
||||
}
|
||||
|
||||
if err := l7.Serve(ctx, conn, peeked, rc, clientAddrPort, s.logger); err != nil {
|
||||
if ctx.Err() == nil {
|
||||
s.logger.Debug("L7 serve ended", "hostname", hostname, "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
s.logger.Info("L7 connection closed", "addr", addr, "hostname", hostname)
|
||||
}
|
||||
|
||||
@@ -3,11 +3,23 @@ package server
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/ecdsa"
|
||||
"crypto/elliptic"
|
||||
"crypto/rand"
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"crypto/x509/pkix"
|
||||
"encoding/binary"
|
||||
"encoding/pem"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"math/big"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/netip"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
@@ -15,6 +27,8 @@ import (
|
||||
"git.wntrmute.dev/kyle/mc-proxy/internal/config"
|
||||
"git.wntrmute.dev/kyle/mc-proxy/internal/firewall"
|
||||
"git.wntrmute.dev/kyle/mc-proxy/internal/proxyproto"
|
||||
"golang.org/x/net/http2"
|
||||
"golang.org/x/net/http2/h2c"
|
||||
)
|
||||
|
||||
// l4Route creates a RouteInfo for an L4 passthrough route.
|
||||
@@ -1038,6 +1052,220 @@ func TestProxyProtocolFirewallUsesRealIP(t *testing.T) {
|
||||
wg.Wait()
|
||||
}
|
||||
|
||||
// --- L7 server integration tests ---
|
||||
|
||||
// testCert generates a self-signed TLS certificate for the given hostname.
|
||||
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")
|
||||
cf, _ := os.Create(certPath)
|
||||
pem.Encode(cf, &pem.Block{Type: "CERTIFICATE", Bytes: certDER})
|
||||
cf.Close()
|
||||
keyDER, _ := x509.MarshalECPrivateKey(key)
|
||||
kf, _ := os.Create(keyPath)
|
||||
pem.Encode(kf, &pem.Block{Type: "EC PRIVATE KEY", Bytes: keyDER})
|
||||
kf.Close()
|
||||
return
|
||||
}
|
||||
|
||||
// startH2CBackend starts an h2c backend for testing.
|
||||
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()
|
||||
}
|
||||
|
||||
func TestL7ThroughServer(t *testing.T) {
|
||||
certPath, keyPath := testCert(t, "l7srv.test")
|
||||
|
||||
backendAddr := startH2CBackend(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
fmt.Fprintf(w, "ok path=%s xff=%s", r.URL.Path, r.Header.Get("X-Forwarded-For"))
|
||||
}))
|
||||
|
||||
proxyLn, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatalf("proxy listen: %v", err)
|
||||
}
|
||||
proxyAddr := proxyLn.Addr().String()
|
||||
proxyLn.Close()
|
||||
|
||||
srv := newTestServer(t, []ListenerData{
|
||||
{
|
||||
ID: 1,
|
||||
Addr: proxyAddr,
|
||||
Routes: map[string]RouteInfo{
|
||||
"l7srv.test": {
|
||||
Backend: backendAddr,
|
||||
Mode: "l7",
|
||||
TLSCert: certPath,
|
||||
TLSKey: keyPath,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
stop := startAndStop(t, srv)
|
||||
defer stop()
|
||||
|
||||
// Connect with TLS and make an HTTP/2 request.
|
||||
tlsConf := &tls.Config{
|
||||
ServerName: "l7srv.test",
|
||||
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)
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
tr := &http2.Transport{}
|
||||
h2conn, err := tr.NewClientConn(conn)
|
||||
if err != nil {
|
||||
t.Fatalf("h2 client conn: %v", err)
|
||||
}
|
||||
|
||||
req, _ := http.NewRequest("GET", "https://l7srv.test/hello", nil)
|
||||
resp, err := h2conn.RoundTrip(req)
|
||||
if err != nil {
|
||||
t.Fatalf("RoundTrip: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
// The X-Forwarded-For should be the TCP source IP (127.0.0.1) since
|
||||
// no PROXY protocol is in use.
|
||||
if resp.StatusCode != 200 {
|
||||
t.Fatalf("status = %d, want 200", resp.StatusCode)
|
||||
}
|
||||
got := string(body)
|
||||
if got != "ok path=/hello xff=127.0.0.1" {
|
||||
t.Fatalf("body = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMixedL4L7SameListener(t *testing.T) {
|
||||
certPath, keyPath := testCert(t, "l7mixed.test")
|
||||
|
||||
// L4 backend: echo server.
|
||||
l4BackendLn, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatalf("l4 backend listen: %v", err)
|
||||
}
|
||||
defer l4BackendLn.Close()
|
||||
go echoServer(t, l4BackendLn)
|
||||
|
||||
// L7 backend: h2c HTTP server.
|
||||
l7BackendAddr := startH2CBackend(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
fmt.Fprint(w, "l7-response")
|
||||
}))
|
||||
|
||||
proxyLn, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatalf("proxy listen: %v", err)
|
||||
}
|
||||
proxyAddr := proxyLn.Addr().String()
|
||||
proxyLn.Close()
|
||||
|
||||
srv := newTestServer(t, []ListenerData{
|
||||
{
|
||||
ID: 1,
|
||||
Addr: proxyAddr,
|
||||
Routes: map[string]RouteInfo{
|
||||
"l4echo.test": l4Route(l4BackendLn.Addr().String()),
|
||||
"l7mixed.test": {
|
||||
Backend: l7BackendAddr,
|
||||
Mode: "l7",
|
||||
TLSCert: certPath,
|
||||
TLSKey: keyPath,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
stop := startAndStop(t, srv)
|
||||
defer stop()
|
||||
|
||||
// Test L4 route works: send ClientHello, get echo.
|
||||
l4Conn, err := net.DialTimeout("tcp", proxyAddr, 2*time.Second)
|
||||
if err != nil {
|
||||
t.Fatalf("dial L4: %v", err)
|
||||
}
|
||||
defer l4Conn.Close()
|
||||
hello := buildClientHello("l4echo.test")
|
||||
l4Conn.Write(hello)
|
||||
echoed := make([]byte, len(hello))
|
||||
l4Conn.SetReadDeadline(time.Now().Add(5 * time.Second))
|
||||
if _, err := io.ReadFull(l4Conn, echoed); err != nil {
|
||||
t.Fatalf("L4 echo read: %v", err)
|
||||
}
|
||||
|
||||
// Test L7 route works: TLS + HTTP/2.
|
||||
tlsConf := &tls.Config{
|
||||
ServerName: "l7mixed.test",
|
||||
InsecureSkipVerify: true,
|
||||
NextProtos: []string{"h2"},
|
||||
}
|
||||
l7Conn, err := tls.DialWithDialer(
|
||||
&net.Dialer{Timeout: 5 * time.Second},
|
||||
"tcp", proxyAddr, tlsConf,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("TLS dial L7: %v", err)
|
||||
}
|
||||
defer l7Conn.Close()
|
||||
|
||||
tr := &http2.Transport{}
|
||||
h2conn, err := tr.NewClientConn(l7Conn)
|
||||
if err != nil {
|
||||
t.Fatalf("h2 client conn: %v", err)
|
||||
}
|
||||
|
||||
req, _ := http.NewRequest("GET", "https://l7mixed.test/", nil)
|
||||
resp, err := h2conn.RoundTrip(req)
|
||||
if err != nil {
|
||||
t.Fatalf("L7 RoundTrip: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
|
||||
if string(body) != "l7-response" {
|
||||
t.Fatalf("L7 body = %q, want %q", body, "l7-response")
|
||||
}
|
||||
}
|
||||
|
||||
// --- ClientHello builder helpers (mirrors internal/sni test helpers) ---
|
||||
|
||||
func buildClientHello(serverName string) []byte {
|
||||
|
||||
Reference in New Issue
Block a user