This is an automated email from the ASF dual-hosted git repository.

AlinsRan pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/apisix-ingress-controller.git


The following commit(s) were added to refs/heads/master by this push:
     new 11635a1e feat: add L4RoutePolicy for attaching stream plugins to L4 
routes (#2791)
11635a1e is described below

commit 11635a1e1fc76366984713177a92980d605e53f8
Author: AlinsRan <[email protected]>
AuthorDate: Tue Jun 16 10:44:02 2026 +0800

    feat: add L4RoutePolicy for attaching stream plugins to L4 routes (#2791)
---
 api/v1alpha1/l4routepolicy_types.go                |  69 ++++
 api/v1alpha1/zz_generated.deepcopy.go              |  88 +++++
 .../bases/apisix.apache.org_l4routepolicies.yaml   | 429 +++++++++++++++++++++
 config/crd/kustomization.yaml                      |   1 +
 config/rbac/role.yaml                              |   2 +
 docs/en/latest/reference/api-reference.md          |  36 ++
 internal/adc/translator/l4route_test.go            | 269 +++++++++++++
 internal/adc/translator/l4routepolicy_test.go      | 143 +++++++
 internal/adc/translator/policies.go                |  57 +++
 internal/adc/translator/tcproute.go                |   5 +
 internal/adc/translator/tlsroute.go                |   6 +
 internal/adc/translator/udproute.go                |   5 +
 internal/controller/indexer/indexer.go             |  27 ++
 internal/controller/policies.go                    | 193 +++++++++
 internal/controller/status/updater.go              |   6 +
 internal/controller/tcproute_controller.go         |  44 +++
 internal/controller/tlsroute_controller.go         |  44 +++
 internal/controller/udproute_controller.go         |  44 +++
 internal/manager/controllers.go                    |   2 +
 internal/provider/provider.go                      |   2 +
 internal/types/k8s.go                              |   9 +
 test/e2e/framework/assertion.go                    |  32 ++
 test/e2e/framework/manifests/ingress.yaml          |   2 +
 test/e2e/gatewayapi/tcproute.go                    |  94 +++++
 test/e2e/scaffold/k8s.go                           |  16 +
 25 files changed, 1625 insertions(+)

diff --git a/api/v1alpha1/l4routepolicy_types.go 
b/api/v1alpha1/l4routepolicy_types.go
new file mode 100644
index 00000000..5bc81e66
--- /dev/null
+++ b/api/v1alpha1/l4routepolicy_types.go
@@ -0,0 +1,69 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+package v1alpha1
+
+import (
+       metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+       gatewayv1alpha2 "sigs.k8s.io/gateway-api/apis/v1alpha2"
+)
+
+// L4RoutePolicySpec defines the desired state of L4RoutePolicy.
+type L4RoutePolicySpec struct {
+       // TargetRefs identifies the L4 route resources (TCPRoute, UDPRoute, or 
TLSRoute)
+       // to which this policy applies. Only same-namespace targets are 
supported.
+       //
+       // +kubebuilder:validation:MinItems=1
+       // +kubebuilder:validation:MaxItems=16
+       // +kubebuilder:validation:XValidation:rule="self.all(r, r.kind == 
'TCPRoute' || r.kind == 'UDPRoute' || r.kind == 
'TLSRoute')",message="targetRefs kind must be TCPRoute, UDPRoute, or TLSRoute"
+       // +kubebuilder:validation:XValidation:rule="self.all(r, r.group == 
'gateway.networking.k8s.io')",message="targetRefs group must be 
gateway.networking.k8s.io"
+       TargetRefs []gatewayv1alpha2.LocalPolicyTargetReferenceWithSectionName 
`json:"targetRefs"`
+
+       // Plugins is the list of APISIX stream plugins to attach to the 
targeted L4 routes.
+       // Plugin names should be valid APISIX stream plugin names (e.g., 
limit-conn, ip-restriction).
+       //
+       // +optional
+       Plugins []Plugin `json:"plugins,omitempty"`
+}
+
+// +kubebuilder:object:root=true
+// +kubebuilder:subresource:status
+
+// L4RoutePolicy defines plugin configuration for Gateway API L4 routes 
(TCPRoute, UDPRoute, TLSRoute).
+// It follows the Gateway API Policy Attachment pattern and attaches APISIX 
stream plugins
+// to the targeted L4 route resources.
+type L4RoutePolicy struct {
+       metav1.TypeMeta   `json:",inline"`
+       metav1.ObjectMeta `json:"metadata,omitempty"`
+
+       // Spec defines the desired state of L4RoutePolicy.
+       Spec   L4RoutePolicySpec `json:"spec,omitempty"`
+       Status PolicyStatus      `json:"status,omitempty"`
+}
+
+// +kubebuilder:object:root=true
+
+// L4RoutePolicyList contains a list of L4RoutePolicy.
+type L4RoutePolicyList struct {
+       metav1.TypeMeta `json:",inline"`
+       metav1.ListMeta `json:"metadata,omitempty"`
+       Items           []L4RoutePolicy `json:"items"`
+}
+
+func init() {
+       SchemeBuilder.Register(&L4RoutePolicy{}, &L4RoutePolicyList{})
+}
diff --git a/api/v1alpha1/zz_generated.deepcopy.go 
b/api/v1alpha1/zz_generated.deepcopy.go
index 9e1c02ff..06a43ca9 100644
--- a/api/v1alpha1/zz_generated.deepcopy.go
+++ b/api/v1alpha1/zz_generated.deepcopy.go
@@ -716,6 +716,94 @@ func (in *HealthCheck) DeepCopy() *HealthCheck {
        return out
 }
 
+// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, 
writing into out. in must be non-nil.
+func (in *L4RoutePolicy) DeepCopyInto(out *L4RoutePolicy) {
+       *out = *in
+       out.TypeMeta = in.TypeMeta
+       in.ObjectMeta.DeepCopyInto(&out.ObjectMeta)
+       in.Spec.DeepCopyInto(&out.Spec)
+       in.Status.DeepCopyInto(&out.Status)
+}
+
+// DeepCopy is an autogenerated deepcopy function, copying the receiver, 
creating a new L4RoutePolicy.
+func (in *L4RoutePolicy) DeepCopy() *L4RoutePolicy {
+       if in == nil {
+               return nil
+       }
+       out := new(L4RoutePolicy)
+       in.DeepCopyInto(out)
+       return out
+}
+
+// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, 
creating a new runtime.Object.
+func (in *L4RoutePolicy) DeepCopyObject() runtime.Object {
+       if c := in.DeepCopy(); c != nil {
+               return c
+       }
+       return nil
+}
+
+// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, 
writing into out. in must be non-nil.
+func (in *L4RoutePolicyList) DeepCopyInto(out *L4RoutePolicyList) {
+       *out = *in
+       out.TypeMeta = in.TypeMeta
+       in.ListMeta.DeepCopyInto(&out.ListMeta)
+       if in.Items != nil {
+               in, out := &in.Items, &out.Items
+               *out = make([]L4RoutePolicy, len(*in))
+               for i := range *in {
+                       (*in)[i].DeepCopyInto(&(*out)[i])
+               }
+       }
+}
+
+// DeepCopy is an autogenerated deepcopy function, copying the receiver, 
creating a new L4RoutePolicyList.
+func (in *L4RoutePolicyList) DeepCopy() *L4RoutePolicyList {
+       if in == nil {
+               return nil
+       }
+       out := new(L4RoutePolicyList)
+       in.DeepCopyInto(out)
+       return out
+}
+
+// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, 
creating a new runtime.Object.
+func (in *L4RoutePolicyList) DeepCopyObject() runtime.Object {
+       if c := in.DeepCopy(); c != nil {
+               return c
+       }
+       return nil
+}
+
+// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, 
writing into out. in must be non-nil.
+func (in *L4RoutePolicySpec) DeepCopyInto(out *L4RoutePolicySpec) {
+       *out = *in
+       if in.TargetRefs != nil {
+               in, out := &in.TargetRefs, &out.TargetRefs
+               *out = 
make([]v1alpha2.LocalPolicyTargetReferenceWithSectionName, len(*in))
+               for i := range *in {
+                       (*in)[i].DeepCopyInto(&(*out)[i])
+               }
+       }
+       if in.Plugins != nil {
+               in, out := &in.Plugins, &out.Plugins
+               *out = make([]Plugin, len(*in))
+               for i := range *in {
+                       (*in)[i].DeepCopyInto(&(*out)[i])
+               }
+       }
+}
+
+// DeepCopy is an autogenerated deepcopy function, copying the receiver, 
creating a new L4RoutePolicySpec.
+func (in *L4RoutePolicySpec) DeepCopy() *L4RoutePolicySpec {
+       if in == nil {
+               return nil
+       }
+       out := new(L4RoutePolicySpec)
+       in.DeepCopyInto(out)
+       return out
+}
+
 // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, 
