images / images (push) Successful in 7m29s
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
612 lines
25 KiB
Go
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")
|
|
}
|
|
}
|