Files
weave/lb/gateway_proxy_test.go
levaliciousandClaude Opus 5.5 7405b51e53
images / images (push) Successful in 6m32s
lb: serve Gateways (Gateway API v1) from the node holding their address
Gateways of a GatewayClass whose controllerName is reweave.levk.dev/gateway
are served like LoadBalancer Services: a Gateway names one IPv4 address in
spec.addresses, and the node holding that address serves all of it and
writes its status.

- TCP listeners forward the oldest TCPRoute attached to them in the kernel,
  through the same DNAT and reply steering as LoadBalancer Services, so the
  backend sees the client's own address.
- HTTP and HTTPS listeners are served by the agent itself on the holder:
  TLS terminated with the listener's certificate (chosen by SNI, swapped in
  place when its Secret changes, HTTP/2 offered), requests routed by the
  attached HTTPRoutes in the order the spec prescribes (hostname, exact
  path, longest prefix, method, headers, query, age, name, rule), and passed
  to a ready endpoint of the backend with X-Forwarded-For built from this
  hop only. RequestRedirect, URLRewrite and both header modifiers are
  implemented; other filters and regular expressions are refused
  (Accepted False, UnsupportedValue). Bodies are streamed, never limited.
  Unresolved backends answer 500, backends without ready endpoints 503,
  requests nothing matches 404. Those ports are let in ahead of the host
  firewall (REWEAVE-LB-IN).

Listeners are checked for protocol, route kinds, certificates (Secrets in
the Gateway's namespace or granted by a ReferenceGrant, and only in the
namespaces the agent may read: --gateway-namespaces, default
gateway-system), conflicts on a port, and ports LoadBalancer Services
already forward. Routes attach by parentRef (sectionName, port),
allowedRoutes (namespaces Same, All or Selector; kinds) and hostname
intersection, with the spec's conditions and reasons.

One writer per status: the holder writes its Gateway's (the first node by
name when nobody holds the address, so failures are reported once), the
first node by name writes GatewayClass and route statuses, keeping other
controllers' route parents. Writes are conditional on the resourceVersion
they were computed from, and a condition keeps its transition time while
its status stands, so an unchanged status is never rewritten.

