Repository: haproxytech/kubernetes-ingress

Issue:
Subdomain wildcard does not work together with route-acl service annotation
**APP VERSION**: 3.1.11
**CHART**: kubernetes-ingress-1.44.6

It's possible to use [route-acl](https://www.haproxy.com/documentation/kubernetes-ingress/community/configuration-reference/service/#route-acl) service annotation to specify paths against regular expressions. For instance, if I want the service to only match paths with `path-in-bug-repro`, I can add the Service annotation `haproxy.org/route-acl: path_reg path-in-bug-repro$`, using the following helm value file

```YAML
# helm upgrade --install -f ./values.yaml haproxy-bug oci://registry-1.docker.io/bitnamicharts/nginx
ingress:
  enabled: true
  hostname: "example.local"
  ingressClassName: "haproxy"
  tls: false

service:
  annotations:
    haproxy.org/route-acl: path_reg path-in-bug-repro$
  type: ClusterIP

# Define a custom server block.
# The content is a multi-line string containing the Nginx configuration.
serverBlock: |
  server {
    listen 0.0.0.0:8080;
    server_name _;

    location / {
      default_type text/html;

      <!DOCTYPE html>
      <html lang="en">
      <body>
          <h1>BUG REPRO</h1>
      </body>
      </html>
      ';
    }
  }
```

And requests to said path will match:

<img width="1030" height="333" alt="Image" src="https://github.com/user-attachments/assets/16f89250-6448-4ff7-88eb-a669914239c5" />

Other paths will not:

<img width="1437" height="144" alt="Image" src="https://github.com/user-attachments/assets/fcfc47d7-8741-4749-8324-f515230df7cd" />

However, when trying to use this feature in combination with wildcard  subdomains, it does not work. Using the YAML (notice the wildcard in the hostname)

```yaml
# helm upgrade --install -f ./values.yaml haproxy-bug oci://registry-1.docker.io/bitnamicharts/nginx
ingress:
  enabled: true
  hostname: "*.example.local"
  ingressClassName: "haproxy"
  tls: false

service:
  annotations:
    haproxy.org/route-acl: path_reg path-in-bug-repro$
  type: ClusterIP

# Define a custom server block.
serverBlock: |
  server {
    listen 0.0.0.0:8080;
    server_name _;

    location / {
      # Set the Content-Type header so browsers render the HTML correctly.
      default_type text/html;

      <!DOCTYPE html>
      <html lang="en">
      <body>
          <h1>BUG REPRO</h1>
      </body>
      </html>
      ';
    }
  }
```

If I access the an subdomain of the wildcard, for instance `subdomain.example.local` it will not match

<img width="935" height="129" alt="Image" src="https://github.com/user-attachments/assets/bb049598-4564-4eb8-9ae1-6e86096e50fb" />

However, if I add the hostname as `subdomain.example.com` exactly, without wildcard, it starts working again.

<img width="1399" height="329" alt="Image" src="https://github.com/user-attachments/assets/10a6eb8b-d830-4b26-9982-0221d6caec2c" />

This led me to debug the ACL rules, and I found out that in https://github.com/haproxytech/kubernetes-ingress/blob/master/pkg/route/route.go#L107 the Ingress controller always adds a `path -m beg <hostname>`, which leads to a `if { var(txn.host) -m str *.example.local }` rule. Notice the `-m str *.example.com` it's matching an exact match of string that has a literal "*" and then .example.com

<img width="1079" height="498" alt="Image" src="https://github.com/user-attachments/assets/6673c033-bc7e-4d8b-af1a-fd93111bdaad" />

Instead, when using wildcard subdomains, the Ingress controller should use suffix match, which would work for subdomains:

`if { var(txn.host) -m end .example.local }`

AFAIK that should fix the issue and make subdomain wildcards and route-acl work correctly together :)


Discussion:
@SF97: Created PR in https://github.com/haproxytech/kubernetes-ingress/pull/735@stale: This issue has been automatically marked as stale because it has not had recent activity. It will be closed if no further activity occurs. Thank you for your contributions.
@SF97: Not stale. This issue has been fixed in https://github.com/haproxytech/kubernetes-ingress/pull/735

Solution patch:
diff --git a/.aspell.yml b/.aspell.yml
index fc6d996..f9c4190 100644
--- a/.aspell.yml
+++ b/.aspell.yml
@@ -57,3 +57,5 @@ allowed:
   - frontent
   - pprof
   - preload
