Files
kacerrandClaude Opus 5.5 42b53b7538 fix: produce valid listener names for wildcard and unusual DNS names
A DNS name like *.home.hrajfrisbee.cz put "*" into the listener name; the
API server rejected the patch, which blocked every certificate on that
Gateway.

- lowercase the DNS part, spell "*" as "wildcard", map other characters
  outside [a-z0-9] to "-"
- names over 253 chars are cut and get a hash suffix instead of a blind cut
- names that were valid before are unchanged

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 20:47:44 +02:00

356 lines
11 KiB
Go

package gateway
import (
"regexp"
"strings"
"testing"
"time"
cmv1 "github.com/cert-manager/cert-manager/pkg/apis/certmanager/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
gwapiv1 "sigs.k8s.io/gateway-api/apis/v1"
)
const testPrefix = "auto-"
func cert(namespace, name, secret string, dnsNames ...string) cmv1.Certificate {
return cmv1.Certificate{
ObjectMeta: metav1.ObjectMeta{Namespace: namespace, Name: name},
Spec: cmv1.CertificateSpec{SecretName: secret, DNSNames: dnsNames},
}
}
func listener(name, hostname, secretNamespace, secret string, port gwapiv1.PortNumber) gwapiv1.Listener {
h := gwapiv1.Hostname(hostname)
ns := gwapiv1.Namespace(secretNamespace)
return gwapiv1.Listener{
Name: gwapiv1.SectionName(name),
Hostname: &h,
Port: port,
Protocol: gwapiv1.HTTPSProtocolType,
TLS: &gwapiv1.ListenerTLSConfig{
CertificateRefs: []gwapiv1.SecretObjectReference{
{Name: gwapiv1.ObjectName(secret), Namespace: &ns},
},
},
}
}
func names(listeners []gwapiv1.Listener) []string {
out := make([]string, 0, len(listeners))
for _, l := range listeners {
out = append(out, string(l.Name))
}
return out
}
func equalStrings(a, b []string) bool {
if len(a) != len(b) {
return false
}
for i := range a {
if a[i] != b[i] {
return false
}
}
return true
}
func TestBuildListenersFromCertificates_names(t *testing.T) {
t.Parallel()
deleted := cert("default", "gone", "gone-tls", "gone.example.com")
now := metav1.NewTime(time.Unix(0, 0))
deleted.DeletionTimestamp = &now
tests := []struct {
name string
certs []cmv1.Certificate
want []string
}{
{
name: "no certificates",
},
{
name: "one listener per DNS name",
certs: []cmv1.Certificate{cert("default", "web", "web-tls", "a.example.com", "b.example.com")},
want: []string{"auto-web-a-example-com", "auto-web-b-example-com"},
},
{
name: "sorted by listener name across certificates",
certs: []cmv1.Certificate{
cert("default", "zeta", "zeta-tls", "z.example.com"),
cert("default", "alpha", "alpha-tls", "a.example.com"),
},
want: []string{"auto-alpha-a-example-com", "auto-zeta-z-example-com"},
},
{
name: "certificate being deleted is skipped",
certs: []cmv1.Certificate{deleted, cert("default", "web", "web-tls", "a.example.com")},
want: []string{"auto-web-a-example-com"},
},
{
name: "certificate without secret name is skipped",
certs: []cmv1.Certificate{cert("default", "web", "", "a.example.com")},
},
{
name: "certificate without DNS names yields nothing",
certs: []cmv1.Certificate{cert("default", "web", "web-tls")},
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
got := names(BuildListenersFromCertificates(tc.certs, testPrefix))
if !equalStrings(got, tc.want) {
t.Errorf("listener names = %v, want %v", got, tc.want)
}
})
}
}
func TestBuildListenersFromCertificates_listenerFields(t *testing.T) {
t.Parallel()
got := BuildListenersFromCertificates(
[]cmv1.Certificate{cert("apps", "web", "web-tls", "app.example.com")}, testPrefix)
if len(got) != 1 {
t.Fatalf("got %d listeners, want 1", len(got))
}
l := got[0]
if l.Hostname == nil || *l.Hostname != "app.example.com" {
t.Errorf("hostname = %v, want app.example.com", l.Hostname)
}
if l.Port != 443 {
t.Errorf("port = %d, want 443", l.Port)
}
if l.Protocol != gwapiv1.HTTPSProtocolType {
t.Errorf("protocol = %q, want HTTPS", l.Protocol)
}
if l.TLS == nil || l.TLS.Mode == nil || *l.TLS.Mode != gwapiv1.TLSModeTerminate {
t.Fatalf("TLS mode is not Terminate: %+v", l.TLS)
}
if len(l.TLS.CertificateRefs) != 1 {
t.Fatalf("got %d certificate refs, want 1", len(l.TLS.CertificateRefs))
}
ref := l.TLS.CertificateRefs[0]
if ref.Name != "web-tls" {
t.Errorf("secret name = %q, want web-tls", ref.Name)
}
if ref.Namespace == nil || *ref.Namespace != "apps" {
t.Errorf("secret namespace = %v, want the certificate's namespace apps", ref.Namespace)
}
if ref.Kind == nil || *ref.Kind != "Secret" {
t.Errorf("ref kind = %v, want Secret", ref.Kind)
}
if ref.Group == nil || *ref.Group != "" {
t.Errorf("ref group = %v, want the core group", ref.Group)
}
if l.AllowedRoutes == nil || l.AllowedRoutes.Namespaces == nil ||
l.AllowedRoutes.Namespaces.From == nil ||
*l.AllowedRoutes.Namespaces.From != gwapiv1.NamespacesFromAll {
t.Errorf("allowed routes = %+v, want namespaces from All", l.AllowedRoutes)
}
}
func TestMergeListeners_ownership(t *testing.T) {
t.Parallel()
manual := listener("https", "manual.example.com", "default", "manual-tls", 443)
stale := listener("auto-old-old-example-com", "old.example.com", "default", "old-tls", 443)
desired := listener("auto-web-a-example-com", "a.example.com", "default", "web-tls", 443)
tests := []struct {
name string
existing []gwapiv1.Listener
desired []gwapiv1.Listener
want []string
}{
{
name: "both empty",
},
{
name: "manual listeners are kept",
existing: []gwapiv1.Listener{manual},
desired: []gwapiv1.Listener{desired},
want: []string{"https", "auto-web-a-example-com"},
},
{
name: "stale managed listeners are dropped",
existing: []gwapiv1.Listener{manual, stale},
desired: []gwapiv1.Listener{desired},
want: []string{"https", "auto-web-a-example-com"},
},
{
name: "no desired listeners removes every managed one",
existing: []gwapiv1.Listener{stale, manual},
want: []string{"https"},
},
{
name: "existing managed listener is not duplicated",
existing: []gwapiv1.Listener{desired},
desired: []gwapiv1.Listener{desired},
want: []string{"auto-web-a-example-com"},
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
got := names(MergeListeners(tc.existing, tc.desired, testPrefix))
if !equalStrings(got, tc.want) {
t.Errorf("merged names = %v, want %v", got, tc.want)
}
})
}
}
func TestMergeListeners_replacesManagedContent(t *testing.T) {
t.Parallel()
old := listener("auto-web-a-example-com", "a.example.com", "default", "old-tls", 443)
desired := listener("auto-web-a-example-com", "a.example.com", "default", "web-tls", 443)
got := MergeListeners([]gwapiv1.Listener{old}, []gwapiv1.Listener{desired}, testPrefix)
if len(got) != 1 {
t.Fatalf("got %d listeners, want 1", len(got))
}
if name := got[0].TLS.CertificateRefs[0].Name; name != "web-tls" {
t.Errorf("secret name = %q, want the desired web-tls", name)
}
}
func TestListenersEqual_managedFields(t *testing.T) {
t.Parallel()
a := listener("auto-web-a-example-com", "a.example.com", "default", "web-tls", 443)
b := listener("auto-web-b-example-com", "b.example.com", "default", "web-tls", 443)
tests := []struct {
name string
x, y []gwapiv1.Listener
want bool
}{
{name: "both empty", want: true},
{name: "identical", x: []gwapiv1.Listener{a, b}, y: []gwapiv1.Listener{a, b}, want: true},
{name: "order is ignored", x: []gwapiv1.Listener{a, b}, y: []gwapiv1.Listener{b, a}, want: true},
{name: "different length", x: []gwapiv1.Listener{a, b}, y: []gwapiv1.Listener{a}, want: false},
{name: "different name", x: []gwapiv1.Listener{a}, y: []gwapiv1.Listener{b}, want: false},
{
name: "different hostname",
x: []gwapiv1.Listener{a},
y: []gwapiv1.Listener{listener("auto-web-a-example-com", "other.example.com", "default", "web-tls", 443)},
want: false,
},
{
name: "different port",
x: []gwapiv1.Listener{a},
y: []gwapiv1.Listener{listener("auto-web-a-example-com", "a.example.com", "default", "web-tls", 8443)},
want: false,
},
{
name: "different secret name",
x: []gwapiv1.Listener{a},
y: []gwapiv1.Listener{listener("auto-web-a-example-com", "a.example.com", "default", "other-tls", 443)},
want: false,
},
{
name: "different secret namespace",
x: []gwapiv1.Listener{a},
y: []gwapiv1.Listener{listener("auto-web-a-example-com", "a.example.com", "other", "web-tls", 443)},
want: false,
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
if got := ListenersEqual(tc.x, tc.y); got != tc.want {
t.Errorf("ListenersEqual() = %v, want %v", got, tc.want)
}
})
}
}
// sectionNamePattern is the Gateway API SectionName validation rule.
var sectionNamePattern = regexp.MustCompile(`^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$`)
func assertValidSectionName(t *testing.T, name string) {
t.Helper()
if len(name) > 253 {
t.Errorf("name is %d chars, limit is 253", len(name))
}
if !sectionNamePattern.MatchString(name) {
t.Errorf("name %q is not a valid SectionName", name)
}
}
func TestListenerNameFor_validSectionName(t *testing.T) {
t.Parallel()
tests := []struct {
name string
certName string
dnsName string
want string
}{
{name: "dots become dashes", certName: "web", dnsName: "app.example.com", want: "auto-web-app-example-com"},
{name: "single label", certName: "web", dnsName: "localhost", want: "auto-web-localhost"},
{name: "existing dashes are kept", certName: "my-cert", dnsName: "my-app.example.com", want: "auto-my-cert-my-app-example-com"},
{
name: "wildcard label is spelled out",
certName: "wildcard-home-hrajfrisbee",
dnsName: "*.home.hrajfrisbee.cz",
want: "auto-wildcard-home-hrajfrisbee-wildcard-home-hrajfrisbee-cz",
},
{name: "uppercase is lowered", certName: "web", dnsName: "App.Example.COM", want: "auto-web-app-example-com"},
{name: "other characters become dashes", certName: "web", dnsName: "my_app.example.com", want: "auto-web-my-app-example-com"},
{name: "trailing dot is dropped", certName: "web", dnsName: "app.example.com.", want: "auto-web-app-example-com"},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
got := listenerNameFor(testPrefix, tc.certName, tc.dnsName)
if got != tc.want {
t.Errorf("listenerNameFor() = %q, want %q", got, tc.want)
}
assertValidSectionName(t, got)
})
}
}
func TestListenerNameFor_longNames(t *testing.T) {
t.Parallel()
// 62 dashes right where the name is cut, so a blind cut would end in "-".
shared := strings.Repeat("a", 200) + strings.Repeat("-", 62)
first := listenerNameFor(testPrefix, "web", shared+"x.example.com")
second := listenerNameFor(testPrefix, "web", shared+"y.example.com")
assertValidSectionName(t, first)
assertValidSectionName(t, second)
if first == second {
t.Errorf("two long DNS names sharing a prefix got the same listener name %q", first)
}
if again := listenerNameFor(testPrefix, "web", shared+"x.example.com"); again != first {
t.Errorf("name is not deterministic: %q then %q", first, again)
}
}
func TestBuildListenersFromCertificates_wildcard(t *testing.T) {
t.Parallel()
got := BuildListenersFromCertificates(
[]cmv1.Certificate{cert("default", "wild", "wild-tls", "*.example.com")}, testPrefix)
if len(got) != 1 {
t.Fatalf("got %d listeners, want 1", len(got))
}
assertValidSectionName(t, string(got[0].Name))
if got[0].Hostname == nil || *got[0].Hostname != "*.example.com" {
t.Errorf("hostname = %v, want the wildcard kept as *.example.com", got[0].Hostname)
}
}