Files
weave/lb/gateway_test.go
levaliciousandClaude Opus 5.5 5d6b0719b3
images / images (push) Successful in 7m29s
lb: route filters may not change the forwarding headers
The proxy sets Forwarded, X-Forwarded-For/-Host/-Proto and X-Real-IP from
the connection, but applied a route's request header filters after that,
so whoever could write a route to a backend could tell it any client
address (a backend that trusts the gateway's X-Forwarded-For believes it).

An HTTPRoute whose request header filter sets, adds or removes one of
them (names compared case-insensitively) is now not accepted
(UnsupportedValue), like the other filters we do not serve. The proxy
also sets them after the filters, clearing them first: SetXForwarded
appends to an X-Forwarded-For already there.

The proxy test's backend now prints every X-Forwarded-For value; it
printed only the first, which hid an appended one.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UHXzGrsxfkGk8LmwrBbHNZ
2026-10-14 10:29:35 -04:00

612 lines
25 KiB
Go

package lb
import (
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/x509"
"crypto/x509/pkix"
"encoding/pem"
"math/big"
"net"
"strings"
"testing"
"time"
coreapi "k8s.io/api/core/v1"
discovery "k8s.io/api/discovery/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
)
var gwNow = metav1.NewTime(time.Date(2026, 10, 14, 4, 0, 0, 0, time.UTC))
// selfSigned is a certificate and key for the given names, PEM-encoded.
func selfSigned(t *testing.T, names ...string) ([]byte, []byte) {
t.Helper()
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
t.Fatal(err)
}
tmpl := &x509.Certificate{
SerialNumber: big.NewInt(time.Now().UnixNano()), Subject: pkix.Name{CommonName: names[0]}, DNSNames: names,
NotBefore: time.Now().Add(-time.Hour), NotAfter: time.Now().Add(24 * time.Hour),
KeyUsage: x509.KeyUsageDigitalSignature, ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
}
der, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &key.PublicKey, key)
if err != nil {
t.Fatal(err)
}
kb, _ := x509.MarshalECPrivateKey(key)
return pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der}), pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: kb})
}
func tlsSecret(t *testing.T, namespace, name string, names ...string) *coreapi.Secret {
crt, key := selfSigned(t, names...)
return &coreapi.Secret{ObjectMeta: metav1.ObjectMeta{Namespace: namespace, Name: name}, Type: coreapi.SecretTypeTLS,
Data: map[string][]byte{coreapi.TLSCertKey: crt, coreapi.TLSPrivateKeyKey: key}}
}
func gwNodes() []Node {
return []Node{
{Name: "cp", Addresses: []net.IP{net.ParseIP("86.107.168.24").To4()}, WeaveIP: net.ParseIP("10.32.0.1").To4(), WeaveMAC: mustMAC("02:00:00:00:00:01")},
{Name: "w15", Addresses: []net.IP{net.ParseIP("86.107.168.15").To4()}, WeaveIP: net.ParseIP("10.44.0.0").To4(), WeaveMAC: mustMAC("02:00:00:00:00:02")},
}
}
func mustMAC(s string) net.HardwareAddr {
m, _ := net.ParseMAC(s)
return m
}
func ourClass() *GatewayClass {
return &GatewayClass{ObjectMeta: metav1.ObjectMeta{Name: "reweave", Generation: 1}, Spec: GatewayClassSpec{ControllerName: GatewayControllerName}}
}
// giteaGateway is the shape Gitea's documentation prescribes: HTTPS with a certificate, plain HTTP, and SSH over TCP.
func giteaGateway() *Gateway {
return &Gateway{
ObjectMeta: metav1.ObjectMeta{Namespace: "gateway-system", Name: "shared", Generation: 3},
Spec: GatewaySpec{
GatewayClassName: "reweave",
Addresses: []GatewayAddress{{Value: "86.107.168.15"}},
Listeners: []Listener{
{Name: "https-gitea", Port: 443, Protocol: ProtocolHTTPS, Hostname: ptr("git.levk.dev"),
TLS: &ListenerTLSConfig{CertificateRefs: []SecretObjectReference{{Name: "git-levk-dev"}}},
AllowedRoutes: &AllowedRoutes{Namespaces: &RouteNamespaces{From: ptr("Selector"), Selector: &metav1.LabelSelector{MatchLabels: map[string]string{"kubernetes.io/metadata.name": "gitea"}}}}},
{Name: "http", Port: 80, Protocol: ProtocolHTTP, Hostname: ptr("git.levk.dev"),
AllowedRoutes: &AllowedRoutes{Namespaces: &RouteNamespaces{From: ptr("All")}}},
{Name: "ssh", Port: 22, Protocol: ProtocolTCP,
AllowedRoutes: &AllowedRoutes{Namespaces: &RouteNamespaces{From: ptr("All")}}},
},
},
}
}
func giteaService() *coreapi.Service {
return &coreapi.Service{ObjectMeta: metav1.ObjectMeta{Namespace: "gitea", Name: "gitea"}, Spec: coreapi.ServiceSpec{
Ports: []coreapi.ServicePort{{Name: "http", Port: 3000}, {Name: "ssh", Port: 22}},
}}
}
func giteaSlice() *discovery.EndpointSlice {
return &discovery.EndpointSlice{
ObjectMeta: metav1.ObjectMeta{Namespace: "gitea", Name: "gitea-abc", Labels: map[string]string{serviceNameLabel: "gitea"}},
AddressType: discovery.AddressTypeIPv4,
Ports: []discovery.EndpointPort{{Name: ptr("http"), Port: ptr(int32(3000))}, {Name: ptr("ssh"), Port: ptr(int32(22))}},
Endpoints: []discovery.Endpoint{{Addresses: []string{"10.44.0.12"}, Conditions: discovery.EndpointConditions{Ready: ptr(true)}}},
}
}
func giteaRoutes() ([]*HTTPRoute, []*TCPRoute) {
t0 := metav1.NewTime(time.Date(2026, 10, 14, 1, 0, 0, 0, time.UTC))
https := &HTTPRoute{
ObjectMeta: metav1.ObjectMeta{Namespace: "gitea", Name: "gitea", Generation: 1, CreationTimestamp: t0},
Spec: HTTPRouteSpec{
ParentRefs: []ParentReference{{Namespace: ptr("gateway-system"), Name: "shared", SectionName: ptr("https-gitea")}},
Hostnames: []string{"git.levk.dev"},
Rules: []HTTPRouteRule{{BackendRefs: []HTTPBackendRef{{BackendRef: BackendRef{Name: "gitea", Port: ptr(int32(3000))}}}}},
},
}
redirect := &HTTPRoute{
ObjectMeta: metav1.ObjectMeta{Namespace: "gitea", Name: "gitea-redirect", Generation: 1, CreationTimestamp: t0},
Spec: HTTPRouteSpec{
ParentRefs: []ParentReference{{Namespace: ptr("gateway-system"), Name: "shared", SectionName: ptr("http")}},
Rules: []HTTPRouteRule{{Filters: []HTTPRouteFilter{{Type: filterRequestRedirect,
RequestRedirect: &HTTPRequestRedirectFilter{Scheme: ptr("https"), StatusCode: ptr(301)}}}}},
},
}
ssh := &TCPRoute{
ObjectMeta: metav1.ObjectMeta{Namespace: "gitea", Name: "gitea-ssh", Generation: 1, CreationTimestamp: t0},
Spec: TCPRouteSpec{
ParentRefs: []ParentReference{{Namespace: ptr("gateway-system"), Name: "shared", SectionName: ptr("ssh")}},
Rules: []TCPRouteRule{{BackendRefs: []BackendRef{{Name: "gitea", Port: ptr(int32(22))}}}},
},
}
return []*HTTPRoute{https, redirect}, []*TCPRoute{ssh}
}
func giteaInput(t *testing.T) GatewayInput {
hr, tr := giteaRoutes()
return GatewayInput{
Classes: []*GatewayClass{ourClass()}, Gateways: []*Gateway{giteaGateway()}, HTTPRoutes: hr, TCPRoutes: tr,
Namespaces: []*coreapi.Namespace{
{ObjectMeta: metav1.ObjectMeta{Name: "gitea", Labels: map[string]string{"kubernetes.io/metadata.name": "gitea"}}},
{ObjectMeta: metav1.ObjectMeta{Name: "gateway-system", Labels: map[string]string{"kubernetes.io/metadata.name": "gateway-system"}}},
},
Services: []*coreapi.Service{giteaService()}, Slices: []*discovery.EndpointSlice{giteaSlice()},
Secrets: []*coreapi.Secret{tlsSecret(t, "gateway-system", "git-levk-dev", "git.levk.dev")},
SecretNamespaces: map[string]bool{"gateway-system": true},
Now: gwNow,
}
}
func condOf(cs []metav1.Condition, typ string) metav1.Condition {
for _, c := range cs {
if c.Type == typ {
return c
}
}
return metav1.Condition{}
}
func TestGatewayServedByItsHolder(t *testing.T) {
in := giteaInput(t)
d := ComputeGateways("w15", gwNodes(), in)
if len(d.Problems) != 0 {
t.Fatalf("problems: %v", d.Problems)
}
// HTTPS and HTTP served by the agent; SSH forwarded in the kernel to Gitea's pod with the client address intact
if len(d.Serve) != 2 {
t.Fatalf("want the https and http listeners served, got %+v", d.Serve)
}
https := d.Serve[0]
if !https.HTTPS || https.Port != 443 || https.Hostname != "git.levk.dev" || https.Cert == nil || len(https.Routes) != 1 {
t.Fatalf("https listener: %+v", https)
}
be := https.Routes[0].Rules[0].Backends
if len(be) != 1 || be[0].Key != "gitea/gitea:3000" || len(be[0].Endpoints) != 1 || be[0].Endpoints[0].String() != "10.44.0.12:3000" {
t.Fatalf("https backends: %+v", be)
}
if len(d.Forwards) != 1 || d.Forwards[0].Ports[0].Port != 22 || d.Forwards[0].Ports[0].Endpoints[0].String() != "10.44.0.12:22" {
t.Fatalf("ssh forward: %+v", d.Forwards)
}
if len(d.Holding) != 1 || d.Holding[0] != "w15" {
t.Fatalf("holding: %v", d.Holding)
}
// the holder writes the Gateway's status: accepted, programmed, its address, each listener with one route
if len(d.Gateways) != 1 {
t.Fatalf("gateway status writes: %+v", d.Gateways)
}
st := d.Gateways[0].Status
if c := condOf(st.Conditions, condProgrammed); c.Status != metav1.ConditionTrue || c.ObservedGeneration != 3 {
t.Fatalf("programmed: %+v", c)
}
if len(st.Addresses) != 1 || st.Addresses[0].Value != "86.107.168.15" {
t.Fatalf("addresses: %+v", st.Addresses)
}
for _, l := range st.Listeners {
if l.AttachedRoutes != 1 || condOf(l.Conditions, condProgrammed).Status != metav1.ConditionTrue {
t.Fatalf("listener %s: %+v", l.Name, l)
}
}
// route statuses come from the first node by name, not the holder
if len(d.Routes) != 0 || len(d.Classes) != 0 {
t.Fatalf("w15 wrote route or class statuses: %+v %+v", d.Routes, d.Classes)
}
}
func TestGatewayElsewhere(t *testing.T) {
in := giteaInput(t)
d := ComputeGateways("cp", gwNodes(), in)
if len(d.Serve) != 0 || len(d.Forwards) != 0 || len(d.Gateways) != 0 {
t.Fatalf("cp serves or writes what w15 holds: %+v %+v %+v", d.Serve, d.Forwards, d.Gateways)
}
// but steers replies of w15's forwards back to it, and writes class and route statuses (first by name)
if len(d.Holding) != 1 || d.Holding[0] != "w15" {
t.Fatalf("holding: %v", d.Holding)
}
if len(d.Classes) != 1 || condOf(d.Classes[0].Status.Conditions, condAccepted).Status != metav1.ConditionTrue {
t.Fatalf("class status: %+v", d.Classes)
}
if len(d.Routes) != 3 {
t.Fatalf("want 3 route statuses, got %+v", d.Routes)
}
for _, r := range d.Routes {
if len(r.Status.Parents) != 1 || condOf(r.Status.Parents[0].Conditions, condAccepted).Status != metav1.ConditionTrue ||
condOf(r.Status.Parents[0].Conditions, condResolvedRefs).Status != metav1.ConditionTrue {
t.Fatalf("route %s/%s status: %+v", r.Namespace, r.Name, r.Status)
}
}
}
func TestGatewayStatusIsWrittenOnce(t *testing.T) {
in := giteaInput(t)
d := ComputeGateways("w15", gwNodes(), in)
in.Gateways[0].Status = d.Gateways[0].Status
in.Now = metav1.NewTime(gwNow.Add(time.Hour)) // later passes see the status they wrote
if again := ComputeGateways("w15", gwNodes(), in); len(again.Gateways) != 0 {
t.Fatalf("unchanged status rewritten: %+v", again.Gateways)
}
c := ComputeGateways("cp", gwNodes(), in)
for _, r := range c.Routes {
for _, hr := range in.HTTPRoutes {
if hr.Name == r.Name {
hr.Status = r.Status
}
}
for _, tr := range in.TCPRoutes {
if tr.Name == r.Name {
tr.Status = r.Status
}
}
}
in.Classes[0].Status = c.Classes[0].Status
if again := ComputeGateways("cp", gwNodes(), in); len(again.Routes) != 0 || len(again.Classes) != 0 {
t.Fatalf("unchanged route or class status rewritten: %+v %+v", again.Routes, again.Classes)
}
}
func TestGatewayFailsClosedWithoutHolder(t *testing.T) {
in := giteaInput(t)
in.Gateways[0].Spec.Addresses[0].Value = "198.51.100.7"
for _, self := range []string{"cp", "w15"} {
d := ComputeGateways(self, gwNodes(), in)
if len(d.Serve) != 0 || len(d.Forwards) != 0 || len(d.Holding) != 0 {
t.Fatalf("%s serves an address nobody holds: %+v", self, d)
}
}
d := ComputeGateways("cp", gwNodes(), in)
if len(d.Gateways) != 1 {
t.Fatalf("the first node by name must write the failure: %+v", d.Gateways)
}
c := condOf(d.Gateways[0].Status.Conditions, condProgrammed)
if c.Status != metav1.ConditionFalse || c.Reason != "AddressNotUsable" || len(d.Gateways[0].Status.Addresses) != 0 {
t.Fatalf("status: %+v", d.Gateways[0].Status)
}
}
func TestGatewayAddressMustBeOneIPv4(t *testing.T) {
in := giteaInput(t)
in.Gateways[0].Spec.Addresses = nil
d := ComputeGateways("cp", gwNodes(), in)
if c := condOf(d.Gateways[0].Status.Conditions, condAccepted); c.Status != metav1.ConditionFalse || c.Reason != "UnsupportedAddress" {
t.Fatalf("accepted: %+v", c)
}
}
func TestListenerCertificateReferences(t *testing.T) {
cases := map[string]struct {
mutate func(in *GatewayInput)
reason string
}{
"missing Secret": {func(in *GatewayInput) { in.Secrets = nil }, "InvalidCertificateRef"},
"other namespace without grant": {func(in *GatewayInput) {
in.Gateways[0].Spec.Listeners[0].TLS.CertificateRefs[0].Namespace = ptr("gitea")
in.SecretNamespaces["gitea"] = true
}, "RefNotPermitted"},
"namespace the agent cannot read": {func(in *GatewayInput) {
in.SecretNamespaces = map[string]bool{}
}, "RefNotPermitted"},
"not a certificate": {func(in *GatewayInput) {
in.Secrets[0].Data[coreapi.TLSCertKey] = []byte("nope")
}, "InvalidCertificateRef"},
}
for name, c := range cases {
in := giteaInput(t)
c.mutate(&in)
d := ComputeGateways("w15", gwNodes(), in)
var https ListenerStatus
for _, l := range d.Gateways[0].Status.Listeners {
if l.Name == "https-gitea" {
https = l
}
}
if r := condOf(https.Conditions, condResolvedRefs); r.Status != metav1.ConditionFalse || r.Reason != c.reason {
t.Errorf("%s: resolvedRefs %+v", name, r)
}
if p := condOf(https.Conditions, condProgrammed); p.Status != metav1.ConditionFalse {
t.Errorf("%s: an HTTPS listener without its certificate must not be programmed", name)
}
for _, s := range d.Serve {
if s.HTTPS {
t.Errorf("%s: HTTPS listener served without a certificate", name)
}
}
if a := condOf(d.Gateways[0].Status.Conditions, condAccepted); a.Reason != "ListenersNotValid" || a.Status != metav1.ConditionTrue {
t.Errorf("%s: gateway accepted %+v", name, a)
}
}
}
func TestCrossNamespaceCertificateWithGrant(t *testing.T) {
in := giteaInput(t)
in.Gateways[0].Spec.Listeners[0].TLS.CertificateRefs[0].Namespace = ptr("gitea")
in.Secrets = []*coreapi.Secret{tlsSecret(t, "gitea", "git-levk-dev", "git.levk.dev")}
in.SecretNamespaces = map[string]bool{"gitea": true}
in.Grants = []*ReferenceGrant{{ObjectMeta: metav1.ObjectMeta{Namespace: "gitea", Name: "gw"}, Spec: ReferenceGrantSpec{
From: []ReferenceGrantFrom{{Group: GatewayGroup, Kind: kindGateway, Namespace: "gateway-system"}},
To: []ReferenceGrantTo{{Group: "", Kind: kindSecret}},
}}}
d := ComputeGateways("w15", gwNodes(), in)
if len(d.Serve) != 2 || d.Serve[0].Cert == nil {
t.Fatalf("a granted certificate in another namespace must be served: %+v", d.Serve)
}
}
func TestListenerConflicts(t *testing.T) {
in := giteaInput(t)
l := in.Gateways[0].Spec.Listeners
in.Gateways[0].Spec.Listeners = append(l,
Listener{Name: "ssh2", Port: 22, Protocol: ProtocolTCP}, // two TCP listeners on 22
Listener{Name: "http-on-443", Port: 443, Protocol: ProtocolHTTP}, // HTTP and HTTPS on 443
)
d := ComputeGateways("w15", gwNodes(), in)
want := map[string]string{"ssh": "HostnameConflict", "ssh2": "HostnameConflict", "https-gitea": "ProtocolConflict", "http-on-443": "ProtocolConflict", "http": ""}
for _, ls := range d.Gateways[0].Status.Listeners {
c := condOf(ls.Conditions, condConflicted)
if want[ls.Name] == "" {
if c.Status != metav1.ConditionFalse {
t.Errorf("%s: conflicted %+v", ls.Name, c)
}
continue
}
if c.Status != metav1.ConditionTrue || c.Reason != want[ls.Name] {
t.Errorf("%s: want conflicted %s, got %+v", ls.Name, want[ls.Name], c)
}
}
if len(d.Forwards) != 0 {
t.Fatalf("conflicted TCP listeners forwarded: %+v", d.Forwards)
}
if len(d.Serve) != 1 || d.Serve[0].Name != "http" {
t.Fatalf("only the plain http listener on 80 is unconflicted: %+v", d.Serve)
}
}
func TestListenerPortTakenByLoadBalancerService(t *testing.T) {
in := giteaInput(t)
class := Class
in.Services = append(in.Services, &coreapi.Service{ObjectMeta: metav1.ObjectMeta{Namespace: "mail", Name: "smtp"}, Spec: coreapi.ServiceSpec{
Type: coreapi.ServiceTypeLoadBalancer, LoadBalancerClass: &class, LoadBalancerIP: "86.107.168.15",
Ports: []coreapi.ServicePort{{Port: 22}},
}})
d := ComputeGateways("w15", gwNodes(), in)
for _, ls := range d.Gateways[0].Status.Listeners {
if ls.Name == "ssh" {
if c := condOf(ls.Conditions, condAccepted); c.Status != metav1.ConditionFalse || c.Reason != "PortUnavailable" {
t.Fatalf("ssh accepted: %+v", c)
}
}
}
if len(d.Forwards) != 0 {
t.Fatalf("a listener on a port a Service forwards must not be forwarded: %+v", d.Forwards)
}
}
func TestRouteAttachment(t *testing.T) {
cases := map[string]struct {
mutate func(in *GatewayInput)
route string
reason string
}{
"unknown section": {func(in *GatewayInput) { in.HTTPRoutes[0].Spec.ParentRefs[0].SectionName = ptr("nope") }, "gitea", "NoMatchingParent"},
"namespace not selected": {func(in *GatewayInput) { in.Namespaces[0].Labels = nil }, "gitea", "NotAllowedByListeners"},
"hostname outside the listener's": {func(in *GatewayInput) { in.HTTPRoutes[0].Spec.Hostnames = []string{"git.example.net"} }, "gitea", "NoMatchingListenerHostname"},
"unsupported filter": {func(in *GatewayInput) {
in.HTTPRoutes[0].Spec.Rules[0].Filters = []HTTPRouteFilter{{Type: "RequestMirror"}}
}, "gitea", "UnsupportedValue"},
"regular expression path": {func(in *GatewayInput) {
in.HTTPRoutes[0].Spec.Rules[0].Matches = []HTTPRouteMatch{{Path: &HTTPPathMatch{Type: ptr("RegularExpression"), Value: ptr("/.*")}}}
}, "gitea", "UnsupportedValue"},
"filter sets X-Forwarded-For": {func(in *GatewayInput) {
in.HTTPRoutes[0].Spec.Rules[0].Filters = []HTTPRouteFilter{{Type: filterRequestHeaderModifier,
RequestHeaderModifier: &HTTPHeaderFilter{Set: []HTTPHeader{{Name: "X-Forwarded-For", Value: "6.6.6.6"}}}}}
}, "gitea", "UnsupportedValue"},
"backend filter adds x-real-ip": {func(in *GatewayInput) {
in.HTTPRoutes[0].Spec.Rules[0].BackendRefs[0].Filters = []HTTPRouteFilter{{Type: filterRequestHeaderModifier,
RequestHeaderModifier: &HTTPHeaderFilter{Add: []HTTPHeader{{Name: "x-real-ip", Value: "6.6.6.6"}}}}}
}, "gitea", "UnsupportedValue"},
"filter removes Forwarded": {func(in *GatewayInput) {
in.HTTPRoutes[0].Spec.Rules[0].Filters = []HTTPRouteFilter{{Type: filterRequestHeaderModifier,
RequestHeaderModifier: &HTTPHeaderFilter{Remove: []string{"FORWARDED"}}}}
}, "gitea", "UnsupportedValue"},
"wrong port": {func(in *GatewayInput) { in.TCPRoutes[0].Spec.ParentRefs[0].Port = ptr(int32(2222)) }, "gitea-ssh", "NoMatchingParent"},
}
for name, c := range cases {
in := giteaInput(t)
c.mutate(&in)
d := ComputeGateways("cp", gwNodes(), in)
found := false
for _, r := range d.Routes {
if r.Name != c.route {
continue
}
found = true
a := condOf(r.Status.Parents[0].Conditions, condAccepted)
if a.Status != metav1.ConditionFalse || a.Reason != c.reason {
t.Errorf("%s: accepted %+v", name, a)
}
}
if !found {
t.Errorf("%s: no status for route %s", name, c.route)
}
h := ComputeGateways("w15", gwNodes(), in)
if c.route == "gitea" {
for _, s := range h.Serve {
if s.Name == "https-gitea" && len(s.Routes) != 0 {
t.Errorf("%s: a route that is not accepted was attached", name)
}
}
}
}
}
func TestForwardingHeadersAreTheGateways(t *testing.T) {
accepted := func(name string, value string) metav1.Condition {
in := giteaInput(t)
in.HTTPRoutes[0].Spec.Rules[0].Filters = []HTTPRouteFilter{{Type: filterRequestHeaderModifier,
RequestHeaderModifier: &HTTPHeaderFilter{Set: []HTTPHeader{{Name: name, Value: value}}}}}
for _, r := range ComputeGateways("cp", gwNodes(), in).Routes {
if r.Name == "gitea" {
return condOf(r.Status.Parents[0].Conditions, condAccepted)
}
}
t.Fatalf("no status for route gitea")
return metav1.Condition{}
}
a := accepted("x-forwarded-proto", "https")
if a.Status != metav1.ConditionFalse || a.Reason != "UnsupportedValue" || !strings.Contains(a.Message, "X-Forwarded-Proto") {
t.Fatalf("a filter on X-Forwarded-Proto: %+v", a)
}
// a header that only looks like one of them is the route's to set
if a := accepted("X-Forwarded-Prefix", "/git"); a.Status != metav1.ConditionTrue {
t.Fatalf("a filter on X-Forwarded-Prefix: %+v", a)
}
}
func TestBackendNotFoundBreaksTheRule(t *testing.T) {
in := giteaInput(t)
in.HTTPRoutes[0].Spec.Rules[0].BackendRefs[0].Name = "nope"
d := ComputeGateways("cp", gwNodes(), in)
for _, r := range d.Routes {
if r.Name == "gitea" {
if c := condOf(r.Status.Parents[0].Conditions, condResolvedRefs); c.Status != metav1.ConditionFalse || c.Reason != "BackendNotFound" {
t.Fatalf("resolvedRefs: %+v", c)
}
if c := condOf(r.Status.Parents[0].Conditions, condAccepted); c.Status != metav1.ConditionTrue {
t.Fatalf("a route with an unresolved backend is still accepted: %+v", c)
}
}
}
h := ComputeGateways("w15", gwNodes(), in)
for _, s := range h.Serve {
if s.Name == "https-gitea" && (len(s.Routes) != 1 || !s.Routes[0].Rules[0].Broken) {
t.Fatalf("the rule must be served broken (500): %+v", s.Routes)
}
}
}
func TestTCPListenerForwardsTheOldestRoute(t *testing.T) {
in := giteaInput(t)
later := in.TCPRoutes[0].DeepCopyObject().(*TCPRoute)
later.Name = "other-ssh"
later.CreationTimestamp = metav1.NewTime(later.CreationTimestamp.Add(time.Minute))
later.Spec.Rules[0].BackendRefs[0].Name = "other"
in.TCPRoutes = append(in.TCPRoutes, later)
d := ComputeGateways("cp", gwNodes(), in)
for _, r := range d.Routes {
a := condOf(r.Status.Parents[0].Conditions, condAccepted)
switch r.Name {
case "gitea-ssh":
if a.Status != metav1.ConditionTrue {
t.Fatalf("oldest route: %+v", a)
}
case "other-ssh":
if a.Status != metav1.ConditionFalse || !strings.Contains(a.Message, "gitea/gitea-ssh") {
t.Fatalf("second route: %+v", a)
}
}
}
}
func TestOtherControllersParentsKept(t *testing.T) {
in := giteaInput(t)
theirs := RouteParentStatus{ParentRef: ParentReference{Name: "elsewhere"}, ControllerName: "example.com/other",
Conditions: []metav1.Condition{{Type: condAccepted, Status: metav1.ConditionTrue, Reason: "Accepted", LastTransitionTime: gwNow}}}
in.HTTPRoutes[0].Status.Parents = []RouteParentStatus{theirs}
d := ComputeGateways("cp", gwNodes(), in)
for _, r := range d.Routes {
if r.Name == "gitea" {
if len(r.Status.Parents) != 2 || r.Status.Parents[0].ControllerName != "example.com/other" {
t.Fatalf("another controller's entry must be kept: %+v", r.Status.Parents)
}
}
}
}
func TestOtherClassesIgnored(t *testing.T) {
in := giteaInput(t)
in.Classes[0].Spec.ControllerName = "example.com/other"
for _, self := range []string{"cp", "w15"} {
d := ComputeGateways(self, gwNodes(), in)
if len(d.Serve)+len(d.Forwards)+len(d.Gateways)+len(d.Routes)+len(d.Classes) != 0 {
t.Fatalf("%s acted on another controller's class: %+v", self, d)
}
}
}
func TestNarrowHostnames(t *testing.T) {
cases := []struct {
listener string
route []string
want []string
ok bool
}{
{"", nil, nil, true},
{"git.levk.dev", nil, []string{"git.levk.dev"}, true},
{"", []string{"a.example.com"}, []string{"a.example.com"}, true},
{"test.example.com", []string{"*.example.com"}, []string{"test.example.com"}, true},
{"*.example.com", []string{"test.example.com", "test.example.net", "foo.test.example.com"}, []string{"test.example.com", "foo.test.example.com"}, true},
{"*.example.com", []string{"example.com"}, nil, false},
{"*.example.com", []string{"*.a.example.com"}, []string{"*.a.example.com"}, true},
{"*.a.example.com", []string{"*.example.com"}, []string{"*.a.example.com"}, true},
{"git.levk.dev", []string{"git.example.net"}, nil, false},
}
for _, c := range cases {
got, ok := narrowHostnames(c.listener, c.route)
if ok != c.ok || strings.Join(got, ",") != strings.Join(c.want, ",") {
t.Errorf("listener %q route %v: got %v %v, want %v %v", c.listener, c.route, got, ok, c.want, c.ok)
}
}
}
func TestMergeGateways(t *testing.T) {
in := giteaInput(t)
nodes := gwNodes()
// on the holder: Gitea's SSH forwarded, its HTTP(S) ports let in
d := MergeGateways(Compute("w15", nodes, nil, nil), ComputeGateways("w15", nodes, in), "w15", nodes)
if len(d.Forwards) != 1 || len(d.Accept) != 2 || len(d.Holders) != 0 {
t.Fatalf("holder: forwards %+v accept %+v holders %+v", d.Forwards, d.Accept, d.Holders)
}
r := Render(d, EgressDesired{}, "w15", &net.IPNet{IP: net.ParseIP("10.32.0.0"), Mask: net.CIDRMask(12, 32)}, nil)
for _, want := range []string{
"-A REWEAVE-LB-IN -d 86.107.168.15/32 -p tcp -m tcp --dport 443",
"-A REWEAVE-LB-IN -d 86.107.168.15/32 -p tcp -m tcp --dport 80",
} {
if !strings.Contains(r.Filter, want) {
t.Errorf("filter lacks %q:\n%s", want, r.Filter)
}
}
if !strings.Contains(r.Nat, "-A REWEAVE-LB -d 86.107.168.15/32 -p tcp -m tcp --dport 22") || !strings.Contains(r.Nat, "--to-destination 10.44.0.12:22") {
t.Errorf("nat lacks the SSH forward:\n%s", r.Nat)
}
// elsewhere: w15 is a holder whose replies come back to it
e := MergeGateways(Compute("cp", nodes, nil, nil), ComputeGateways("cp", nodes, in), "cp", nodes)
if len(e.Forwards) != 0 || len(e.Accept) != 0 || len(e.Holders) != 1 || e.Holders[0].Node.Name != "w15" {
t.Fatalf("cp: forwards %+v accept %+v holders %+v", e.Forwards, e.Accept, e.Holders)
}
}
func TestUnboundListenerReported(t *testing.T) {
in := giteaInput(t)
in.Unbound = map[string]string{"gateway-system/shared/https-gitea": "address already in use"}
d := ComputeGateways("w15", gwNodes(), in)
for _, l := range d.Gateways[0].Status.Listeners {
if l.Name != "https-gitea" {
continue
}
if a := condOf(l.Conditions, condAccepted); a.Status != metav1.ConditionFalse || a.Reason != "PortUnavailable" {
t.Fatalf("accepted: %+v", a)
}
if p := condOf(l.Conditions, condProgrammed); p.Status != metav1.ConditionFalse {
t.Fatalf("programmed: %+v", p)
}
}
served := false
for _, s := range d.Serve {
served = served || s.Name == "https-gitea"
}
if !served {
t.Fatal("an unbound listener is still tried on every pass")
}
}