writing into out. in must be non-nil.
 func (in *LoadBalancer) DeepCopyInto(out *LoadBalancer) {
        *out = *in
diff --git a/config/crd/bases/apisix.apache.org_l4routepolicies.yaml 
b/config/crd/bases/apisix.apache.org_l4routepolicies.yaml
new file mode 100644
index 00000000..3981fb4f
--- /dev/null
+++ b/config/crd/bases/apisix.apache.org_l4routepolicies.yaml
@@ -0,0 +1,429 @@
+---
+apiVersion: apiextensions.k8s.io/v1
+kind: CustomResourceDefinition
+metadata:
+  annotations:
+    controller-gen.kubebuilder.io/version: v0.17.2
+  name: l4routepolicies.apisix.apache.org
+spec:
+  group: apisix.apache.org
+  names:
+    kind: L4RoutePolicy
+    listKind: L4RoutePolicyList
+    plural: l4routepolicies
+    singular: l4routepolicy
+  scope: Namespaced
+  versions:
+  - name: v1alpha1
+    schema:
+      openAPIV3Schema:
+        description: |-
+          L4RoutePolicy defines plugin configuration for Gateway API L4 routes 
(TCPRoute, UDPRoute, TLSRoute).
+          It follows the Gateway API Policy Attachment pattern and attaches 
APISIX stream plugins
+          to the targeted L4 route resources.
+        properties:
+          apiVersion:
+            description: |-
+              APIVersion defines the versioned schema of this representation 
of an object.
+              Servers should convert recognized schemas to the latest internal 
value, and
+              may reject unrecognized values.
+              More info: 
https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources
+            type: string
+          kind:
+            description: |-
+              Kind is a string value representing the REST resource this 
object represents.
+              Servers may infer this from the endpoint the client submits 
requests to.
+              Cannot be updated.
+              In CamelCase.
+              More info: 
https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds
+            type: string
+          metadata:
+            type: object
+          spec:
+            description: Spec defines the desired state of L4RoutePolicy.
+            properties:
+              plugins:
+                description: |-
+                  Plugins is the list of APISIX stream plugins to attach to 
the targeted L4 routes.
+                  Plugin names should be valid APISIX stream plugin names 
(e.g., limit-conn, ip-restriction).
+                items:
+                  properties:
+                    config:
+                      description: Config is plugin configuration details.
+                      x-kubernetes-preserve-unknown-fields: true
+                    name:
+                      description: Name is the name of the plugin.
+                      type: string
+                  required:
+                  - name
+                  type: object
+                type: array
+              targetRefs:
+                description: |-
+                  TargetRefs identifies the L4 route resources (TCPRoute, 
UDPRoute, or TLSRoute)
+                  to which this policy applies. Only same-namespace targets 
are supported.
+                items:
+                  description: |-
+                    LocalPolicyTargetReferenceWithSectionName identifies an 
API object to apply a
+                    direct policy to. This should be used as part of Policy 
resources that can
+                    target single resources. For more information on how this 
policy attachment
+                    mode works, and a sample Policy resource, refer to the 
policy attachment
+                    documentation for Gateway API.
+
+                    Note: This should only be used for direct policy 
attachment when references
+                    to SectionName are actually needed. In all other cases,
+                    LocalPolicyTargetReference should be used.
+                  properties:
+                    group:
+                      description: Group is the group of the target resource.
+                      maxLength: 253
+                      pattern: 
^$|^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$
+                      type: string
+                    kind:
+                      description: Kind is kind of the target resource.
+                      maxLength: 63
+                      minLength: 1
+                      pattern: ^[a-zA-Z]([-a-zA-Z0-9]*[a-zA-Z0-9])?$
+                      type: string
+                    name:
+                      description: Name is the name of the target resource.
+                      maxLength: 253
+                      minLength: 1
+                      type: string
+                    sectionName:
+                      description: |-
+                        SectionName is the name of a section within the target 
resource. When
+                        unspecified, this targetRef targets the entire 
resource. In the following
+                        resources, SectionName is interpreted as the following:
+
+                        * Gateway: Listener name
+                        * HTTPRoute: HTTPRouteRule name
+                        * Service: Port name
+
+                        If a SectionName is specified, but does not exist on 
the targeted object,
+                        the Policy must fail to attach, and the policy 
implementation should record
+                        a `ResolvedRefs` or similar Condition in the Policy's 
status.
+                      maxLength: 253
+                      minLength: 1
+                      pattern: 
^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$
+                      type: string
+                  required:
+                  - group
+                  - kind
+                  - name
+                  type: object
+                maxItems: 16
+                minItems: 1
+                type: array
+                x-kubernetes-validations:
+                - message: targetRefs kind must be TCPRoute, UDPRoute, or 
TLSRoute
+                  rule: self.all(r, r.kind == 'TCPRoute' || r.kind == 
'UDPRoute' ||
+                    r.kind == 'TLSRoute')
+                - message: targetRefs group must be gateway.networking.k8s.io
+                  rule: self.all(r, r.group == 'gateway.networking.k8s.io')
+            required:
+            - targetRefs
+            type: object
+          status:
+            description: |-
+              PolicyStatus defines the common attributes that all Policies 
should include within
+              their status.
+            properties:
+              ancestors:
+                description: |-
+                  Ancestors is a list of ancestor resources (usually Gateways) 
that are
+                  associated with the policy, and the status of the policy 
with respect to
+                  each ancestor. When this policy attaches to a parent, the 
controller that
+                  manages the parent and the ancestors MUST add an entry to 
this list when
+                  the controller first sees the policy and SHOULD update the 
entry as
+                  appropriate when the relevant ancestor is modified.
+
+                  Note that choosing the relevant ancestor is left to the 
Policy designers;
+                  an important part of Policy design is designing the right 
object level at
+                  which to namespace this status.
+
+                  Note also that implementations MUST ONLY populate ancestor 
status for
+                  the Ancestor resources they are responsible for. 
Implementations MUST
+                  use the ControllerName field to uniquely identify the 
entries in this list
+                  that they are responsible for.
+
+                  Note that to achieve this, the list of PolicyAncestorStatus 
structs
+                  MUST be treated as a map with a composite key, made up of 
the AncestorRef
+                  and ControllerName fields combined.
+
+                  A maximum of 16 ancestors will be represented in this list. 
An empty list
+                  means the Policy is not relevant for any ancestors.
+
+                  If this slice is full, implementations MUST NOT add further 
entries.
+                  Instead they MUST consider the policy unimplementable and 
signal that
+                  on any related resources such as the ancestor that would be 
referenced
+                  here. For example, if this list was full on 
BackendTLSPolicy, no
+                  additional Gateways would be able to reference the Service 
targeted by
+                  the BackendTLSPolicy.
+                items:
+                  description: |-
+                    PolicyAncestorStatus describes the status of a route with 
respect to an
+                    associated Ancestor.
+
+                    Ancestors refer to objects that are either the Target of a 
policy or above it
+                    in terms of object hierarchy. For example, if a policy 
targets a Service, the
+                    Policy's Ancestors are, in order, the Service, the 
HTTPRoute, the Gateway, and
+                    the GatewayClass. Almost always, in this hierarchy, the 
Gateway will be the most
+                    useful object to place Policy status on, so we recommend 
that implementations
+                    SHOULD use Gateway as the PolicyAncestorStatus object 
unless the designers
+                    have a _very_ good reason otherwise.
+
+                    In the context of policy attachment, the Ancestor is used 
to distinguish which
+                    resource results in a distinct application of this policy. 
For example, if a policy
+                    targets a Service, it may have a distinct result per 
attached Gateway.
+
+                    Policies targeting the same resource may have different 
effects depending on the
+                    ancestors of those resources. For example, different 
Gateways targeting the same
+                    Service may have different capabilities, especially if 
they have different underlying
+                    implementations.
+
+                    For example, in BackendTLSPolicy, the Policy attaches to a 
Service that is
+                    used as a backend in a HTTPRoute that is itself attached 
to a Gateway.
+                    In this case, the relevant object for status is the 
Gateway, and that is the
+                    ancestor object referred to in this status.
+
+                    Note that a parent is also an ancestor, so for objects 
where the parent is the
+                    relevant object for status, this struct SHOULD still be 
used.
+
+                    This struct is intended to be used in a slice that's 
effectively a map,
+                    with a composite key made up of the AncestorRef and the 
ControllerName.
+                  properties:
+                    ancestorRef:
+                      description: |-
+                        AncestorRef corresponds with a ParentRef in the spec 
that this
+                        PolicyAncestorStatus struct describes the status of.
+                      properties:
+                        group:
+                          default: gateway.networking.k8s.io
+                          description: |-
+                            Group is the group of the referent.
+                            When unspecified, "gateway.networking.k8s.io" is 
inferred.
+                            To set the core API group (such as for a "Service" 
kind referent),
+                            Group must be explicitly set to "" (empty string).
+
+                            Support: Core
+                          maxLength: 253
+                          pattern: 
^$|^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$
+                          type: string
+                        kind:
+                          default: Gateway
+                          description: |-
+                            Kind is kind of the referent.
+
+                            There are two kinds of parent resources with 
"Core" support:
+
+                            * Gateway (Gateway conformance profile)
+                            * Service (Mesh conformance profile, ClusterIP 
Services only)
+
+                            Support for other resources is 
Implementation-Specific.
+                          maxLength: 63
+                          minLength: 1
+                          pattern: ^[a-zA-Z]([-a-zA-Z0-9]*[a-zA-Z0-9])?$
+                          type: string
+                        name:
+                          description: |-
+                            Name is the name of the referent.
+
+                            Support: Core
+                          maxLength: 253
+                          minLength: 1
+                          type: string
+                        namespace:
+                          description: |-
+                            Namespace is the namespace of the referent. When 
unspecified, this refers
+                            to the local namespace of the Route.
+
+                            Note that there are specific rules for ParentRefs 
which cross namespace
+                            boundaries. Cross-namespace references are only 
valid if they are explicitly
+                            allowed by something in the namespace they are 
referring to. For example:
+                            Gateway has the AllowedRoutes field, and 
ReferenceGrant provides a
+                            generic way to enable any other kind of 
cross-namespace reference.
+
+                            <gateway:experimental:description>
+                            ParentRefs from a Route to a Service in the same 
namespace are "producer"
+                            routes, which apply default routing rules to 
inbound connections from
+                            any namespace to the Service.
+
+                            ParentRefs from a Route to a Service in a 
different namespace are
+                            "consumer" routes, and these routing rules are 
only applied to outbound
+                            connections originating from the same namespace as 
the Route, for which
+                            the intended destination of the connections are a 
Service targeted as a
+                            ParentRef of the Route.
+                            </gateway:experimental:description>
+
+                            Support: Core
+                          maxLength: 63
+                          minLength: 1
+                          pattern: ^[a-z0-9]([-a-z0-9]*[a-z0-9])?$
+                          type: string
+                        port:
+                          description: |-
+                            Port is the network port this Route targets. It 
can be interpreted
+                            differently based on the type of parent resource.
+
+                            When the parent resource is a Gateway, this 
targets all listeners
+                            listening on the specified port that also support 
this kind of Route(and
+                            select this Route). It's not recommended to set 
`Port` unless the
+                            networking behaviors specified in a Route must 
apply to a specific port
+                            as opposed to a listener(s) whose port(s) may be 
changed. When both Port
+                            and SectionName are specified, the name and port 
of the selected listener
+                            must match both specified values.
+
+                            <gateway:experimental:description>
+                            When the parent resource is a Service, this 
targets a specific port in the
+                            Service spec. When both Port (experimental) and 
SectionName are specified,
+                            the name and port of the selected port must match 
both specified values.
+                            </gateway:experimental:description>
+
+                            Implementations MAY choose to support other parent 
resources.
+                            Implementations supporting other types of parent 
resources MUST clearly
+                            document how/if Port is interpreted.
+
+                            For the purpose of status, an attachment is 
considered successful as
+                            long as the parent resource accepts it partially. 
For example, Gateway
+                            listeners can restrict which Routes can attach to 
them by Route kind,
+                            namespace, or hostname. If 1 of 2 Gateway 
listeners accept attachment
+                            from the referencing Route, the Route MUST be 
considered successfully
+                            attached. If no Gateway listeners accept 
attachment from this Route,
+                            the Route MUST be considered detached from the 
Gateway.
+
+                            Support: Extended
+                          format: int32
+                          maximum: 65535
+                          minimum: 1
+                          type: integer
+                        sectionName:
+                          description: |-
+                            SectionName is the name of a section within the 
target resource. In the
+                            following resources, SectionName is interpreted as 
the following:
+
+                            * Gateway: Listener name. When both Port 
(experimental) and SectionName
+                            are specified, the name and port of the selected 
listener must match
+                            both specified values.
+                            * Service: Port name. When both Port 
(experimental) and SectionName
+                            are specified, the name and port of the selected 
listener must match
+                            both specified values.
+
+                            Implementations MAY choose to support attaching 
Routes to other resources.
+                            If that is the case, they MUST clearly document 
how SectionName is
+                            interpreted.
+
+                            When unspecified (empty string), this will 
reference the entire resource.
+                            For the purpose of status, an attachment is 
considered successful if at
+                            least one section in the parent resource accepts 
it. For example, Gateway
+                            listeners can restrict which Routes can attach to 
them by Route kind,
+                            namespace, or hostname. If 1 of 2 Gateway 
listeners accept attachment from
+                            the referencing Route, the Route MUST be 
considered successfully
+                            attached. If no Gateway listeners accept 
attachment from this Route, the
+                            Route MUST be considered detached from the Gateway.
+
+                            Support: Core
+                          maxLength: 253
+                          minLength: 1
+                          pattern: 
^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$
+                          type: string
+                      required:
+                      - name
+                      type: object
+                    conditions:
+                      description: Conditions describes the status of the 
Policy with
+                        respect to the given Ancestor.
+                      items:
+                        description: Condition contains details for one aspect 
of
+                          the current state of this API Resource.
+                        properties:
+                          lastTransitionTime:
+                            description: |-
+                              lastTransitionTime is the last time the 
condition transitioned from one status to another.
+                              This should be when the underlying condition 
changed.  If that is not known, then using the time when the API field changed 
is acceptable.
+                            format: date-time
+                            type: string
+                          message:
+                            description: |-
+                              message is a human readable message indicating 
details about the transition.
+                              This may be an empty string.
+                            maxLength: 32768
+                            type: string
+                          observedGeneration:
+                            description: |-
+                              observedGeneration represents the 
.metadata.generation that the condition was set based upon.
+                              For instance, if .metadata.generation is 
currently 12, but the .status.conditions[x].observedGeneration is 9, the 
condition is out of date
+                              with respect to the current state of the 
instance.
+                            format: int64
+                            minimum: 0
+                            type: integer
+                          reason:
+                            description: |-
+                              reason contains a programmatic identifier 
indicating the reason for the condition's last transition.
+                              Producers of specific condition types may define 
expected values and meanings for this field,
+                              and whether the values are considered a 
guaranteed API.
+                              The value should be a CamelCase string.
+                              This field may not be empty.
+                            maxLength: 1024
+                            minLength: 1
+                            pattern: ^[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?$
+                            type: string
+                          status:
+                            description: status of the condition, one of True, 
False,
+                              Unknown.
+                            enum:
+                            - "True"
+                            - "False"
+                            - Unknown
+                            type: string
+                          type:
+                            description: type of condition in CamelCase or in 
foo.example.com/CamelCase.
+                            maxLength: 316
+                            pattern: 
^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/)?(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])$
+                            type: string
+                        required:
+                        - lastTransitionTime
+                        - message
+                        - reason
+                        - status
+                        - type
+                        type: object
+                      maxItems: 8
+                      minItems: 1
+                      type: array
+                      x-kubernetes-list-map-keys:
+                      - type
+                      x-kubernetes-list-type: map
+                    controllerName:
+                      description: |-
+                        ControllerName is a domain/path string that indicates 
the name of the
+                        controller that wrote this status. This corresponds 
with the
+                        controllerName field on GatewayClass.
+
+                        Example: "example.net/gateway-controller".
+
+                        The format of this field is DOMAIN "/" PATH, where 
DOMAIN and PATH are
+                        valid Kubernetes names
+                        
(https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names).
+
+                        Controllers MUST populate this field when writing 
status. Controllers should ensure that
+                        entries to status populated with their ControllerName 
are cleaned up when they are no
+                        longer necessary.
+                      maxLength: 253
+                      minLength: 1
+                      pattern: 
^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*\/[A-Za-z0-9\/\-._~%!$&'()*+,;=:]+$
+                      type: string
+                  required:
+                  - ancestorRef
+                  - controllerName
+                  type: object
+                maxItems: 16
+                type: array
+            required:
+            - ancestors
+            type: object
+        type: object
+    served: true
+    storage: true
+    subresources:
+      status: {}
diff --git a/config/crd/kustomization.yaml b/config/crd/kustomization.yaml
index 4148a24d..c2a7b3c0 100644
--- a/config/crd/kustomization.yaml
+++ b/config/crd/kustomization.yaml
@@ -7,6 +7,7 @@ resources:
 - bases/apisix.apache.org_consumers.yaml
 - bases/apisix.apache.org_backendtrafficpolicies.yaml
 - bases/apisix.apache.org_httproutepolicies.yaml
+- bases/apisix.apache.org_l4routepolicies.yaml
 - bases/apisix.apache.org_apisixroutes.yaml
 - bases/apisix.apache.org_apisixconsumers.yaml
 - bases/apisix.apache.org_apisixglobalrules.yaml
diff --git a/config/rbac/role.yaml b/config/rbac/role.yaml
index bfda220b..a883ef90 100644
--- a/config/rbac/role.yaml
+++ b/config/rbac/role.yaml
@@ -35,6 +35,7 @@ rules:
   - consumers
   - gatewayproxies
   - httproutepolicies
+  - l4routepolicies
   - pluginconfigs
   verbs:
   - get
@@ -52,6 +53,7 @@ rules:
   - backendtrafficpolicies/status
   - consumers/status
   - httproutepolicies/status
+  - l4routepolicies/status
   verbs:
   - get
   - update
diff --git a/docs/en/latest/reference/api-reference.md 
b/docs/en/latest/reference/api-reference.md
index 3d8987ac..8a02cfdf 100644
--- a/docs/en/latest/reference/api-reference.md
+++ b/docs/en/latest/reference/api-reference.md
@@ -19,6 +19,7 @@ Package v1alpha1 contains API Schema definitions for the 
apisix.apache.org v1alp
 - [Consumer](#consumer)
 - [GatewayProxy](#gatewayproxy)
 - [HTTPRoutePolicy](#httproutepolicy)
+- [L4RoutePolicy](#l4routepolicy)
 - [PluginConfig](#pluginconfig)
 ### BackendTrafficPolicy
 
@@ -84,6 +85,24 @@ HTTPRoutePolicy defines configuration of traffic policies.
 
 
 
+### L4RoutePolicy
+
+
+L4RoutePolicy defines plugin configuration for Gateway API L4 routes 
(TCPRoute, UDPRoute, TLSRoute).
+It follows the Gateway API Policy Attachment pattern and attaches APISIX 
stream plugins
+to the targeted L4 route resources.
+
+<!-- L4RoutePolicy resource -->
+
+| Field | Description |
+| --- | --- |
+| `apiVersion` _string_ | `apisix.apache.org/v1alpha1`
+| `kind` _string_ | `L4RoutePolicy`
+| `metadata` 
_[ObjectMeta](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.30/#objectmeta-v1-meta)_
 | Please refer to the Kubernetes API documentation for details on the 
`metadata` field. |
+| `spec` _[L4RoutePolicySpec](#l4routepolicyspec)_ | Spec defines the desired 
state of L4RoutePolicy. |
+
+
+
 ### PluginConfig
 
 
@@ -433,6 +452,22 @@ _Base type:_ `string`
 _Appears in:_
 - [BackendTrafficPolicySpec](#backendtrafficpolicyspec)
 
+#### L4RoutePolicySpec
+
+
+L4RoutePolicySpec defines the desired state of L4RoutePolicy.
+
+
+
+| Field | Description |
+| --- | --- |
+| `targetRefs` _LocalPolicyTargetReferenceWithSectionName array_ | TargetRefs 
identifies the L4 route resources (TCPRoute, UDPRoute, or TLSRoute) to which 
this policy applies. Only same-namespace targets are supported. |
+| `plugins` _[Plugin](#plugin) array_ | Plugins is the list of APISIX stream 
plugins to attach to the targeted L4 routes. Plugin names should be valid 
APISIX stream plugin names (e.g., limit-conn, ip-restriction). |
+
+
+_Appears in:_
+- [L4RoutePolicy](#l4routepolicy)
+
 #### LoadBalancer
 
 
@@ -518,6 +553,7 @@ _Appears in:_
 
 _Appears in:_
 - [ConsumerSpec](#consumerspec)
+- [L4RoutePolicySpec](#l4routepolicyspec)
 - [PluginConfigSpec](#pluginconfigspec)
 
 #### PluginConfigSpec
diff --git a/internal/adc/translator/l4route_test.go 
b/internal/adc/translator/l4route_test.go
new file mode 100644
index 00000000..fdf7a87d
--- /dev/null
+++ b/internal/adc/translator/l4route_test.go
@@ -0,0 +1,269 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+package translator
+
+import (
+       "context"
+       "testing"
+
+       "github.com/go-logr/logr"
+       "github.com/stretchr/testify/assert"
+       "github.com/stretchr/testify/require"
+       metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+       k8stypes "k8s.io/apimachinery/pkg/types"
+       gatewayv1alpha2 "sigs.k8s.io/gateway-api/apis/v1alpha2"
+
+       "github.com/apache/apisix-ingress-controller/api/v1alpha1"
+       "github.com/apache/apisix-ingress-controller/internal/provider"
+)
+
+func TestTranslateTCPRouteWithL4RoutePolicy(t *testing.T) {
+       tests := []struct {
+               name          string
+               policy        *v1alpha1.L4RoutePolicy
+               wantPlugins   []string
+               wantNoPlugins bool
+       }{
+               {
+                       name: "attaches plugins from matching L4RoutePolicy",
+                       policy: makeL4RoutePolicy("default", "tcp-policy", 
"TCPRoute", "my-tcp", []v1alpha1.Plugin{
+                               {Name: "limit-conn", Config: 
mustJSON(map[string]any{"conn": 100})},
+                               {Name: "ip-restriction", Config: 
mustJSON(map[string]any{"whitelist": []string{"10.0.0.0/8"}})},
+                       }),
+                       wantPlugins: []string{"limit-conn", "ip-restriction"},
+               },
+               {
+                       name: "does not attach plugins from policy targeting 
different route kind",
+                       policy: makeL4RoutePolicy("default", "udp-policy", 
"UDPRoute", "my-tcp", []v1alpha1.Plugin{
+                               {Name: "limit-conn", Config: 
mustJSON(map[string]any{"conn": 100})},
+                       }),
+                       wantNoPlugins: true,
+               },
+               {
+                       name: "does not attach plugins from policy targeting 
different route name",
+                       policy: makeL4RoutePolicy("default", "tcp-policy", 
"TCPRoute", "other-tcp", []v1alpha1.Plugin{
+                               {Name: "limit-conn", Config: 
mustJSON(map[string]any{"conn": 100})},
+                       }),
+                       wantNoPlugins: true,
+               },
+               {
+                       name:          "succeeds with no policy in context",
+                       policy:        nil,
+                       wantNoPlugins: true,
+               },
+       }
+
+       for _, tt := range tests {
+               t.Run(tt.name, func(t *testing.T) {
+                       translator := NewTranslator(logr.Discard(), "")
+                       tctx := 
provider.NewDefaultTranslateContext(context.Background())
+
+                       if tt.policy != nil {
+                               key := k8stypes.NamespacedName{Namespace: 
tt.policy.Namespace, Name: tt.policy.Name}
+                               tctx.L4RoutePolicies[key] = tt.policy
+                       }
+
+                       route := &gatewayv1alpha2.TCPRoute{
+                               ObjectMeta: metav1.ObjectMeta{
+                                       Name:      "my-tcp",
+                                       Namespace: "default",
+                               },
+                               Spec: gatewayv1alpha2.TCPRouteSpec{
+                                       Rules: []gatewayv1alpha2.TCPRouteRule{
+                                               {BackendRefs: 
[]gatewayv1alpha2.BackendRef{}},
+                                       },
+                               },
+                       }
+
+                       result, err := translator.TranslateTCPRoute(tctx, route)
+                       require.NoError(t, err)
+                       require.Len(t, result.Services, 1)
+                       require.NotEmpty(t, result.Services[0].StreamRoutes)
+
+                       plugins := result.Services[0].StreamRoutes[0].Plugins
+                       if tt.wantNoPlugins {
+                               assert.Empty(t, plugins)
+                       } else {
+                               for _, name := range tt.wantPlugins {
+                                       assert.Contains(t, plugins, name, 
"expected plugin %q to be attached", name)
+                               }
+                       }
+               })
+       }
+}
+
+func TestTranslateUDPRouteWithL4RoutePolicy(t *testing.T) {
+       tests := []struct {
+               name          string
+               policy        *v1alpha1.L4RoutePolicy
+               wantPlugins   []string
+               wantNoPlugins bool
+       }{
+               {
+                       name: "attaches plugins from matching L4RoutePolicy",
+                       policy: makeL4RoutePolicy("default", "udp-policy", 
"UDPRoute", "my-udp", []v1alpha1.Plugin{
+                               {Name: "limit-conn", Config: 
mustJSON(map[string]any{"conn": 50})},
+                       }),
+                       wantPlugins: []string{"limit-conn"},
+               },
+               {
+                       name: "does not attach plugins from policy targeting 
TCPRoute",
+                       policy: makeL4RoutePolicy("default", "tcp-policy", 
"TCPRoute", "my-udp", []v1alpha1.Plugin{
+                               {Name: "limit-conn", Config: 
mustJSON(map[string]any{"conn": 50})},
+                       }),
+                       wantNoPlugins: true,
+               },
+               {
+                       name:          "succeeds with no policy in context",
+                       policy:        nil,
+                       wantNoPlugins: true,
+               },
+       }
+
+       for _, tt := range tests {
+               t.Run(tt.name, func(t *testing.T) {
+                       translator := NewTranslator(logr.Discard(), "")
+                       tctx := 
provider.NewDefaultTranslateContext(context.Background())
+
+                       if tt.policy != nil {
+                               key := k8stypes.NamespacedName{Namespace: 
tt.policy.Namespace, Name: tt.policy.Name}
+                               tctx.L4RoutePolicies[key] = tt.policy
+                       }
+
+                       route := &gatewayv1alpha2.UDPRoute{
+                               ObjectMeta: metav1.ObjectMeta{
+                                       Name:      "my-udp",
+                                       Namespace: "default",
+                               },
+                               Spec: gatewayv1alpha2.UDPRouteSpec{
+                                       Rules: []gatewayv1alpha2.UDPRouteRule{
+                                               {BackendRefs: 
[]gatewayv1alpha2.BackendRef{}},
+                                       },
+                               },
+                       }
+
+                       result, err := translator.TranslateUDPRoute(tctx, route)
+                       require.NoError(t, err)
+                       require.Len(t, result.Services, 1)
+                       require.NotEmpty(t, result.Services[0].StreamRoutes)
+
+                       plugins := result.Services[0].StreamRoutes[0].Plugins
+                       if tt.wantNoPlugins {
+                               assert.Empty(t, plugins)
+                       } else {
+                               for _, name := range tt.wantPlugins {
+                                       assert.Contains(t, plugins, name, 
"expected plugin %q to be attached", name)
+                               }
+                       }
+               })
+       }
+}
+
+func TestTranslateTLSRouteWithL4RoutePolicy(t *testing.T) {
+       tests := []struct {
+               name          string
+               policy        *v1alpha1.L4RoutePolicy
+               hostnames     []string
+               wantPlugins   []string
+               wantNoPlugins bool
+       }{
+               {
+                       name: "attaches plugins from matching L4RoutePolicy",
+                       policy: makeL4RoutePolicy("default", "tls-policy", 
"TLSRoute", "my-tls", []v1alpha1.Plugin{
+                               {Name: "ip-restriction", Config: 
mustJSON(map[string]any{"whitelist": []string{"192.168.0.0/16"}})},
+                       }),
+                       hostnames:   []string{"example.com"},
+                       wantPlugins: []string{"ip-restriction"},
+               },
+               {
+                       name: "plugins attached once per rule even with 
multiple SNI hostnames",
+                       policy: makeL4RoutePolicy("default", "tls-policy", 
"TLSRoute", "my-tls", []v1alpha1.Plugin{
+                               {Name: "limit-conn", Config: 
mustJSON(map[string]any{"conn": 20})},
+                       }),
+                       hostnames:   []string{"foo.example.com", 
"bar.example.com"},
+                       wantPlugins: []string{"limit-conn"},
+               },
+               {
+                       name: "does not attach plugins from policy targeting 
TCPRoute",
+                       policy: makeL4RoutePolicy("default", "tcp-policy", 
"TCPRoute", "my-tls", []v1alpha1.Plugin{
+                               {Name: "limit-conn", Config: 
mustJSON(map[string]any{"conn": 20})},
+                       }),
+                       hostnames:     []string{"example.com"},
+                       wantNoPlugins: true,
+               },
+               {
+                       name:          "succeeds with no policy in context",
+                       policy:        nil,
+                       hostnames:     []string{"example.com"},
+                       wantNoPlugins: true,
+               },
+       }
+
+       for _, tt := range tests {
+               t.Run(tt.name, func(t *testing.T) {
+                       translator := NewTranslator(logr.Discard(), "")
+                       tctx := 
provider.NewDefaultTranslateContext(context.Background())
+
+                       if tt.policy != nil {
+                               key := k8stypes.NamespacedName{Namespace: 
tt.policy.Namespace, Name: tt.policy.Name}
+                               tctx.L4RoutePolicies[key] = tt.policy
+                       }
+
+                       hostnames := make([]gatewayv1alpha2.Hostname, 0, 
len(tt.hostnames))
+                       for _, h := range tt.hostnames {
+                               hostnames = append(hostnames, 
gatewayv1alpha2.Hostname(h))
+                       }
+
+                       route := &gatewayv1alpha2.TLSRoute{
+                               ObjectMeta: metav1.ObjectMeta{
+                                       Name:      "my-tls",
+                                       Namespace: "default",
+                               },
+                               Spec: gatewayv1alpha2.TLSRouteSpec{
+                                       Hostnames: hostnames,
+                                       Rules: []gatewayv1alpha2.TLSRouteRule{
+                                               {BackendRefs: 
[]gatewayv1alpha2.BackendRef{}},
+                                       },
+                               },
+                       }
+
+                       result, err := translator.TranslateTLSRoute(tctx, route)
+                       require.NoError(t, err)
+                       require.Len(t, result.Services, 1)
+
+                       // Verify stream routes are created per SNI hostname
+                       if len(tt.hostnames) > 0 {
+                               assert.Len(t, result.Services[0].StreamRoutes, 
len(tt.hostnames))
+                       }
+
+                       // Plugins are attached at the stream_route level so 
the APISIX stream proxy
+                       // applies them; with multiple SNIs each stream_route 
carries its own copy.
+                       require.NotEmpty(t, result.Services[0].StreamRoutes)
+                       plugins := result.Services[0].StreamRoutes[0].Plugins
+                       if tt.wantNoPlugins {
+                               assert.Empty(t, plugins)
+                       } else {
+                               for _, streamRoute := range 
result.Services[0].StreamRoutes {
+                                       for _, name := range tt.wantPlugins {
+                                               assert.Contains(t, 
streamRoute.Plugins, name, "expected plugin %q to be attached", name)
+                                       }
+                               }
+                       }
+               })
+       }
+}
diff --git a/internal/adc/translator/l4routepolicy_test.go 
b/internal/adc/translator/l4routepolicy_test.go
new file mode 100644
index 00000000..9bae179c
--- /dev/null
+++ b/internal/adc/translator/l4routepolicy_test.go
@@ -0,0 +1,143 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+package translator
+
+import (
+       "encoding/json"
+       "testing"
+
+       "github.com/go-logr/logr"
+       "github.com/stretchr/testify/assert"
+       apiextensionsv1 
"k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1"
+       metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+       k8stypes "k8s.io/apimachinery/pkg/types"
+       gatewayv1alpha2 "sigs.k8s.io/gateway-api/apis/v1alpha2"
+
+       adctypes "github.com/apache/apisix-ingress-controller/api/adc"
+       "github.com/apache/apisix-ingress-controller/api/v1alpha1"
+)
+
+func makeL4RoutePolicy(namespace, name, targetKind, targetName string, plugins 
[]v1alpha1.Plugin) *v1alpha1.L4RoutePolicy {
+       return &v1alpha1.L4RoutePolicy{
+               ObjectMeta: metav1.ObjectMeta{
+                       Namespace: namespace,
+                       Name:      name,
+               },
+               Spec: v1alpha1.L4RoutePolicySpec{
+                       TargetRefs: 
[]gatewayv1alpha2.LocalPolicyTargetReferenceWithSectionName{
+                               {
+                                       LocalPolicyTargetReference: 
gatewayv1alpha2.LocalPolicyTargetReference{
+                                               Group: 
gatewayv1alpha2.GroupName,
+                                               Kind:  
gatewayv1alpha2.Kind(targetKind),
+                                               Name:  
gatewayv1alpha2.ObjectName(targetName),
+                                       },
+                               },
+                       },
+                       Plugins: plugins,
+               },
+       }
+}
+
+func mustJSON(v any) apiextensionsv1.JSON {
+       b, err := json.Marshal(v)
+       if err != nil {
+               panic(err)
+       }
+       return apiextensionsv1.JSON{Raw: b}
+}
+
+func TestAttachL4RoutePolicyPlugins_AttachesMatchingPolicy(t *testing.T) {
+       tr := NewTranslator(logr.Discard(), "")
+
+       policy := makeL4RoutePolicy("default", "my-policy", "TCPRoute", 
"my-tcp-route", []v1alpha1.Plugin{
+               {Name: "limit-conn", Config: mustJSON(map[string]any{"conn": 
100, "burst": 50})},
+               {Name: "ip-restriction", Config: 
mustJSON(map[string]any{"whitelist": []string{"10.0.0.0/8"}})},
+       })
+
+       policies := map[k8stypes.NamespacedName]*v1alpha1.L4RoutePolicy{
+               {Namespace: "default", Name: "my-policy"}: policy,
+       }
+
+       plugins := adctypes.Plugins{}
+       tr.AttachL4RoutePolicyPlugins(policies, "default", "my-tcp-route", 
"TCPRoute", plugins)
+
+       assert.Len(t, plugins, 2)
+       assert.Contains(t, plugins, "limit-conn")
+       assert.Contains(t, plugins, "ip-restriction")
+
+       cfg := plugins["limit-conn"].(map[string]any)
+       assert.EqualValues(t, 100, cfg["conn"])
+}
+
+func TestAttachL4RoutePolicyPlugins_NoMatchOnKind(t *testing.T) {
+       tr := NewTranslator(logr.Discard(), "")
+
+       policy := makeL4RoutePolicy("default", "udp-policy", "UDPRoute", 
"my-udp-route", []v1alpha1.Plugin{
+               {Name: "limit-conn", Config: mustJSON(map[string]any{"conn": 
10})},
+       })
+
+       policies := map[k8stypes.NamespacedName]*v1alpha1.L4RoutePolicy{
+               {Namespace: "default", Name: "udp-policy"}: policy,
+       }
+
+       plugins := adctypes.Plugins{}
+       // Looking for TCPRoute, but policy targets UDPRoute — should not match.
+       tr.AttachL4RoutePolicyPlugins(policies, "default", "my-udp-route", 
"TCPRoute", plugins)
+
+       assert.Empty(t, plugins)
+}
+
+func TestAttachL4RoutePolicyPlugins_NoMatchOnNamespace(t *testing.T) {
+       tr := NewTranslator(logr.Discard(), "")
+
+       policy := makeL4RoutePolicy("other-ns", "my-policy", "TCPRoute", 
"my-tcp-route", []v1alpha1.Plugin{
+               {Name: "limit-conn", Config: mustJSON(map[string]any{"conn": 
10})},
+       })
+
+       policies := map[k8stypes.NamespacedName]*v1alpha1.L4RoutePolicy{
+               {Namespace: "other-ns", Name: "my-policy"}: policy,
+       }
+
+       plugins := adctypes.Plugins{}
+       // Route is in "default" namespace, policy is in "other-ns" — should 
not match.
+       tr.AttachL4RoutePolicyPlugins(policies, "default", "my-tcp-route", 
"TCPRoute", plugins)
+
+       assert.Empty(t, plugins)
+}
+
+func TestAttachL4RoutePolicyPlugins_EmptyPlugins(t *testing.T) {
+       tr := NewTranslator(logr.Discard(), "")
+
+       policy := makeL4RoutePolicy("default", "empty-policy", "TCPRoute", 
"my-tcp-route", nil)
+
+       policies := map[k8stypes.NamespacedName]*v1alpha1.L4RoutePolicy{
+               {Namespace: "default", Name: "empty-policy"}: policy,
+       }
+
+       plugins := adctypes.Plugins{}
+       tr.AttachL4RoutePolicyPlugins(policies, "default", "my-tcp-route", 
"TCPRoute", plugins)
+
+       assert.Empty(t, plugins)
+}
+
+func TestAttachL4RoutePolicyPlugins_EmptyPolicies(t *testing.T) {
+       tr := NewTranslator(logr.Discard(), "")
+       plugins := adctypes.Plugins{}
+       tr.AttachL4RoutePolicyPlugins(nil, "default", "my-tcp-route", 
"TCPRoute", plugins)
+       assert.Empty(t, plugins)
+}
diff --git a/internal/adc/translator/policies.go 
b/internal/adc/translator/policies.go
index 726fbfac..9bf65999 100644
--- a/internal/adc/translator/policies.go
+++ b/internal/adc/translator/policies.go
@@ -18,9 +18,12 @@
 package translator
 
 import (
+       "encoding/json"
+
        "k8s.io/apimachinery/pkg/types"
        "k8s.io/utils/ptr"
        gatewayv1 "sigs.k8s.io/gateway-api/apis/v1"
+       gatewayv1alpha2 "sigs.k8s.io/gateway-api/apis/v1alpha2"
 
        adctypes "github.com/apache/apisix-ingress-controller/api/adc"
        "github.com/apache/apisix-ingress-controller/api/v1alpha1"
@@ -169,3 +172,57 @@ func translateBTPPassiveHealthCheck(config 
*v1alpha1.PassiveHealthCheck) *adctyp
        }
        return passive
 }
+
+// AttachL4RoutePolicyPlugins merges plugins from the matching L4RoutePolicy 
(if any) into the
+// provided plugins map. It looks up policies targeting the route identified 
by routeNamespace,
+// routeName, and routeKind.
+func (t *Translator) AttachL4RoutePolicyPlugins(
+       policies map[types.NamespacedName]*v1alpha1.L4RoutePolicy,
+       routeNamespace, routeName, routeKind string,
+       plugins adctypes.Plugins,
+) {
+       if len(policies) == 0 {
+               return
+       }
+       for _, policy := range policies {
+               if policy.Namespace != routeNamespace {
+                       continue
+               }
+               for _, ref := range policy.Spec.TargetRefs {
+                       if string(ref.Group) != gatewayv1alpha2.GroupName {
+                               continue
+                       }
+                       if string(ref.Kind) != routeKind {
+                               continue
+                       }
+                       if string(ref.Name) != routeName {
+                               continue
+                       }
+                       // sectionName targeting is not supported for L4 
routes; skip such refs
+                       // so plugins are not attached for an attachment that 
cannot be honored.
+                       if ref.SectionName != nil && *ref.SectionName != "" {
+                               continue
+                       }
+                       t.mergeL4PolicyPlugins(policy, plugins)
+                       return
+               }
+       }
+}
+
+func (t *Translator) mergeL4PolicyPlugins(policy *v1alpha1.L4RoutePolicy, 
plugins adctypes.Plugins) {
+       for _, plugin := range policy.Spec.Plugins {
+               cfg := make(map[string]any)
+               if len(plugin.Config.Raw) > 0 {
+                       if err := json.Unmarshal(plugin.Config.Raw, &cfg); err 
!= nil {
+                               t.Log.Error(err, "failed to unmarshal 
L4RoutePolicy plugin config", "plugin", plugin.Name, "policy", policy.Name)
+                               continue
+                       }
+               }
+               // A literal `config: null` unmarshals to a nil map, which 
serializes back to
+               // null and is rejected by most APISIX plugins; normalize it to 
an empty object.
+               if cfg == nil {
+                       cfg = map[string]any{}
+               }
+               plugins[plugin.Name] = cfg
+       }
+}
diff --git a/internal/adc/translator/tcproute.go 
b/internal/adc/translator/tcproute.go
index fc9c0b13..c7f00e0e 100644
--- a/internal/adc/translator/tcproute.go
+++ b/internal/adc/translator/tcproute.go
@@ -156,7 +156,12 @@ func (t *Translator) TranslateTCPRoute(tctx 
*provider.TranslateContext, tcpRoute
                streamRoute.ID = id.GenID(streamRouteName)
                streamRoute.Labels = labels
                // TODO: support remote_addr, server_addr, sni, server_port
+               // Attach L4RoutePolicy plugins at the stream_route level: the 
APISIX stream proxy
+               // applies plugins from the stream_route, not from the service.
+               streamRoute.Plugins = make(adctypes.Plugins)
+               t.AttachL4RoutePolicyPlugins(tctx.L4RoutePolicies, 
tcpRoute.Namespace, tcpRoute.Name, "TCPRoute", streamRoute.Plugins)
                service.StreamRoutes = append(service.StreamRoutes, streamRoute)
+
                result.Services = append(result.Services, service)
        }
        return result, nil
diff --git a/internal/adc/translator/tlsroute.go 
b/internal/adc/translator/tlsroute.go
index b1eb5fa0..4b2ef33d 100644
--- a/internal/adc/translator/tlsroute.go
+++ b/internal/adc/translator/tlsroute.go
@@ -151,8 +151,14 @@ func (t *Translator) TranslateTLSRoute(tctx 
*provider.TranslateContext, tlsRoute
                        streamRoute.ID = id.GenID(streamRouteName)
                        streamRoute.SNI = host
                        streamRoute.Labels = labels
+                       // Attach L4RoutePolicy plugins at the stream_route 
level: the APISIX stream proxy
+                       // applies plugins from the stream_route, not from the 
service. With multiple SNIs
+                       // each stream_route carries its own copy of the 
plugins.
+                       streamRoute.Plugins = make(adctypes.Plugins)
+                       t.AttachL4RoutePolicyPlugins(tctx.L4RoutePolicies, 
tlsRoute.Namespace, tlsRoute.Name, "TLSRoute", streamRoute.Plugins)
                        service.StreamRoutes = append(service.StreamRoutes, 
streamRoute)
                }
+
                result.Services = append(result.Services, service)
        }
        return result, nil
diff --git a/internal/adc/translator/udproute.go 
b/internal/adc/translator/udproute.go
index 5cc09a10..00c90f3e 100644
--- a/internal/adc/translator/udproute.go
+++ b/internal/adc/translator/udproute.go
@@ -145,7 +145,12 @@ func (t *Translator) TranslateUDPRoute(tctx 
*provider.TranslateContext, udpRoute
                streamRoute.ID = id.GenID(streamRouteName)
                streamRoute.Labels = labels
                // TODO: support remote_addr, server_addr, sni, server_port
+               // Attach L4RoutePolicy plugins at the stream_route level: the 
APISIX stream proxy
+               // applies plugins from the stream_route, not from the service.
+               streamRoute.Plugins = make(adctypes.Plugins)
+               t.AttachL4RoutePolicyPlugins(tctx.L4RoutePolicies, 
udpRoute.Namespace, udpRoute.Name, "UDPRoute", streamRoute.Plugins)
                service.StreamRoutes = append(service.StreamRoutes, streamRoute)
+
                result.Services = append(result.Services, service)
        }
        return result, nil
diff --git a/internal/controller/indexer/indexer.go 
b/internal/controller/indexer/indexer.go
index 5172e487..58403f71 100644
--- a/internal/controller/indexer/indexer.go
+++ b/internal/controller/indexer/indexer.go
@@ -62,6 +62,7 @@ func SetupAPIv1alpha1Indexer(mgr ctrl.Manager) error {
                &v1alpha1.BackendTrafficPolicy{}: 
setupBackendTrafficPolicyIndexer,
                &v1alpha1.Consumer{}:             setupConsumerIndexer,
                &v1alpha1.GatewayProxy{}:         setupGatewayProxyIndexer,
+               &v1alpha1.L4RoutePolicy{}:        setupL4RoutePolicyIndexer,
        } {
                if utils.HasAPIResource(mgr, resource) {
                        if err := setup(mgr); err != nil {
@@ -487,6 +488,18 @@ func setupBackendTrafficPolicyIndexer(mgr ctrl.Manager) 
error {
        return nil
 }
 
+func setupL4RoutePolicyIndexer(mgr ctrl.Manager) error {
+       if err := mgr.GetFieldIndexer().IndexField(
+               context.Background(),
+               &v1alpha1.L4RoutePolicy{},
+               PolicyTargetRefs,
+               L4RoutePolicyIndexFunc,
+       ); err != nil {
+               return err
+       }
+       return nil
+}
+
 func IngressClassIndexFunc(rawObj client.Object) []string {
        ingressClass := rawObj.(*networkingv1.IngressClass)
        if ingressClass.Spec.Controller == "" {
@@ -853,6 +866,20 @@ func BackendTrafficPolicyIndexFunc(rawObj client.Object) 
[]string {
        return keys
 }
 
+func L4RoutePolicyIndexFunc(rawObj client.Object) []string {
+       lrp := rawObj.(*v1alpha1.L4RoutePolicy)
+       keys := make([]string, 0, len(lrp.Spec.TargetRefs))
+       m := make(map[string]struct{})
+       for _, ref := range lrp.Spec.TargetRefs {
+               key := GenIndexKeyWithGK(string(ref.Group), string(ref.Kind), 
lrp.GetNamespace(), string(ref.Name))
+               if _, ok := m[key]; !ok {
+                       m[key] = struct{}{}
+                       keys = append(keys, key)
+               }
+       }
+       return keys
+}
+
 func IngressClassParametersRefIndexFunc(rawObj client.Object) []string {
        ingressClass := rawObj.(*networkingv1.IngressClass)
        // check if the IngressClass references this gateway proxy
diff --git a/internal/controller/policies.go b/internal/controller/policies.go
index f117cdea..ef563d2a 100644
--- a/internal/controller/policies.go
+++ b/internal/controller/policies.go
@@ -18,7 +18,10 @@
 package controller
 
 import (
+       "context"
        "fmt"
+       "slices"
+       "sort"
 
        "github.com/go-logr/logr"
        "k8s.io/apimachinery/pkg/api/meta"
@@ -224,3 +227,193 @@ func parentRefValueEqual(a, b gatewayv1.ParentReference) 
bool {
                ptr.Equal(a.Namespace, b.Namespace) &&
                a.Name == b.Name
 }
+
+// l4RoutePolicyMatchesRoute reports whether the policy has a targetRef that 
matches the
+// given L4 route. A ref matches only when its group/kind/name equal the route 
and it does
+// not pin a sectionName, since L4 routes expose no addressable sections to 
attach to.
+func l4RoutePolicyMatchesRoute(policy v1alpha1.L4RoutePolicy, routeKind, 
routeNamespace, routeName string) bool {
+       if policy.Namespace != routeNamespace {
+               return false
+       }
+       for _, ref := range policy.Spec.TargetRefs {
+               if string(ref.Group) != gatewayv1alpha2.GroupName {
+                       continue
+               }
+               if string(ref.Kind) != routeKind {
+                       continue
+               }
+               if string(ref.Name) != routeName {
+                       continue
+               }
+               if ref.SectionName != nil && *ref.SectionName != "" {
+                       continue
+               }
+               return true
+       }
+       return false
+}
+
+// ProcessL4RoutePolicy finds L4RoutePolicy resources that target the given L4 
route
+// (identified by namespace, name, and kind), resolves conflicts 
deterministically,
+// populates tctx.L4RoutePolicies with the winning policy, and queues status 
updates.
+func ProcessL4RoutePolicy(
+       c client.Client,
+       log logr.Logger,
+       tctx *provider.TranslateContext,
+       routeNamespace, routeName, routeKind string,
+) {
+       var list v1alpha1.L4RoutePolicyList
+       key := indexer.GenIndexKeyWithGK(gatewayv1alpha2.GroupName, routeKind, 
routeNamespace, routeName)
+       if err := c.List(tctx, &list, 
client.MatchingFields{indexer.PolicyTargetRefs: key}); err != nil {
+               log.Error(err, "failed to list L4RoutePolicy", "namespace", 
routeNamespace, "name", routeName, "kind", routeKind)
+               return
+       }
+       if len(list.Items) == 0 {
+               return
+       }
+
+       // L4 routes have no addressable sections; a targetRef that specifies a 
sectionName
+       // cannot be honored, so ignore policies that only match this route via 
such a ref.
+       list.Items = slices.DeleteFunc(list.Items, func(p 
v1alpha1.L4RoutePolicy) bool {
+               return !l4RoutePolicyMatchesRoute(p, routeKind, routeNamespace, 
routeName)
+       })
+       if len(list.Items) == 0 {
+               return
+       }
+
+       // Deterministic conflict resolution: oldest creationTimestamp wins; 
tie-break by namespace/name.
+       sort.Slice(list.Items, func(i, j int) bool {
+               ti := list.Items[i].CreationTimestamp.Time
+               tj := list.Items[j].CreationTimestamp.Time
+               if ti.Equal(tj) {
+                       ki := list.Items[i].Namespace + "/" + list.Items[i].Name
+                       kj := list.Items[j].Namespace + "/" + list.Items[j].Name
+                       return ki < kj
+               }
+               return ti.Before(tj)
+       })
+
+       winner := list.Items[0].DeepCopy()
+       tctx.L4RoutePolicies[types.NamespacedName{Namespace: winner.Namespace, 
Name: winner.Name}] = winner
+
+       for i := range list.Items {
+               policy := list.Items[i]
+               var condition metav1.Condition
+               if i == 0 {
+                       condition = metav1.Condition{
+                               Type:               
string(gatewayv1alpha2.PolicyConditionAccepted),
+                               Status:             metav1.ConditionTrue,
+                               ObservedGeneration: policy.GetGeneration(),
+                               LastTransitionTime: metav1.Now(),
+                               Reason:             
string(gatewayv1alpha2.PolicyReasonAccepted),
+                               Message:            "Policy has been accepted",
+                       }
+               } else {
+                       condition = metav1.Condition{
+                               Type:               
string(gatewayv1alpha2.PolicyConditionAccepted),
+                               Status:             metav1.ConditionFalse,
+                               ObservedGeneration: policy.GetGeneration(),
+                               LastTransitionTime: metav1.Now(),
+                               Reason:             
string(gatewayv1alpha2.PolicyReasonConflicted),
+                               Message:            fmt.Sprintf("Conflicts with 
L4RoutePolicy %s/%s which was created earlier", winner.Namespace, winner.Name),
+                       }
+               }
+
+               if updated := SetAncestors(&policy.Status, 
tctx.RouteParentRefs, condition); updated {
+                       // Resource must be a separate copy from the object 
captured by the Mutator:
+                       // the status updater calls client.Get into Resource, 
overwriting it with the
+                       // server state. The Mutator reads policy.Status, which 
keeps the ancestors set above.
+                       tctx.StatusUpdaters = append(tctx.StatusUpdaters, 
status.Update{
+                               NamespacedName: utils.NamespacedName(&policy),
+                               Resource:       policy.DeepCopy(),
+                               Mutator: status.MutatorFunc(func(obj 
client.Object) client.Object {
+                                       cp := 
obj.(*v1alpha1.L4RoutePolicy).DeepCopy()
+                                       cp.Status = policy.Status
+                                       return cp
+                               }),
+                       })
+               }
+       }
+}
+
+// updateL4RoutePolicyStatusOnDeleting removes the deleted route's ancestor 
status entries
+// from L4RoutePolicy resources that target it. A single policy may target 
multiple routes,
+// so the still-existing target routes' parentRefs are recomputed and only 
ancestor entries
+// no longer referenced by any of them are removed.
+func updateL4RoutePolicyStatusOnDeleting(ctx context.Context, c client.Client, 
updater status.Updater, log logr.Logger, nn types.NamespacedName, routeKind 
string) {
+       var list v1alpha1.L4RoutePolicyList
+       key := indexer.GenIndexKeyWithGK(gatewayv1alpha2.GroupName, routeKind, 
nn.Namespace, nn.Name)
+       if err := c.List(ctx, &list, 
client.MatchingFields{indexer.PolicyTargetRefs: key}); err != nil {
+               log.Error(err, "failed to list L4RoutePolicy on route 
deletion", "namespace", nn.Namespace, "name", nn.Name)
+               return
+       }
+       for i := range list.Items {
+               policy := list.Items[i]
+               var parentRefs []gatewayv1.ParentReference
+               for _, ref := range policy.Spec.TargetRefs {
+                       if string(ref.Group) != gatewayv1alpha2.GroupName {
+                               continue
+                       }
+                       // The deleted route returns NotFound here and is 
naturally skipped.
+                       refs, ok := l4RouteParentRefs(ctx, c, string(ref.Kind), 
types.NamespacedName{Namespace: policy.Namespace, Name: string(ref.Name)})
+                       if !ok {
+                               continue
+                       }
+                       parentRefs = append(parentRefs, refs...)
+               }
+               updateL4RoutePolicyDeleteAncestors(updater, policy, parentRefs)
+       }
+}
+
+// l4RouteParentRefs returns the parentRefs of the L4 route identified by 
kind/nn,
+// or ok=false if the route kind is unsupported or the route no longer exists.
+func l4RouteParentRefs(ctx context.Context, c client.Client, kind string, nn 
types.NamespacedName) ([]gatewayv1.ParentReference, bool) {
+       switch kind {
+       case internaltypes.KindTCPRoute:
+               var route gatewayv1alpha2.TCPRoute
+               if err := c.Get(ctx, nn, &route); err != nil {
+                       return nil, false
+               }
+               return route.Spec.ParentRefs, true
+       case internaltypes.KindUDPRoute:
+               var route gatewayv1alpha2.UDPRoute
+               if err := c.Get(ctx, nn, &route); err != nil {
+                       return nil, false
+               }
+               return route.Spec.ParentRefs, true
+       case internaltypes.KindTLSRoute:
+               var route gatewayv1alpha2.TLSRoute
+               if err := c.Get(ctx, nn, &route); err != nil {
+                       return nil, false
+               }
+               return route.Spec.ParentRefs, true
+       default:
+               return nil, false
+       }
+}
+
+func updateL4RoutePolicyDeleteAncestors(updater status.Updater, policy 
v1alpha1.L4RoutePolicy, parentRefs []gatewayv1.ParentReference) {
+       length := len(policy.Status.Ancestors)
+       policy.Status.Ancestors = slices.DeleteFunc(policy.Status.Ancestors, 
func(ancestor gatewayv1alpha2.PolicyAncestorStatus) bool {
+               return !slices.ContainsFunc(parentRefs, func(ref 
gatewayv1.ParentReference) bool {
+                       return parentRefValueEqual(ancestor.AncestorRef, ref)
+               })
+       })
+       if length == len(policy.Status.Ancestors) {
+               return
+       }
+       // status.ancestors is a required field; ensure a fully-cleared list 
serializes to []
+       // rather than null, which the CRD schema rejects.
+       if policy.Status.Ancestors == nil {
+               policy.Status.Ancestors = 
[]gatewayv1alpha2.PolicyAncestorStatus{}
+       }
+       updater.Update(status.Update{
+               NamespacedName: utils.NamespacedName(&policy),
+               Resource:       policy.DeepCopy(),
+               Mutator: status.MutatorFunc(func(obj client.Object) 
client.Object {
+                       cp := obj.(*v1alpha1.L4RoutePolicy).DeepCopy()
+                       cp.Status = policy.Status
+                       return cp
+               }),
+       })
+}
diff --git a/internal/controller/status/updater.go 
b/internal/controller/status/updater.go
index e2ef06ed..ae1e1c28 100644
--- a/internal/controller/status/updater.go
+++ b/internal/controller/status/updater.go
@@ -236,6 +236,12 @@ func statusEqual(a, b any, opts ...cmp.Option) bool {
                        return false
                }
                statusA, statusB = a.Status, b.Status
+       case *v1alpha1.L4RoutePolicy:
+               b, ok := b.(*v1alpha1.L4RoutePolicy)
+               if !ok {
+                       return false
+               }
+               statusA, statusB = a.Status, b.Status
        case *v1alpha1.BackendTrafficPolicy:
                b, ok := b.(*v1alpha1.BackendTrafficPolicy)
                if !ok {
diff --git a/internal/controller/tcproute_controller.go 
b/internal/controller/tcproute_controller.go
index 125a14a9..f3487547 100644
--- a/internal/controller/tcproute_controller.go
+++ b/internal/controller/tcproute_controller.go
@@ -45,6 +45,7 @@ import (
        "github.com/apache/apisix-ingress-controller/internal/provider"
        "github.com/apache/apisix-ingress-controller/internal/types"
        "github.com/apache/apisix-ingress-controller/internal/utils"
+       pkgutils "github.com/apache/apisix-ingress-controller/pkg/utils"
 )
 
 // TCPRouteReconciler reconciles a TCPRoute object.
@@ -58,6 +59,9 @@ type TCPRouteReconciler struct { //nolint:revive
 
        Updater status.Updater
        Readier readiness.ReadinessManager
+
+       // supportsL4RoutePolicy indicates whether the L4RoutePolicy CRD is 
installed.
+       supportsL4RoutePolicy bool
 }
 
 // SetupWithManager sets up the controller with the Manager.
@@ -95,6 +99,15 @@ func (r *TCPRouteReconciler) SetupWithManager(mgr 
ctrl.Manager) error {
                        
handler.EnqueueRequestsFromMapFunc(r.listTCPRoutesForGatewayProxy),
                )
 
+       // L4RoutePolicy is an optional CRD. Only watch it when installed so the
+       // controller still starts if the CRD has not been applied yet (e.g. 
upgrades).
+       r.supportsL4RoutePolicy = pkgutils.HasAPIResource(mgr, 
&v1alpha1.L4RoutePolicy{})
+       if r.supportsL4RoutePolicy {
+               bdr.Watches(&v1alpha1.L4RoutePolicy{},
+                       
handler.EnqueueRequestsFromMapFunc(r.listTCPRoutesForL4RoutePolicy),
+               )
+       }
+
        if GetEnableReferenceGrant() {
                bdr.Watches(&v1beta1.ReferenceGrant{},
                        
handler.EnqueueRequestsFromMapFunc(r.listTCPRoutesForReferenceGrant),
@@ -240,6 +253,9 @@ func (r *TCPRouteReconciler) Reconcile(ctx context.Context, 
req ctrl.Request) (c
                                r.Log.Error(err, "failed to delete tcproute", 
"tcproute", tr)
                                return ctrl.Result{}, err
                        }
+                       if r.supportsL4RoutePolicy {
+                               updateL4RoutePolicyStatusOnDeleting(ctx, 
r.Client, r.Updater, r.Log, req.NamespacedName, KindTCPRoute)
+                       }
                        return ctrl.Result{}, nil
                }
                return ctrl.Result{}, err
@@ -293,6 +309,9 @@ func (r *TCPRouteReconciler) Reconcile(ctx context.Context, 
req ctrl.Request) (c
        }
 
        ProcessBackendTrafficPolicy(r.Client, r.Log, tctx)
+       if r.supportsL4RoutePolicy {
+               ProcessL4RoutePolicy(r.Client, r.Log, tctx, tr.Namespace, 
tr.Name, KindTCPRoute)
+       }
        tr.Status.Parents = make([]gatewayv1.RouteParentStatus, 0, 
len(gateways))
        for _, gateway := range gateways {
                parentStatus := gatewayv1.RouteParentStatus{}
@@ -503,3 +522,28 @@ func (r *TCPRouteReconciler) listTCPRoutesByServiceRef(ctx 
context.Context, obj
        }
        return requests
 }
+
+func (r *TCPRouteReconciler) listTCPRoutesForL4RoutePolicy(ctx 
context.Context, obj client.Object) []reconcile.Request {
+       policy, ok := obj.(*v1alpha1.L4RoutePolicy)
+       if !ok {
+               r.Log.Error(fmt.Errorf("unexpected object type"), "failed to 
convert object to L4RoutePolicy")
+               return nil
+       }
+       requests := make([]reconcile.Request, 0, len(policy.Spec.TargetRefs))
+       seen := make(map[k8stypes.NamespacedName]struct{})
+       for _, ref := range policy.Spec.TargetRefs {
+               if string(ref.Group) != gatewayv1.GroupName || string(ref.Kind) 
!= KindTCPRoute {
+                       continue
+               }
+               nn := k8stypes.NamespacedName{
+                       Namespace: policy.Namespace,
+                       Name:      string(ref.Name),
+               }
+               if _, ok := seen[nn]; ok {
+                       continue
+               }
+               seen[nn] = struct{}{}
+               requests = append(requests, reconcile.Request{NamespacedName: 
nn})
+       }
+       return requests
+}
diff --git a/internal/controller/tlsroute_controller.go 
b/internal/controller/tlsroute_controller.go
index f5f97721..ecf46600 100644
--- a/internal/controller/tlsroute_controller.go
+++ b/internal/controller/tlsroute_controller.go
@@ -45,6 +45,7 @@ import (
        "github.com/apache/apisix-ingress-controller/internal/provider"
        "github.com/apache/apisix-ingress-controller/internal/types"
        "github.com/apache/apisix-ingress-controller/internal/utils"
+       pkgutils "github.com/apache/apisix-ingress-controller/pkg/utils"
 )
 
 // TLSRouteReconciler reconciles a TLSRoute object.
@@ -58,6 +59,9 @@ type TLSRouteReconciler struct { //nolint:revive
 
        Updater status.Updater
        Readier readiness.ReadinessManager
+
+       // supportsL4RoutePolicy indicates whether the L4RoutePolicy CRD is 
installed.
+       supportsL4RoutePolicy bool
 }
 
 // SetupWithManager sets up the controller with the Manager.
@@ -95,6 +99,15 @@ func (r *TLSRouteReconciler) SetupWithManager(mgr 
ctrl.Manager) error {
                        
handler.EnqueueRequestsFromMapFunc(r.listTLSRoutesForGatewayProxy),
                )
 
+       // L4RoutePolicy is an optional CRD. Only watch it when installed so the
+       // controller still starts if the CRD has not been applied yet (e.g. 
upgrades).
+       r.supportsL4RoutePolicy = pkgutils.HasAPIResource(mgr, 
&v1alpha1.L4RoutePolicy{})
+       if r.supportsL4RoutePolicy {
+               bdr.Watches(&v1alpha1.L4RoutePolicy{},
+                       
handler.EnqueueRequestsFromMapFunc(r.listTLSRoutesForL4RoutePolicy),
+               )
+       }
+
        if GetEnableReferenceGrant() {
                bdr.Watches(&v1beta1.ReferenceGrant{},
                        
handler.EnqueueRequestsFromMapFunc(r.listTLSRoutesForReferenceGrant),
@@ -240,6 +253,9 @@ func (r *TLSRouteReconciler) Reconcile(ctx context.Context, 
req ctrl.Request) (c
                                r.Log.Error(err, "failed to delete tlsroute", 
"tlsroute", tr)
                                return ctrl.Result{}, err
                        }
+                       if r.supportsL4RoutePolicy {
+                               updateL4RoutePolicyStatusOnDeleting(ctx, 
r.Client, r.Updater, r.Log, req.NamespacedName, types.KindTLSRoute)
+                       }
                        return ctrl.Result{}, nil
                }
                return ctrl.Result{}, err
@@ -293,6 +309,9 @@ func (r *TLSRouteReconciler) Reconcile(ctx context.Context, 
req ctrl.Request) (c
        }
 
        ProcessBackendTrafficPolicy(r.Client, r.Log, tctx)
+       if r.supportsL4RoutePolicy {
+               ProcessL4RoutePolicy(r.Client, r.Log, tctx, tr.Namespace, 
tr.Name, types.KindTLSRoute)
+       }
        tr.Status.Parents = make([]gatewayv1.RouteParentStatus, 0, 
len(gateways))
        for _, gateway := range gateways {
                parentStatus := gatewayv1.RouteParentStatus{}
@@ -503,3 +522,28 @@ func (r *TLSRouteReconciler) listTLSRoutesByServiceRef(ctx 
context.Context, obj
        }
        return requests
 }
+
+func (r *TLSRouteReconciler) listTLSRoutesForL4RoutePolicy(ctx 
context.Context, obj client.Object) []reconcile.Request {
+       policy, ok := obj.(*v1alpha1.L4RoutePolicy)
+       if !ok {
+               r.Log.Error(fmt.Errorf("unexpected object type"), "failed to 
convert object to L4RoutePolicy")
+               return nil
+       }
+       requests := make([]reconcile.Request, 0, len(policy.Spec.TargetRefs))
+       seen := make(map[k8stypes.NamespacedName]struct{})
+       for _, ref := range policy.Spec.TargetRefs {
+               if string(ref.Group) != gatewayv1.GroupName || string(ref.Kind) 
!= types.KindTLSRoute {
+                       continue
+               }
+               nn := k8stypes.NamespacedName{
+                       Namespace: policy.Namespace,
+                       Name:      string(ref.Name),
+               }
+               if _, ok := seen[nn]; ok {
+                       continue
+               }
+               seen[nn] = struct{}{}
+               requests = append(requests, reconcile.Request{NamespacedName: 
nn})
+       }
+       return requests
+}
diff --git a/internal/controller/udproute_controller.go 
b/internal/controller/udproute_controller.go
index 2a4a7a4a..070cee76 100644
--- a/internal/controller/udproute_controller.go
+++ b/internal/controller/udproute_controller.go
@@ -45,6 +45,7 @@ import (
        "github.com/apache/apisix-ingress-controller/internal/provider"
        "github.com/apache/apisix-ingress-controller/internal/types"
        "github.com/apache/apisix-ingress-controller/internal/utils"
+       pkgutils "github.com/apache/apisix-ingress-controller/pkg/utils"
 )
 
 // UDPRouteReconciler reconciles a UDPRoute object.
@@ -58,6 +59,9 @@ type UDPRouteReconciler struct { //nolint:revive
 
        Updater status.Updater
        Readier readiness.ReadinessManager
+
+       // supportsL4RoutePolicy indicates whether the L4RoutePolicy CRD is 
installed.
+       supportsL4RoutePolicy bool
 }
 
 // SetupWithManager sets up the controller with the Manager.
@@ -95,6 +99,15 @@ func (r *UDPRouteReconciler) SetupWithManager(mgr 
ctrl.Manager) error {
                        
handler.EnqueueRequestsFromMapFunc(r.listUDPRoutesForGatewayProxy),
                )
 
+       // L4RoutePolicy is an optional CRD. Only watch it when installed so the
+       // controller still starts if the CRD has not been applied yet (e.g. 
upgrades).
+       r.supportsL4RoutePolicy = pkgutils.HasAPIResource(mgr, 
&v1alpha1.L4RoutePolicy{})
+       if r.supportsL4RoutePolicy {
+               bdr.Watches(&v1alpha1.L4RoutePolicy{},
+                       
handler.EnqueueRequestsFromMapFunc(r.listUDPRoutesForL4RoutePolicy),
+               )
+       }
+
        if GetEnableReferenceGrant() {
                bdr.Watches(&v1beta1.ReferenceGrant{},
                        
handler.EnqueueRequestsFromMapFunc(r.listUDPRoutesForReferenceGrant),
@@ -240,6 +253,9 @@ func (r *UDPRouteReconciler) Reconcile(ctx context.Context, 
req ctrl.Request) (c
                                r.Log.Error(err, "failed to delete udproute", 
"udproute", tr)
                                return ctrl.Result{}, err
                        }
+                       if r.supportsL4RoutePolicy {
+                               updateL4RoutePolicyStatusOnDeleting(ctx, 
r.Client, r.Updater, r.Log, req.NamespacedName, KindUDPRoute)
+                       }
                        return ctrl.Result{}, nil
                }
                return ctrl.Result{}, err
@@ -293,6 +309,9 @@ func (r *UDPRouteReconciler) Reconcile(ctx context.Context, 
req ctrl.Request) (c
        }
 
        ProcessBackendTrafficPolicy(r.Client, r.Log, tctx)
+       if r.supportsL4RoutePolicy {
+               ProcessL4RoutePolicy(r.Client, r.Log, tctx, tr.Namespace, 
tr.Name, KindUDPRoute)
+       }
        tr.Status.Parents = make([]gatewayv1.RouteParentStatus, 0, 
len(gateways))
        for _, gateway := range gateways {
                parentStatus := gatewayv1.RouteParentStatus{}
@@ -503,3 +522,28 @@ func (r *UDPRouteReconciler) listUDPRoutesByServiceRef(ctx 
context.Context, obj
        }
        return requests
 }
+
+func (r *UDPRouteReconciler) listUDPRoutesForL4RoutePolicy(ctx 
context.Context, obj client.Object) []reconcile.Request {
+       policy, ok := obj.(*v1alpha1.L4RoutePolicy)
+       if !ok {
+               r.Log.Error(fmt.Errorf("unexpected object type"), "failed to 
convert object to L4RoutePolicy")
+               return nil
+       }
+       requests := make([]reconcile.Request, 0, len(policy.Spec.TargetRefs))
+       seen := make(map[k8stypes.NamespacedName]struct{})
+       for _, ref := range policy.Spec.TargetRefs {
+               if string(ref.Group) != gatewayv1.GroupName || string(ref.Kind) 
!= KindUDPRoute {
+                       continue
+               }
+               nn := k8stypes.NamespacedName{
+                       Namespace: policy.Namespace,
+                       Name:      string(ref.Name),
+               }
+               if _, ok := seen[nn]; ok {
+                       continue
+               }
+               seen[nn] = struct{}{}
+               requests = append(requests, reconcile.Request{NamespacedName: 
nn})
+       }
+       return requests
+}
diff --git a/internal/manager/controllers.go b/internal/manager/controllers.go
index fd6e5782..a8b9e055 100644
--- a/internal/manager/controllers.go
+++ b/internal/manager/controllers.go
@@ -77,6 +77,8 @@ import (
 // 
+kubebuilder:rbac:groups=apisix.apache.org,resources=backendtrafficpolicies/status,verbs=get;update
 // 
+kubebuilder:rbac:groups=apisix.apache.org,resources=httproutepolicies,verbs=get;list;watch
 // 
+kubebuilder:rbac:groups=apisix.apache.org,resources=httproutepolicies/status,verbs=get;update
+// 
+kubebuilder:rbac:groups=apisix.apache.org,resources=l4routepolicies,verbs=get;list;watch
+// 
+kubebuilder:rbac:groups=apisix.apache.org,resources=l4routepolicies/status,verbs=get;update
 
 // GatewayAPI
 // 
+kubebuilder:rbac:groups=gateway.networking.k8s.io,resources=gatewayclasses,verbs=get;list;watch;update
diff --git a/internal/provider/provider.go b/internal/provider/provider.go
index 92ada510..92f36a87 100644
--- a/internal/provider/provider.go
+++ b/internal/provider/provider.go
@@ -54,6 +54,7 @@ type TranslateContext struct {
        ApisixPluginConfigs    
map[k8stypes.NamespacedName]*apiv2.ApisixPluginConfig
        Services               map[k8stypes.NamespacedName]*corev1.Service
        BackendTrafficPolicies 
map[k8stypes.NamespacedName]*v1alpha1.BackendTrafficPolicy
+       L4RoutePolicies        
map[k8stypes.NamespacedName]*v1alpha1.L4RoutePolicy
        Upstreams              map[k8stypes.NamespacedName]*apiv2.ApisixUpstream
        GatewayProxies         
map[types.NamespacedNameKind]v1alpha1.GatewayProxy
        ResourceParentRefs     
map[types.NamespacedNameKind][]types.NamespacedNameKind
@@ -73,6 +74,7 @@ func NewDefaultTranslateContext(ctx context.Context) 
*TranslateContext {
                ApisixPluginConfigs:    
make(map[k8stypes.NamespacedName]*apiv2.ApisixPluginConfig),
                Services:               
make(map[k8stypes.NamespacedName]*corev1.Service),
                BackendTrafficPolicies: 
make(map[k8stypes.NamespacedName]*v1alpha1.BackendTrafficPolicy),
+               L4RoutePolicies:        
make(map[k8stypes.NamespacedName]*v1alpha1.L4RoutePolicy),
                Upstreams:              
make(map[k8stypes.NamespacedName]*apiv2.ApisixUpstream),
                GatewayProxies:         
make(map[types.NamespacedNameKind]v1alpha1.GatewayProxy),
                ResourceParentRefs:     
make(map[types.NamespacedNameKind][]types.NamespacedNameKind),
diff --git a/internal/types/k8s.go b/internal/types/k8s.go
index 2aa317e4..fa2e05f6 100644
--- a/internal/types/k8s.go
+++ b/internal/types/k8s.go
@@ -55,6 +55,7 @@ const (
        KindApisixTls            = "ApisixTls"
        KindApisixConsumer       = "ApisixConsumer"
        KindHTTPRoutePolicy      = "HTTPRoutePolicy"
+       KindL4RoutePolicy        = "L4RoutePolicy"
        KindBackendTrafficPolicy = "BackendTrafficPolicy"
        KindConsumer             = "Consumer"
        KindPluginConfig         = "PluginConfig"
@@ -108,6 +109,8 @@ func KindOf(obj any) string {
                return KindApisixConsumer
        case *v1alpha1.HTTPRoutePolicy:
                return KindHTTPRoutePolicy
+       case *v1alpha1.L4RoutePolicy:
+               return KindL4RoutePolicy
        case *v1alpha1.BackendTrafficPolicy:
                return KindBackendTrafficPolicy
        case *v1alpha1.GatewayProxy:
@@ -176,6 +179,12 @@ func GvkOf(obj any) schema.GroupVersionKind {
                        Version: "v1alpha1",
                        Kind:    KindHTTPRoutePolicy,
                }
+       case *v1alpha1.L4RoutePolicy:
+               return schema.GroupVersionKind{
+                       Group:   "apisix.apache.org",
+                       Version: "v1alpha1",
+                       Kind:    KindL4RoutePolicy,
+               }
        case *v1alpha1.BackendTrafficPolicy:
                return schema.GroupVersionKind{
                        Group:   "apisix.apache.org",
diff --git a/test/e2e/framework/assertion.go b/test/e2e/framework/assertion.go
index fe069b4e..7fcb5f50 100644
--- a/test/e2e/framework/assertion.go
+++ b/test/e2e/framework/assertion.go
@@ -102,6 +102,38 @@ func PollUntilHTTPRoutePolicyHaveStatus(cli client.Client, 
timeout time.Duration
        return genericPollResource(new(v1alpha1.HTTPRoutePolicy), cli, timeout, 
hrpNN, f)
 }
 
+func L4RoutePolicyMustHaveCondition(t testing.TestingT, client client.Client, 
timeout time.Duration, refNN, policyNN types.NamespacedName,
+       condition metav1.Condition) {
+       err := PollUntilL4RoutePolicyHaveStatus(client, timeout, policyNN, 
func(policy *v1alpha1.L4RoutePolicy) bool {
+               for _, ancestor := range policy.Status.Ancestors {
+                       if err := 
kubernetes.ConditionsHaveLatestObservedGeneration(policy, ancestor.Conditions); 
err != nil {
+                               log.Printf("L4RoutePolicy %s (ancestorRef=%v) 
%v", policyNN, parentRefToString(ancestor.AncestorRef), err)
+                               return false
+                       }
+
+                       if ancestor.AncestorRef.Name == 
gatewayv1.ObjectName(refNN.Name) &&
+                               (refNN.Namespace == "" || 
(ancestor.AncestorRef.Namespace != nil && 
string(*ancestor.AncestorRef.Namespace) == refNN.Namespace)) {
+                               if findConditionInList(ancestor.Conditions, 
condition) {
+                                       log.Printf("found condition %v in list 
%v for %s reference %s", condition, ancestor.Conditions, policyNN, refNN)
+                                       return true
+                               }
+                               log.Printf("NOT FOUND condition %v in %v for %s 
reference %s", condition, ancestor.Conditions, policyNN, refNN)
+                       }
+               }
+               return false
+       })
+
+       require.NoError(t, err, "error waiting for L4RoutePolicy %s status to 
have a Condition matching %+v", policyNN, condition)
+}
+
+func PollUntilL4RoutePolicyHaveStatus(cli client.Client, timeout 
time.Duration, policyNN types.NamespacedName,
+       f func(policy *v1alpha1.L4RoutePolicy) bool) error {
+       if err := v1alpha1.AddToScheme(cli.Scheme()); err != nil {
+               return err
+       }
+       return genericPollResource(new(v1alpha1.L4RoutePolicy), cli, timeout, 
policyNN, f)
+}
+
 func APIv2MustHaveCondition(t testing.TestingT, cli client.Client, timeout 
time.Duration, nn types.NamespacedName, obj client.Object, cond 
metav1.Condition) {
        f := func(object client.Object) bool {
                value := reflect.Indirect(reflect.ValueOf(object))
diff --git a/test/e2e/framework/manifests/ingress.yaml 
b/test/e2e/framework/manifests/ingress.yaml
index 15fdf630..0c690392 100644
--- a/test/e2e/framework/manifests/ingress.yaml
+++ b/test/e2e/framework/manifests/ingress.yaml
@@ -101,6 +101,7 @@ rules:
   - consumers
   - gatewayproxies
   - httproutepolicies
+  - l4routepolicies
   - pluginconfigs
   verbs:
   - get
@@ -118,6 +119,7 @@ rules:
   - backendtrafficpolicies/status
   - consumers/status
   - httproutepolicies/status
+  - l4routepolicies/status
   verbs:
   - get
   - update
diff --git a/test/e2e/gatewayapi/tcproute.go b/test/e2e/gatewayapi/tcproute.go
index b6de0a42..9c292395 100644
--- a/test/e2e/gatewayapi/tcproute.go
+++ b/test/e2e/gatewayapi/tcproute.go
@@ -23,6 +23,9 @@ import (
 
        . "github.com/onsi/ginkgo/v2"
        . "github.com/onsi/gomega"
+       metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+       "k8s.io/apimachinery/pkg/types"
+       gatewayv1alpha2 "sigs.k8s.io/gateway-api/apis/v1alpha2"
 
        "github.com/apache/apisix-ingress-controller/test/e2e/scaffold"
 )
@@ -108,4 +111,95 @@ spec:
                        s.HTTPOverTCPConnectAssert(false, time.Minute*3)
                })
        })
+
+       Context("TCPRoute With L4RoutePolicy", func() {
+               var tcpGateway = `
+apiVersion: gateway.networking.k8s.io/v1
+kind: Gateway
+metadata:
+  name: %s
+spec:
+  gatewayClassName: %s
+  listeners:
+  - name: tcp
+    protocol: TCP
+    port: 80
+    allowedRoutes:
+      kinds:
+      - kind: TCPRoute
+  infrastructure:
+    parametersRef:
+      group: apisix.apache.org
+      kind: GatewayProxy
+      name: apisix-proxy-config
+`
+
+               var tcpRoute = `
+apiVersion: gateway.networking.k8s.io/v1alpha2
+kind: TCPRoute
+metadata:
+  name: tcp-l4policy
+spec:
+  parentRefs:
+  - name: %s
+    sectionName: tcp
+  rules:
+  - backendRefs:
+    - name: httpbin-service-e2e-test
+      port: 80
+`
+
+               // ip-restriction with blacklist covering all IPv4 addresses 
blocks all TCP connections.
+               var l4RoutePolicyBlockAll = `
+apiVersion: apisix.apache.org/v1alpha1
+kind: L4RoutePolicy
+metadata:
+  name: tcp-block-all
+spec:
+  targetRefs:
+  - group: gateway.networking.k8s.io
+    kind: TCPRoute
+    name: tcp-l4policy
+  plugins:
+  - name: ip-restriction
+    config:
+      blacklist:
+      - "0.0.0.0/0"
+`
+
+               BeforeEach(func() {
+                       
Expect(s.CreateResourceFromString(s.GetGatewayProxySpec())).NotTo(HaveOccurred(),
 "creating GatewayProxy")
+                       
Expect(s.CreateResourceFromString(s.GetGatewayClassYaml())).NotTo(HaveOccurred(),
 "creating GatewayClass")
+                       
Expect(s.CreateResourceFromString(fmt.Sprintf(tcpGateway, s.Namespace(), 
s.Namespace()))).
+                               NotTo(HaveOccurred(), "creating Gateway")
+               })
+
+               It("L4RoutePolicy blocks traffic via ip-restriction plugin", 
func() {
+                       By("creating TCPRoute")
+                       s.ResourceApplied("TCPRoute", "tcp-l4policy", 
fmt.Sprintf(tcpRoute, s.Namespace()), 1)
+
+                       By("verifying TCP traffic works before applying 
L4RoutePolicy")
+                       s.HTTPOverTCPConnectAssert(true, time.Minute*3)
+
+                       By("applying L4RoutePolicy with ip-restriction 
blacklist")
+                       s.ApplyL4RoutePolicy(
+                               types.NamespacedName{Name: s.Namespace()},
+                               types.NamespacedName{Namespace: s.Namespace(), 
Name: "tcp-block-all"},
+                               l4RoutePolicyBlockAll,
+                               metav1.Condition{
+                                       Type:   
string(gatewayv1alpha2.PolicyConditionAccepted),
+                                       Status: metav1.ConditionTrue,
+                               },
+                       )
+
+                       By("verifying TCP traffic is blocked by the 
L4RoutePolicy")
+                       s.HTTPOverTCPConnectAssert(false, time.Minute*3)
+
+                       By("deleting L4RoutePolicy")
+                       Expect(s.DeleteResource("L4RoutePolicy", 
"tcp-block-all")).NotTo(HaveOccurred(), "deleting L4RoutePolicy")
+
+                       By("verifying TCP traffic recovers after L4RoutePolicy 
deletion")
+                       s.HTTPOverTCPConnectAssert(true, time.Minute*3)
+               })
+       })
 })
diff --git a/test/e2e/scaffold/k8s.go b/test/e2e/scaffold/k8s.go
index 30fab2fd..db461150 100644
--- a/test/e2e/scaffold/k8s.go
+++ b/test/e2e/scaffold/k8s.go
@@ -294,6 +294,22 @@ func (s *Scaffold) RunCurlFromK8s(args ...string) (string, 
error) {
        return s.RunKubectlAndGetOutput(kubectlArgs...)
 }
 
+func (s *Scaffold) ApplyL4RoutePolicy(refNN, policyNN types.NamespacedName, 
spec string, conditions ...metav1.Condition) {
+       err := s.CreateResourceFromString(spec)
+       Expect(err).NotTo(HaveOccurred(), "creating L4RoutePolicy %s", policyNN)
+       if len(conditions) == 0 {
+               conditions = []metav1.Condition{
+                       {
+                               Type:   
string(v1alpha2.PolicyConditionAccepted),
+                               Status: metav1.ConditionTrue,
+                       },
+               }
+       }
+       for _, condition := range conditions {
+               framework.L4RoutePolicyMustHaveCondition(s.GinkgoT, 
s.K8sClient, 8*time.Second, refNN, policyNN, condition)
+       }
+}
+
 func (s *Scaffold) GetGatewayProxySpec() string {
        var gatewayProxyYaml = `
 apiVersion: apisix.apache.org/v1alpha1

Reply via email to