+  - hostname
+  - str
diff --git a/pkg/route/route.go b/pkg/route/route.go
index 8c3f91d..b366785 100644
--- a/pkg/route/route.go
+++ b/pkg/route/route.go
@@ -104,7 +104,13 @@ func AddHostPathRoute(route Route, mapFiles maps.Maps) error {
 func AddCustomRoute(route Route, routeACLAnn string, api api.HAProxyClient) (err error) {
 	var routeCond string
 	if route.Host != "" {
-		routeCond = fmt.Sprintf("{ var(txn.host) -m str %s } ", route.Host)
+		if route.Host[0] == '*' {
+			// Wildcard host - use suffix matching
+			routeCond = fmt.Sprintf("{ var(txn.host) -m end %s } ", route.Host[1:])
+		} else {
+			// Regular host - use string matching
+			routeCond = fmt.Sprintf("{ var(txn.host) -m str %s } ", route.Host)
+		}
 	}
 	if route.Path.Path != "" {
 		if route.Path.PathTypeMatch == store.PATH_TYPE_EXACT {



Test patch:
diff --git a/deploy/tests/tnr/routeacl/suite_test.go b/deploy/tests/tnr/routeacl/suite_test.go
index d955a74..0b8a65e 100644
--- a/deploy/tests/tnr/routeacl/suite_test.go
+++ b/deploy/tests/tnr/routeacl/suite_test.go
@@ -208,3 +208,215 @@ func (suite *UseBackendSuite) UseBackendFixture() (eventChan chan k8ssync.SyncDa
 	<-controllerHasWorked
 	return eventChan
 }
+
+func (suite *UseBackendSuite) NonWildcardHostFixture() (eventChan chan k8ssync.SyncDataEvent) {
+	var osArgs utils.OSArgs
+	os.Args = []string{os.Args[0], "-e", "-t", "--config-dir=" + suite.test.TempDir}
+	parser := flags.NewParser(&osArgs, flags.IgnoreUnknown)
+	_, errParsing := parser.Parse() //nolint:ifshort
+	if errParsing != nil {
+		suite.T().Fatal(errParsing)
+	}
+
+	s := store.NewK8sStore(osArgs)
+
+	haproxyEnv := env.Env{
+		CfgDir: suite.test.TempDir,
+		Proxies: env.Proxies{
+			FrontHTTP:  "http",
+			FrontHTTPS: "https",
+			FrontSSL:   "ssl",
+			BackSSL:    "ssl-backend",
+		},
+	}
+
+	eventChan = make(chan k8ssync.SyncDataEvent, watch.DefaultChanSize*6)
+	controller := c.NewBuilder().
+		WithHaproxyCfgFile([]byte(haproxyConfig)).
+		WithEventChan(eventChan).
+		WithStore(s).
+		WithHaproxyEnv(haproxyEnv).
+		WithUpdateStatusManager(&updateStatusManager{}).
+		WithArgs(osArgs).Build()
+
+	go controller.Start()
+
+	// Now sending store events for test setup
+	ns := store.Namespace{Name: "ns", Status: store.ADDED}
+	eventChan <- k8ssync.SyncDataEvent{SyncType: k8ssync.NAMESPACE, Namespace: ns.Name, Data: &ns}
+
+	endpoints := &store.Endpoints{
+		SliceName: "api-service",
+		Service:   "api-service",
+		Namespace: ns.Name,
+		Ports: map[string]*store.PortEndpoints{
+			"https": {
+				Port:      int64(3001),
+				Addresses: map[string]struct{}{"10.244.0.11": {}},
+			},
+		},
+		Status: store.ADDED,
+	}
+
+	eventChan <- k8ssync.SyncDataEvent{SyncType: k8ssync.ENDPOINTS, Namespace: endpoints.Namespace, Data: endpoints}
+
+	service := &store.Service{
+		Name:        "api-service",
+		Namespace:   ns.Name,
+		Annotations: map[string]string{"route-acl": "path_reg path-in-bug-repro$"},
+		Ports: []store.ServicePort{
+			{
+				Name:     "https",
+				Protocol: "TCP",
+				Port:     8443,
+				Status:   store.ADDED,
+			},
+		},
+		Status: store.ADDED,
+	}
+	eventChan <- k8ssync.SyncDataEvent{SyncType: k8ssync.SERVICE, Namespace: service.Namespace, Data: service}
+
+	ingressClass := &store.IngressClass{
+		Name:       "haproxy",
+		Controller: "haproxy.org/ingress-controller",
+		Status:     store.ADDED,
+	}
+	eventChan <- k8ssync.SyncDataEvent{SyncType: k8ssync.INGRESS_CLASS, Data: ingressClass}
+
+	prefixPathType := networkingv1.PathTypePrefix
+	ingress := &store.Ingress{
+		IngressCore: store.IngressCore{
+			APIVersion: store.NETWORKINGV1,
+			Name:       "api-ingress",
+			Namespace:  ns.Name,
+			Class:      "haproxy",
+			Rules: map[string]*store.IngressRule{
+				"api.example.local": {
+					Host: "api.example.local", // Explicitly set the Host field
+					Paths: map[string]*store.IngressPath{
+						string(prefixPathType) + "-/": {
+							Path:          "/",
+							PathTypeMatch: string(prefixPathType),
+							SvcNamespace:  service.Namespace,
+							SvcPortString: "https",
+							SvcName:       service.Name,
+						},
+					},
+				},
+			},
+		},
+		Status: store.ADDED,
+	}
+
+	eventChan <- k8ssync.SyncDataEvent{SyncType: k8ssync.INGRESS, Namespace: ingress.Namespace, Data: ingress}
+	controllerHasWorked := make(chan struct{})
+	eventChan <- k8ssync.SyncDataEvent{SyncType: k8ssync.COMMAND, EventProcessed: controllerHasWorked}
+	<-controllerHasWorked
+	return eventChan
+}
+
+func (suite *UseBackendSuite) WildcardHostFixture() (eventChan chan k8ssync.SyncDataEvent) {
+	var osArgs utils.OSArgs
+	os.Args = []string{os.Args[0], "-e", "-t", "--config-dir=" + suite.test.TempDir}
+	parser := flags.NewParser(&osArgs, flags.IgnoreUnknown)
+	_, errParsing := parser.Parse() //nolint:ifshort
+	if errParsing != nil {
+		suite.T().Fatal(errParsing)
+	}
+
+	s := store.NewK8sStore(osArgs)
+
+	haproxyEnv := env.Env{
+		CfgDir: suite.test.TempDir,
+		Proxies: env.Proxies{
+			FrontHTTP:  "http",
+			FrontHTTPS: "https",
+			FrontSSL:   "ssl",
+			BackSSL:    "ssl-backend",
+		},
+	}
+
+	eventChan = make(chan k8ssync.SyncDataEvent, watch.DefaultChanSize*6)
+	controller := c.NewBuilder().
+		WithHaproxyCfgFile([]byte(haproxyConfig)).
+		WithEventChan(eventChan).
+		WithStore(s).
+		WithHaproxyEnv(haproxyEnv).
+		WithUpdateStatusManager(&updateStatusManager{}).
+		WithArgs(osArgs).Build()
+
+	go controller.Start()
+
+	// Now sending store events for test setup
+	ns := store.Namespace{Name: "ns", Status: store.ADDED}
+	eventChan <- k8ssync.SyncDataEvent{SyncType: k8ssync.NAMESPACE, Namespace: ns.Name, Data: &ns}
+
+	endpoints := &store.Endpoints{
+		SliceName: "wildcard-service",
+		Service:   "wildcard-service",
+		Namespace: ns.Name,
+		Ports: map[string]*store.PortEndpoints{
+			"https": {
+				Port:      int64(3001),
+				Addresses: map[string]struct{}{"10.244.0.10": {}},
+			},
+		},
+		Status: store.ADDED,
+	}
+
+	eventChan <- k8ssync.SyncDataEvent{SyncType: k8ssync.ENDPOINTS, Namespace: endpoints.Namespace, Data: endpoints}
+
+	service := &store.Service{
+		Name:        "wildcard-service",
+		Namespace:   ns.Name,
+		Annotations: map[string]string{"route-acl": "path_reg path-in-bug-repro$"},
+		Ports: []store.ServicePort{
+			{
+				Name:     "https",
+				Protocol: "TCP",
+				Port:     8443,
+				Status:   store.ADDED,
+			},
+		},
+		Status: store.ADDED,
+	}
+	eventChan <- k8ssync.SyncDataEvent{SyncType: k8ssync.SERVICE, Namespace: service.Namespace, Data: service}
+
+	ingressClass := &store.IngressClass{
+		Name:       "haproxy",
+		Controller: "haproxy.org/ingress-controller",
+		Status:     store.ADDED,
+	}
+	eventChan <- k8ssync.SyncDataEvent{SyncType: k8ssync.INGRESS_CLASS, Data: ingressClass}
+
+	prefixPathType := networkingv1.PathTypePrefix
+	ingress := &store.Ingress{
+		IngressCore: store.IngressCore{
+			APIVersion: store.NETWORKINGV1,
+			Name:       "wildcard-ingress",
+			Namespace:  ns.Name,
+			Class:      "haproxy",
+			Rules: map[string]*store.IngressRule{
+				"*.example.local": {
+					Host: "*.example.local", // Explicitly set the Host field
+					Paths: map[string]*store.IngressPath{
+						string(prefixPathType) + "-/": {
+							Path:          "/",
+							PathTypeMatch: string(prefixPathType),
+							SvcNamespace:  service.Namespace,
+							SvcPortString: "https",
+							SvcName:       service.Name,
+						},
+					},
+				},
+			},
+		},
+		Status: store.ADDED,
+	}
+
+	eventChan <- k8ssync.SyncDataEvent{SyncType: k8ssync.INGRESS, Namespace: ingress.Namespace, Data: ingress}
+	controllerHasWorked := make(chan struct{})
+	eventChan <- k8ssync.SyncDataEvent{SyncType: k8ssync.COMMAND, EventProcessed: controllerHasWorked}
+	<-controllerHasWorked
+	return eventChan
+}
diff --git a/deploy/tests/tnr/routeacl/usebackend_test.go b/deploy/tests/tnr/routeacl/usebackend_test.go
index c41e60d..d705dff 100644
--- a/deploy/tests/tnr/routeacl/usebackend_test.go
+++ b/deploy/tests/tnr/routeacl/usebackend_test.go
@@ -32,3 +32,53 @@ func (suite *UseBackendSuite) TestUseBackend() {
 		suite.Exactly(c, 2, "use_backend for route-acl is repeated %d times but expected 2", c)
 	})
 }
+
+func (suite *UseBackendSuite) TestNonWildcardHostWithRouteACL() {
+	// Test non-wildcard host first to ensure route-acl works
+	suite.NonWildcardHostFixture()
+	suite.Run("Non-wildcard host should use string matching (-m str) with route-acl", func() {
+		contents, err := os.ReadFile(filepath.Join(suite.test.TempDir, "haproxy.cfg"))
+		if err != nil {
+			suite.T().Error(err.Error())
+		}
+
+		// Check that -m str is used with non-wildcard hosts in route-acl
+		if !strings.Contains(string(contents), "var(txn.host) -m str api.example.local") {
+			suite.T().Error("Expected to find 'var(txn.host) -m str api.example.local' in HAProxy config")
+		}
+
+		// Check that route-acl annotation is applied
+		if !strings.Contains(string(contents), "path_reg path-in-bug-repro$") {
+			suite.T().Error("Expected to find route-acl pattern 'path_reg path-in-bug-repro$' in HAProxy config")
+		}
+	})
+}
+
+func (suite *UseBackendSuite) TestWildcardHostWithRouteACL() {
+	// This test addresses https://github.com/haproxytech/kubernetes-ingress/issues/734
+	suite.WildcardHostFixture()
+	suite.Run("Wildcard host should use suffix matching (-m end) with route-acl", func() {
+		contents, err := os.ReadFile(filepath.Join(suite.test.TempDir, "haproxy.cfg"))
+		if err != nil {
+			suite.T().Error(err.Error())
+		}
+
+		// Debug: Print the actual config to see what's generated
+		suite.T().Logf("Generated HAProxy config:\n%s", string(contents))
+
+		// Check that -m end is used with wildcard hosts in route-acl
+		if !strings.Contains(string(contents), "var(txn.host) -m end .example.local") {
+			suite.T().Error("Expected to find 'var(txn.host) -m end .example.local' in HAProxy config")
+		}
+
+		// Check that the buggy -m str pattern is NOT used
+		if strings.Contains(string(contents), "var(txn.host) -m str *.example.local") {
+			suite.T().Error("Found buggy pattern 'var(txn.host) -m str *.example.local' in HAProxy config")
+		}
+
+		// Check that route-acl annotation is applied
+		if !strings.Contains(string(contents), "path_reg path-in-bug-repro$") {
+			suite.T().Error("Expected to find route-acl pattern 'path_reg path-in-bug-repro$' in HAProxy config")
+		}
+	})
+}