The types are plain structs for the subset implemented (the upstream module
needs a newer client-go than the fork's); the CRDs are installed apart from
this manifest, which grants the agent read access to the Gateway API kinds,
patch on their status, and Secrets in gateway-system only.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UHXzGrsxfkGk8LmwrBbHNZ
2026-10-13 23:51:44 -04:00

315 lines
13 KiB
Go

package lb
import (
"bytes"
"crypto/tls"
"crypto/x509"
"fmt"
"io"
"net"
"net/http"
"net/http/httptest"
"strconv"
"strings"
"sync"
"testing"
"time"
coreapi "k8s.io/api/core/v1"
"k8s.io/apimachinery/pkg/types"
)
// testProxy binds every requested address on loopback and remembers where.
type testProxy struct {
*Proxy
mu sync.Mutex
actual map[string]string
}
func newTestProxy(t *testing.T) *testProxy {
tp := &testProxy{Proxy: NewProxy(), actual: map[string]string{}}
tp.listen = func(addr string) (net.Listener, error) {
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err == nil {
tp.mu.Lock()
tp.actual[addr] = ln.Addr().String()
tp.mu.Unlock()
}
return ln, err
}
t.Cleanup(tp.Close)
return tp
}
func (tp *testProxy) at(port int32) string {
tp.mu.Lock()
defer tp.mu.Unlock()
return tp.actual[net.JoinHostPort("192.0.2.1", strconv.Itoa(int(port)))]
}
// named is a backend that answers with its name and the request it saw.
func named(t *testing.T, name string) (*httptest.Server, Endpoint) {
s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("X-Backend", name)
n, _ := io.Copy(io.Discard, r.Body)
fmt.Fprintf(w, "%s path=%s host=%s xff=%s proto=%s xhost=%s realip=%s added=%s removed=%s body=%d",
name, r.URL.Path, r.Host, r.Header.Get("X-Forwarded-For"), r.Header.Get("X-Forwarded-Proto"),
r.Header.Get("X-Forwarded-Host"), r.Header.Get("X-Real-IP"), strings.Join(r.Header.Values("X-Added"), ","),
r.Header.Get("X-Remove-Me"), n)
}))
t.Cleanup(s.Close)
_, port, _ := net.SplitHostPort(s.Listener.Addr().String())
p, _ := strconv.Atoi(port)
return s, Endpoint{IP: net.ParseIP("127.0.0.1").To4(), Port: int32(p)}
}
func backend(name string, ep Endpoint) []ServeBackend {
return []ServeBackend{{Key: name, Weight: 1, Endpoints: []Endpoint{ep}}}
}
func prefix(v string) HTTPRouteMatch {
return HTTPRouteMatch{Path: &HTTPPathMatch{Type: ptr(pathPrefix), Value: ptr(v)}}
}
func exact(v string) HTTPRouteMatch {
return HTTPRouteMatch{Path: &HTTPPathMatch{Type: ptr(pathExact), Value: ptr(v)}}
}
func get(t *testing.T, url, host string, hdr map[string]string) (int, string, http.Header) {
t.Helper()
req, _ := http.NewRequest("GET", url, nil)
if host != "" {
req.Host = host
}
for k, v := range hdr {
req.Header.Set(k, v)
}
c := &http.Client{CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }}
resp, err := c.Do(req)
if err != nil {
t.Fatalf("GET %s: %v", url, err)
}
defer resp.Body.Close()
b, _ := io.ReadAll(resp.Body)
return resp.StatusCode, string(b), resp.Header
}
func TestProxyRoutesByPrecedence(t *testing.T) {
tp := newTestProxy(t)
_, a := named(t, "A")
_, b := named(t, "B")
_, c := named(t, "C")
_, d := named(t, "D")
_, e := named(t, "E")
t0 := time.Date(2026, 10, 14, 1, 0, 0, 0, time.UTC)
routes := []ServeRoute{
{Key: "ns/first", Created: t0, Rules: []ServeRule{
{Matches: []HTTPRouteMatch{exact("/exact")}, Backends: backend("A", a)},
{Matches: []HTTPRouteMatch{prefix("/api")}, Backends: backend("B", b)},
{Matches: []HTTPRouteMatch{prefix("/")}, Backends: backend("C", c)},
}},
{Key: "ns/second", Created: t0.Add(time.Minute), Rules: []ServeRule{
{Matches: []HTTPRouteMatch{prefix("/api/v2")}, Backends: backend("D", d)},
{Matches: []HTTPRouteMatch{{Path: &HTTPPathMatch{Type: ptr(pathPrefix), Value: ptr("/api")},
Headers: []HTTPHeaderMatch{{Name: "X-Ver", Value: "2"}}}}, Backends: backend("E", e)},
}},
}
if f := tp.Apply([]ServeListener{{Gateway: "ns/gw", Name: "http", Address: net.ParseIP("192.0.2.1"), Port: 8080, Routes: routes}}); len(f) != 0 {
t.Fatal(f)
}
base := "http://" + tp.at(8080)
for path, want := range map[string]string{
"/exact": "A", "/exactly": "C", "/api/x": "B", "/api": "B", "/api/v2/y": "D", "/apix": "C", "/": "C",
} {
if _, body, _ := get(t, base+path, "example.com", nil); !strings.HasPrefix(body, want+" ") {
t.Errorf("%s went to %q, want %s", path, body, want)
}
}
// same prefix length: the match with more headers takes it, though its route is younger
if _, body, _ := get(t, base+"/api/x", "example.com", map[string]string{"X-Ver": "2"}); !strings.HasPrefix(body, "E ") {
t.Errorf("header match lost: %q", body)
}
}
func TestProxyHostPrecedence(t *testing.T) {
tp := newTestProxy(t)
_, w := named(t, "W")
_, x := named(t, "X")
routes := []ServeRoute{
{Key: "ns/wild", Hostnames: []string{"*.example.com"}, Rules: []ServeRule{{Matches: []HTTPRouteMatch{prefix("/")}, Backends: backend("W", w)}}},
{Key: "ns/exact", Hostnames: []string{"a.example.com"}, Rules: []ServeRule{{Matches: []HTTPRouteMatch{prefix("/")}, Backends: backend("X", x)}}},
}
tp.Apply([]ServeListener{{Gateway: "ns/gw", Name: "http", Address: net.ParseIP("192.0.2.1"), Port: 8081, Routes: routes}})
base := "http://" + tp.at(8081)
for host, want := range map[string]string{"a.example.com": "X", "A.Example.com:8081": "X", "b.example.com": "W"} {
if _, body, _ := get(t, base+"/", host, nil); !strings.HasPrefix(body, want+" ") {
t.Errorf("host %s went to %q, want %s", host, body, want)
}
}
if code, _, _ := get(t, base+"/", "example.org", nil); code != http.StatusNotFound {
t.Errorf("a host no route names: %d, want 404", code)
}
}
func certSecret(t *testing.T, name, rv string) *coreapi.Secret {
s := tlsSecret(t, "gateway-system", strings.ReplaceAll(name, ".", "-"), name)
s.UID = types.UID("uid-" + name)
s.ResourceVersion = rv
return s
}
func dialTLS(t *testing.T, addr, sni string) (*x509.Certificate, error) {
conn, err := tls.Dial("tcp", addr, &tls.Config{ServerName: sni, InsecureSkipVerify: true})
if err != nil {
return nil, err
}
defer conn.Close()
return conn.ConnectionState().PeerCertificates[0], nil
}
func TestProxyTLSBySNIAndSwapsCertificates(t *testing.T) {
tp := newTestProxy(t)
_, a := named(t, "A")
_, b := named(t, "B")
listeners := func(certA *coreapi.Secret) []ServeListener {
return []ServeListener{
{Gateway: "ns/gw", Name: "a", Address: net.ParseIP("192.0.2.1"), Port: 8443, HTTPS: true, Hostname: "a.test", Cert: certA,
Routes: []ServeRoute{{Key: "ns/a", Rules: []ServeRule{{Matches: []HTTPRouteMatch{prefix("/")}, Backends: backend("A", a)}}}}},
{Gateway: "ns/gw", Name: "b", Address: net.ParseIP("192.0.2.1"), Port: 8443, HTTPS: true, Hostname: "b.test", Cert: certSecret(t, "b.test", "1"),
Routes: []ServeRoute{{Key: "ns/b", Rules: []ServeRule{{Matches: []HTTPRouteMatch{prefix("/")}, Backends: backend("B", b)}}}}},
}
}
first := certSecret(t, "a.test", "1")
if f := tp.Apply(listeners(first)); len(f) != 0 {
t.Fatal(f)
}
addr := tp.at(8443)
ca, err := dialTLS(t, addr, "a.test")
if err != nil || ca.Subject.CommonName != "a.test" {
t.Fatalf("SNI a.test: %v %v", ca, err)
}
cb, err := dialTLS(t, addr, "b.test")
if err != nil || cb.Subject.CommonName != "b.test" {
t.Fatalf("SNI b.test: %v %v", cb, err)
}
if _, err := dialTLS(t, addr, "c.test"); err == nil {
t.Fatal("a name no listener has must not get a certificate")
}
// requests go by listener, over HTTP/2 when the client offers it
client := &http.Client{Transport: &http.Transport{TLSClientConfig: &tls.Config{InsecureSkipVerify: true, ServerName: "b.test"}, ForceAttemptHTTP2: true}}
req, _ := http.NewRequest("GET", "https://"+addr+"/", nil)
req.Host = "b.test"
resp, err := client.Do(req)
if err != nil {
t.Fatal(err)
}
body, _ := io.ReadAll(resp.Body)
resp.Body.Close()
if !strings.HasPrefix(string(body), "B ") || !strings.Contains(string(body), "proto=https") || resp.ProtoMajor != 2 {
t.Fatalf("over HTTPS to b.test: %s %q", resp.Proto, body)
}
// a renewed Secret: new handshakes get the new certificate on the same, still-bound port
renewed := certSecret(t, "a.test", "2")
tp.Apply(listeners(renewed))
if tp.at(8443) != addr {
t.Fatal("the port was rebound for a certificate change")
}
cn, err := dialTLS(t, addr, "a.test")
if err != nil || cn.SerialNumber.Cmp(ca.SerialNumber) == 0 {
t.Fatalf("still the old certificate after the swap: %v", err)
}
}
func TestProxyRedirectsAndForwards(t *testing.T) {
tp := newTestProxy(t)
_, a := named(t, "A")
routes := []ServeRoute{{Key: "ns/r", Rules: []ServeRule{
{Matches: []HTTPRouteMatch{prefix("/old")}, Filters: []HTTPRouteFilter{{Type: filterRequestRedirect,
RequestRedirect: &HTTPRequestRedirectFilter{Scheme: ptr("https"), StatusCode: ptr(301)}}}},
{Matches: []HTTPRouteMatch{prefix("/moved")}, Filters: []HTTPRouteFilter{{Type: filterRequestRedirect,
RequestRedirect: &HTTPRequestRedirectFilter{Path: &HTTPPathModifier{Type: "ReplacePrefixMatch", ReplacePrefixMatch: ptr("/new")}}}}},
{Matches: []HTTPRouteMatch{prefix("/v1")}, Backends: backend("A", a), Filters: []HTTPRouteFilter{
{Type: filterRequestHeaderModifier, RequestHeaderModifier: &HTTPHeaderFilter{
Set: []HTTPHeader{{Name: "X-Set", Value: "s"}}, Add: []HTTPHeader{{Name: "X-Added", Value: "two"}}, Remove: []string{"X-Remove-Me"}}},
{Type: filterResponseHeaderModifier, ResponseHeaderModifier: &HTTPHeaderFilter{Set: []HTTPHeader{{Name: "X-Resp", Value: "r"}}}},
{Type: filterURLRewrite, URLRewrite: &HTTPURLRewriteFilter{Path: &HTTPPathModifier{Type: "ReplacePrefixMatch", ReplacePrefixMatch: ptr("/v2")}}},
}},
{Matches: []HTTPRouteMatch{prefix("/")}, Backends: backend("A", a)},
}}}
tp.Apply([]ServeListener{{Gateway: "ns/gw", Name: "http", Address: net.ParseIP("192.0.2.1"), Port: 80, Routes: routes}})
base := "http://" + tp.at(80)
code, _, h := get(t, base+"/old/page?q=1", "git.levk.dev", nil)
if code != http.StatusMovedPermanently || h.Get("Location") != "https://git.levk.dev/old/page?q=1" {
t.Fatalf("scheme redirect: %d %q", code, h.Get("Location"))
}
code, _, h = get(t, base+"/moved/x", "git.levk.dev", nil)
if code != http.StatusFound || h.Get("Location") != "http://git.levk.dev/new/x" {
t.Fatalf("prefix redirect: %d %q", code, h.Get("Location"))
}
// the client's own claims about who it is are not passed on; the backend sees this hop's view
_, body, _ := get(t, base+"/", "git.levk.dev", map[string]string{"X-Forwarded-For": "6.6.6.6", "X-Real-IP": "6.6.6.6"})
if !strings.Contains(body, "host=git.levk.dev") || !strings.Contains(body, "xff=127.0.0.1 ") ||
!strings.Contains(body, "proto=http ") || !strings.Contains(body, "xhost=git.levk.dev") || !strings.Contains(body, "realip=127.0.0.1") {
t.Fatalf("forwarded headers: %q", body)
}
_, body, h = get(t, base+"/v1/thing", "git.levk.dev", map[string]string{"X-Added": "one", "X-Remove-Me": "x"})
if !strings.Contains(body, "path=/v2/thing") || !strings.Contains(body, "added=one,two") || !strings.Contains(body, "removed= ") || h.Get("X-Resp") != "r" {
t.Fatalf("filters: %q %v", body, h)
}
}
func TestProxyAnswersWhatItCannotRoute(t *testing.T) {
tp := newTestProxy(t)
routes := []ServeRoute{{Key: "ns/r", Rules: []ServeRule{
{Matches: []HTTPRouteMatch{prefix("/broken")}, Broken: true},
{Matches: []HTTPRouteMatch{prefix("/empty")}, Backends: []ServeBackend{{Key: "ns/svc:80", Weight: 1}}},
}}}
tp.Apply([]ServeListener{{Gateway: "ns/gw", Name: "http", Address: net.ParseIP("192.0.2.1"), Port: 8082, Routes: routes}})
base := "http://" + tp.at(8082)
for path, want := range map[string]int{"/broken": 500, "/empty": 503, "/nothing": 404} {
if code, _, _ := get(t, base+path, "x", nil); code != want {
t.Errorf("%s: %d, want %d", path, code, want)
}
}
}
func TestProxyStreamsLargeBodies(t *testing.T) {
tp := newTestProxy(t)
_, a := named(t, "A")
tp.Apply([]ServeListener{{Gateway: "ns/gw", Name: "http", Address: net.ParseIP("192.0.2.1"), Port: 8083,
Routes: []ServeRoute{{Key: "ns/r", Rules: []ServeRule{{Matches: []HTTPRouteMatch{prefix("/")}, Backends: backend("A", a)}}}}}})
const size = 32 << 20
resp, err := http.Post("http://"+tp.at(8083)+"/upload", "application/octet-stream", bytes.NewReader(make([]byte, size)))
if err != nil {
t.Fatal(err)
}
body, _ := io.ReadAll(resp.Body)
resp.Body.Close()
if !strings.Contains(string(body), fmt.Sprintf("body=%d", size)) {
t.Fatalf("upload: %q", body)
}
}
func TestProxyBindFailureAndClose(t *testing.T) {
tp := newTestProxy(t)
tp.listen = func(addr string) (net.Listener, error) { return nil, fmt.Errorf("address in use") }
f := tp.Apply([]ServeListener{{Gateway: "ns/gw", Name: "http", Address: net.ParseIP("192.0.2.1"), Port: 8084}})
if f["ns/gw/http"] == nil {
t.Fatalf("bind failure not reported: %v", f)
}
tp2 := newTestProxy(t)
tp2.Apply([]ServeListener{{Gateway: "ns/gw", Name: "http", Address: net.ParseIP("192.0.2.1"), Port: 8085}})
addr := tp2.at(8085)
tp2.Apply(nil)
deadline := time.Now().Add(5 * time.Second)
for time.Now().Before(deadline) {
c, err := net.DialTimeout("tcp", addr, 200*time.Millisecond)
if err != nil {
return
}
c.Close()
time.Sleep(50 * time.Millisecond)
}
t.Fatal("a port no longer served still accepts connections")
}