From a6ea2e0fe13c466fa57fcca6edfa000e04f0dbf4 Mon Sep 17 00:00:00 2001 From: Marius Bertram Date: Mon, 16 Feb 2026 12:29:18 +0100 Subject: [PATCH 1/3] Refactor IPRule to support multiple CIDRs and add validation webhook - Deprecate `spec.cidr` in favor of `spec.cidrs` list in `IPRule` API and CRD. - Update controller logic to handle multiple CIDRs and implement automatic migration from the legacy field. - Add validating webhook for `IPRule` to verify CIDR syntax and warn on deprecated field usage. - Update `Agent` CRD to include container resource limits and requests. - Register webhook in `main.go` and add webhook configuration manifests. - Update tests to verify migration behavior and support the new API structure. --- api/v1alpha1/iprule_types.go | 5 +- api/v1alpha1/iprule_webhook.go | 82 +++++++++++++++++++ api/v1alpha1/zz_generated.deepcopy.go | 9 +- cmd/main.go | 6 ++ .../bases/api.operator.brtrm.dev_agents.yaml | 59 +++++++++++++ .../bases/api.operator.brtrm.dev_iprules.yaml | 11 ++- config/webhook/manifests.yaml | 26 ++++++ internal/controller/iprule_controller.go | 42 +++++++--- internal/controller/iprule_controller_test.go | 63 +++++++++++++- 9 files changed, 282 insertions(+), 21 deletions(-) create mode 100644 api/v1alpha1/iprule_webhook.go create mode 100644 config/webhook/manifests.yaml diff --git a/api/v1alpha1/iprule_types.go b/api/v1alpha1/iprule_types.go index 3d1b7c5..2a80da9 100644 --- a/api/v1alpha1/iprule_types.go +++ b/api/v1alpha1/iprule_types.go @@ -30,7 +30,10 @@ type IPRuleSpec struct { // Priority is the rule priority used. If 0, a default will be used by the agent Priority int `json:"priority,omitempty"` // SubnetTableMappings defines which routing table/priority to use for any LB IP within the given CIDR subnets - Cidr string `json:"cidr"` + // Deprecated: Use Cidrs instead + Cidr string `json:"cidr,omitempty"` + // Cidrs is a list of CIDR subnets (IPv4 and IPv6) + Cidrs []string `json:"cidrs,omitempty"` } // State constants for IPRuleConfig.Spec.State diff --git a/api/v1alpha1/iprule_webhook.go b/api/v1alpha1/iprule_webhook.go new file mode 100644 index 0000000..73b94e6 --- /dev/null +++ b/api/v1alpha1/iprule_webhook.go @@ -0,0 +1,82 @@ +/* +Copyright 2025 Marius Bertram. + +Licensed 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 ( + "context" + "fmt" + "net/netip" + + "k8s.io/apimachinery/pkg/runtime" + ctrl "sigs.k8s.io/controller-runtime" + logf "sigs.k8s.io/controller-runtime/pkg/log" + "sigs.k8s.io/controller-runtime/pkg/webhook" + "sigs.k8s.io/controller-runtime/pkg/webhook/admission" +) + +// log is for logging in this package. +var iprulelog = logf.Log.WithName("iprule-resource") + +// SetupWebhookWithManager will setup the manager to manage the webhooks +func (r *IPRule) SetupWebhookWithManager(mgr ctrl.Manager) error { + return ctrl.NewWebhookManagedBy(mgr). + For(r). + Complete() +} + +// +kubebuilder:webhook:path=/validate-api-operator-brtrm-dev-v1alpha1-iprule,mutating=false,failurePolicy=fail,sideEffects=None,groups=api.operator.brtrm.dev,resources=iprules,verbs=create;update,versions=v1alpha1,name=viprule.kb.io,admissionReviewVersions=v1 + +var _ webhook.CustomValidator = &IPRule{} + +// ValidateCreate implements webhook.CustomValidator so a webhook will be registered for the type +func (r *IPRule) ValidateCreate(ctx context.Context, obj runtime.Object) (admission.Warnings, error) { + iprulelog.Info("validate create", "name", r.Name) + return r.validateIPRule() +} + +// ValidateUpdate implements webhook.CustomValidator so a webhook will be registered for the type +func (r *IPRule) ValidateUpdate(ctx context.Context, oldObj, newObj runtime.Object) (admission.Warnings, error) { + iprulelog.Info("validate update", "name", r.Name) + return r.validateIPRule() +} + +// ValidateDelete implements webhook.CustomValidator so a webhook will be registered for the type +func (r *IPRule) ValidateDelete(ctx context.Context, obj runtime.Object) (admission.Warnings, error) { + iprulelog.Info("validate delete", "name", r.Name) + return nil, nil +} + +func (r *IPRule) validateIPRule() (admission.Warnings, error) { + var warnings admission.Warnings + + // Validate Cidrs + for _, cidr := range r.Spec.Cidrs { + if _, err := netip.ParsePrefix(cidr); err != nil { + return warnings, fmt.Errorf("invalid CIDR %q: %v", cidr, err) + } + } + + // Validate legacy Cidr if present + if r.Spec.Cidr != "" { + if _, err := netip.ParsePrefix(r.Spec.Cidr); err != nil { + return warnings, fmt.Errorf("invalid legacy CIDR %q: %v", r.Spec.Cidr, err) + } + warnings = append(warnings, "field 'cidr' is deprecated, please use 'cidrs' instead") + } + + return warnings, nil +} diff --git a/api/v1alpha1/zz_generated.deepcopy.go b/api/v1alpha1/zz_generated.deepcopy.go index aa25982..003c595 100644 --- a/api/v1alpha1/zz_generated.deepcopy.go +++ b/api/v1alpha1/zz_generated.deepcopy.go @@ -23,7 +23,7 @@ package v1alpha1 import ( "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - runtime "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime" ) // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. @@ -142,7 +142,7 @@ func (in *IPRule) DeepCopyInto(out *IPRule) { *out = *in out.TypeMeta = in.TypeMeta in.ObjectMeta.DeepCopyInto(&out.ObjectMeta) - out.Spec = in.Spec + in.Spec.DeepCopyInto(&out.Spec) in.Status.DeepCopyInto(&out.Status) } @@ -272,6 +272,11 @@ func (in *IPRuleList) DeepCopyObject() runtime.Object { // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *IPRuleSpec) DeepCopyInto(out *IPRuleSpec) { *out = *in + if in.Cidrs != nil { + in, out := &in.Cidrs, &out.Cidrs + *out = make([]string, len(*in)) + copy(*out, *in) + } } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new IPRuleSpec. diff --git a/cmd/main.go b/cmd/main.go index c0723eb..7215d61 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -216,6 +216,12 @@ func main() { setupLog.Error(err, "unable to create controller", "controller", "Agent") os.Exit(1) } + if os.Getenv("ENABLE_WEBHOOKS") != "false" { + if err = (&apiv1alpha1.IPRule{}).SetupWebhookWithManager(mgr); err != nil { + setupLog.Error(err, "unable to create webhook", "webhook", "IPRule") + os.Exit(1) + } + } // +kubebuilder:scaffold:builder if metricsCertWatcher != nil { diff --git a/config/crd/bases/api.operator.brtrm.dev_agents.yaml b/config/crd/bases/api.operator.brtrm.dev_agents.yaml index 5a51a81..770f326 100644 --- a/config/crd/bases/api.operator.brtrm.dev_agents.yaml +++ b/config/crd/bases/api.operator.brtrm.dev_agents.yaml @@ -48,6 +48,65 @@ spec: description: NodeSelector restricts the target nodes on which the agent pods will be scheduled. type: object + resources: + description: Resources applied to the agent pods. + properties: + claims: + description: |- + Claims lists the names of resources, defined in spec.resourceClaims, + that are used by this container. + + This is an alpha field and requires enabling the + DynamicResourceAllocation feature gate. + + This field is immutable. It can only be set for containers. + items: + description: ResourceClaim references one entry in PodSpec.ResourceClaims. + properties: + name: + description: |- + Name must match the name of one entry in pod.spec.resourceClaims of + the Pod where this field is used. It makes that resource available + inside a container. + type: string + request: + description: |- + Request is the name chosen for a request in the referenced claim. + If empty, everything from the claim is made available, otherwise + only the result of this request. + type: string + required: + - name + type: object + type: array + x-kubernetes-list-map-keys: + - name + x-kubernetes-list-type: map + limits: + additionalProperties: + anyOf: + - type: integer + - type: string + pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ + x-kubernetes-int-or-string: true + description: |- + Limits describes the maximum amount of compute resources allowed. + More info: https://kubernetes.io/docs/concepts/configuration/manage-resources-containers/ + type: object + requests: + additionalProperties: + anyOf: + - type: integer + - type: string + pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ + x-kubernetes-int-or-string: true + description: |- + Requests describes the minimum amount of compute resources required. + If Requests is omitted for a container, it defaults to Limits if that is explicitly specified, + otherwise to an implementation-defined value. Requests cannot exceed Limits. + More info: https://kubernetes.io/docs/concepts/configuration/manage-resources-containers/ + type: object + type: object tolerations: description: Tolerations applied to the agent pods. items: diff --git a/config/crd/bases/api.operator.brtrm.dev_iprules.yaml b/config/crd/bases/api.operator.brtrm.dev_iprules.yaml index a855d70..c5b8254 100644 --- a/config/crd/bases/api.operator.brtrm.dev_iprules.yaml +++ b/config/crd/bases/api.operator.brtrm.dev_iprules.yaml @@ -40,9 +40,15 @@ spec: description: IpRuleSpec defines the desired state of IpRule. properties: cidr: - description: SubnetTableMappings defines which routing table/priority - to use for any LB IP within the given CIDR subnets + description: |- + SubnetTableMappings defines which routing table/priority to use for any LB IP within the given CIDR subnets + Deprecated: Use Cidrs instead type: string + cidrs: + description: Cidrs is a list of CIDR subnets (IPv4 and IPv6) + items: + type: string + type: array priority: description: Priority is the rule priority used. If 0, a default will be used by the agent @@ -52,7 +58,6 @@ spec: rules. If 0, a default will be used by the agent type: integer required: - - cidr - table type: object status: diff --git a/config/webhook/manifests.yaml b/config/webhook/manifests.yaml new file mode 100644 index 0000000..7cd36f5 --- /dev/null +++ b/config/webhook/manifests.yaml @@ -0,0 +1,26 @@ +--- +apiVersion: admissionregistration.k8s.io/v1 +kind: ValidatingWebhookConfiguration +metadata: + name: validating-webhook-configuration +webhooks: +- admissionReviewVersions: + - v1 + clientConfig: + service: + name: webhook-service + namespace: system + path: /validate-api-operator-brtrm-dev-v1alpha1-iprule + failurePolicy: Fail + name: viprule.kb.io + rules: + - apiGroups: + - api.operator.brtrm.dev + apiVersions: + - v1alpha1 + operations: + - CREATE + - UPDATE + resources: + - iprules + sideEffects: None diff --git a/internal/controller/iprule_controller.go b/internal/controller/iprule_controller.go index 4076492..4d5e46e 100644 --- a/internal/controller/iprule_controller.go +++ b/internal/controller/iprule_controller.go @@ -82,6 +82,22 @@ func (r *IPRuleReconciler) Reconcile(ctx context.Context, _ ctrl.Request) (ctrl. return ctrl.Result{}, client.IgnoreNotFound(err) } + // Migrate old Cidr field to new Cidrs list + for i := range ipRules.Items { + rule := &ipRules.Items[i] + if rule.Spec.Cidr != "" && len(rule.Spec.Cidrs) == 0 { + rule.Spec.Cidrs = []string{rule.Spec.Cidr} + rule.Spec.Cidr = "" // Clear old field + if err := r.Update(ctx, rule); err != nil { + log.Error(err, "failed to migrate IPRule Cidr to Cidrs", "name", rule.Name) + metricReconcileErrors.WithLabelValues("iprule").Inc() + return ctrl.Result{}, err + } + // Re-queue to process updated object + return ctrl.Result{Requeue: true}, nil + } + } + svcIPSet, err := r.collectServiceVIPs(ctx) if err != nil { metricReconcileErrors.WithLabelValues("iprule").Inc() @@ -143,18 +159,22 @@ func (r *IPRuleReconciler) buildDesiredEntryMap(ipRules *apiv1alpha1.IPRuleList, for _, lbIP := range lbIPs { for i := range ipRules.Items { rule := &ipRules.Items[i] - cidr, _ := netip.ParsePrefix(rule.Spec.Cidr) - if !cidr.IsValid() || !cidr.Contains(lbIP) { - continue - } - entry := ipRuleEntry{IP: clusterIP, Table: rule.Spec.Table, Priority: rule.Spec.Priority, Owner: rule, PrefixLen: cidr.Bits()} - key := entry.IP.String() + "|" + strconv.Itoa(entry.Table) + "|" + strconv.Itoa(entry.Priority) - if existing, ok := entryMap[key]; ok { - if entry.PrefixLen > existing.PrefixLen { // most specific + + // Check all CIDRs in the list + for _, cidrStr := range rule.Spec.Cidrs { + cidr, _ := netip.ParsePrefix(cidrStr) + if !cidr.IsValid() || !cidr.Contains(lbIP) { + continue + } + entry := ipRuleEntry{IP: clusterIP, Table: rule.Spec.Table, Priority: rule.Spec.Priority, Owner: rule, PrefixLen: cidr.Bits()} + key := entry.IP.String() + "|" + strconv.Itoa(entry.Table) + "|" + strconv.Itoa(entry.Priority) + if existing, ok := entryMap[key]; ok { + if entry.PrefixLen > existing.PrefixLen { // most specific + entryMap[key] = entry + } + } else { entryMap[key] = entry } - } else { - entryMap[key] = entry } } } @@ -169,7 +189,7 @@ func (r *IPRuleReconciler) applyDesiredConfigs(ctx context.Context, entryMap map annotationSpecHash = "iprule.operator.brtrm.dev/spec-hash" ) for _, e := range entryMap { - name := "iprc-" + strings.ReplaceAll(e.IP.String(), ".", "-") + name := "iprc-" + strings.ReplaceAll(strings.ReplaceAll(e.IP.String(), ".", "-"), ":", "-") cfg := &apiv1alpha1.IPRuleConfig{} errGet := r.Get(ctx, types.NamespacedName{Name: name}, cfg) if k8serrors.IsNotFound(errGet) { diff --git a/internal/controller/iprule_controller_test.go b/internal/controller/iprule_controller_test.go index e216364..4fa03b4 100644 --- a/internal/controller/iprule_controller_test.go +++ b/internal/controller/iprule_controller_test.go @@ -18,16 +18,16 @@ package controller import ( "context" + "time" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" corev1 "k8s.io/api/core/v1" "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/types" "sigs.k8s.io/controller-runtime/pkg/reconcile" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - apiv1alpha1 "github.com/mariusbertram/ip-rule-operator/api/v1alpha1" ) @@ -48,7 +48,7 @@ var _ = Describe("IpRule Controller", func() { Name: resourceName, }, Spec: apiv1alpha1.IPRuleSpec{ - Cidr: "10.0.0.0/24", + Cidrs: []string{"10.0.0.0/24"}, Table: 100, Priority: 1000, }, @@ -110,7 +110,7 @@ var _ = Describe("IpRule Controller", func() { Name: ruleName, }, Spec: apiv1alpha1.IPRuleSpec{ - Cidr: "10.0.0.0/24", + Cidrs: []string{"10.0.0.0/24"}, Table: 200, Priority: 2000, }, @@ -214,4 +214,59 @@ var _ = Describe("IpRule Controller", func() { }, "10s", "500ms").Should(BeTrue()) }) }) + + Context("When migrating old Cidr field", func() { + const ruleName = "test-iprule-migration" + + ctx := context.Background() + + BeforeEach(func() { + By("creating an IPRule with old Cidr field") + rule := &apiv1alpha1.IPRule{ + ObjectMeta: metav1.ObjectMeta{ + Name: ruleName, + }, + Spec: apiv1alpha1.IPRuleSpec{ + Cidr: "10.0.0.0/24", + Table: 300, + Priority: 3000, + }, + } + err := k8sClient.Create(ctx, rule) + if err != nil && !errors.IsAlreadyExists(err) { + Expect(err).NotTo(HaveOccurred()) + } + }) + + AfterEach(func() { + By("Cleanup the IPRule") + rule := &apiv1alpha1.IPRule{} + err := k8sClient.Get(ctx, types.NamespacedName{Name: ruleName}, rule) + if err == nil { + Expect(k8sClient.Delete(ctx, rule)).To(Succeed()) + } + }) + + It("should migrate Cidr to Cidrs", func() { + By("Reconciling the IPRule") + controllerReconciler := &IPRuleReconciler{ + Client: k8sClient, + Scheme: k8sClient.Scheme(), + } + + // Reconcile multiple times to ensure migration and subsequent processing + _, err := controllerReconciler.Reconcile(ctx, reconcile.Request{}) + Expect(err).NotTo(HaveOccurred()) + + // Give it a moment to update + time.Sleep(100 * time.Millisecond) + + By("Verifying the IPRule was updated") + rule := &apiv1alpha1.IPRule{} + err = k8sClient.Get(ctx, types.NamespacedName{Name: ruleName}, rule) + Expect(err).NotTo(HaveOccurred()) + Expect(rule.Spec.Cidr).To(BeEmpty()) + Expect(rule.Spec.Cidrs).To(ContainElement("10.0.0.0/24")) + }) + }) }) From a33a2f202e9bcf9b9186bb743edbd8a077c222eb Mon Sep 17 00:00:00 2001 From: Marius Bertram Date: Mon, 16 Feb 2026 13:19:41 +0100 Subject: [PATCH 2/3] Support multiple ClusterIPs and match IP families in IPRule controller - Update `collectServiceVIPs` to iterate through `ClusterIPs` and enforce address family matching (IPv4/IPv6) between ClusterIP and LoadBalancer IP. - Update service predicate to trigger reconciliation on `ClusterIP` or `ClusterIPs` changes. --- internal/controller/iprule_controller.go | 35 ++++++++++++++++++++---- 1 file changed, 30 insertions(+), 5 deletions(-) diff --git a/internal/controller/iprule_controller.go b/internal/controller/iprule_controller.go index 4d5e46e..00b1f68 100644 --- a/internal/controller/iprule_controller.go +++ b/internal/controller/iprule_controller.go @@ -135,19 +135,30 @@ func (r *IPRuleReconciler) collectServiceVIPs(ctx context.Context) (map[netip.Ad } svcIPSet := map[netip.Addr][]netip.Addr{} for _, svc := range svcList.Items { + clusterIPs := svc.Spec.ClusterIPs + if len(clusterIPs) == 0 && svc.Spec.ClusterIP != "" { + clusterIPs = []string{svc.Spec.ClusterIP} + } + for _, ing := range svc.Status.LoadBalancer.Ingress { if ing.IP == "" { continue } - clusterIP, _ := netip.ParseAddr(svc.Spec.ClusterIP) - if !clusterIP.IsValid() { - continue - } svcVIP, _ := netip.ParseAddr(ing.IP) if !svcVIP.IsValid() { continue } - svcIPSet[clusterIP] = append(svcIPSet[clusterIP], svcVIP) + + for _, cIPStr := range clusterIPs { + clusterIP, _ := netip.ParseAddr(cIPStr) + if !clusterIP.IsValid() { + continue + } + // Match address families (IPv4 with IPv4, IPv6 with IPv6) + if (clusterIP.Is4() && svcVIP.Is4()) || (clusterIP.Is6() && svcVIP.Is6()) { + svcIPSet[clusterIP] = append(svcIPSet[clusterIP], svcVIP) + } + } } } return svcIPSet, nil @@ -306,6 +317,20 @@ func (r *IPRuleReconciler) SetupWithManager(mgr ctrl.Manager) error { if oldSvc.Spec.Type != newSvc.Spec.Type { return true } + + // Check ClusterIPs change + if len(oldSvc.Spec.ClusterIPs) != len(newSvc.Spec.ClusterIPs) { + return true + } + for i := range oldSvc.Spec.ClusterIPs { + if oldSvc.Spec.ClusterIPs[i] != newSvc.Spec.ClusterIPs[i] { + return true + } + } + if oldSvc.Spec.ClusterIP != newSvc.Spec.ClusterIP { + return true + } + oldIPs := loadBalancerIPs(oldSvc) newIPs := loadBalancerIPs(newSvc) if len(oldIPs) != len(newIPs) { From f55ccc57143857cd3b4c7043e27a203a392bc52e Mon Sep 17 00:00:00 2001 From: Marius Bertram Date: Mon, 16 Feb 2026 14:37:56 +0100 Subject: [PATCH 3/3] Update README to reflect Dual-Stack support and 'cidrs' field usage - Document IPv4 and IPv6 (Dual-Stack) capabilities in overview and architecture. - Update IPRule CRD examples to use the `cidrs` list field instead of `cidr`. - Add IPv6 examples for `ip rule` and `ip route` configuration. - Refresh diagrams to include IPv6 traffic flows. --- README.md | 35 +++++++++++++++++++++++++---------- 1 file changed, 25 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index 3555eb1..921fa53 100644 --- a/README.md +++ b/README.md @@ -31,12 +31,14 @@ The operator consists of two main components: - Matches LoadBalancer IPs against defined IPRule policies (CIDR-based) - Automatically generates IPRuleConfig resources for each Service - Manages the Agent DaemonSet + - Supports IPv4 and IPv6 (Dual-Stack) 2. **Agent (DaemonSet)**: - Runs on each node with hostNetwork access - Applies/removes IP routing rules on the node - Uses Linux netlink for direct kernel interaction - Continuously reconciles the desired state + - Supports IPv4 and IPv6 rules ### What is Policy-Based Routing? @@ -50,6 +52,7 @@ In a Kubernetes cluster with multiple network interfaces or load balancers, you - **Provider-based Routing**: Route services from different tenants through different ISP uplinks - **Traffic Segregation**: Physically separate production and test traffic - **Geo-Routing**: Regionally distribute traffic based on LoadBalancer IP ranges +- **Dual-Stack Routing**: Handle IPv4 and IPv6 traffic with specific routing tables #### How does it work? @@ -58,15 +61,19 @@ The operator uses Linux **IP Rules** (see `ip rule`) to route traffic based on t ```bash # Example: Traffic from Service 10.96.1.50 uses routing table 100 ip rule add from 10.96.1.50 lookup 100 priority 1000 + +# Example IPv6: Traffic from Service fd00::1 uses routing table 100 +ip -6 rule add from fd00::1 lookup 100 priority 1000 ``` Routing table 100 can then contain its own routes, e.g.: ```bash # Table 100: Traffic via special gateway ip route add default via 192.168.1.1 dev eth1 table 100 +ip -6 route add default via fd00::1 dev eth1 table 100 ``` -**Result**: All packets originating from the Service IP `10.96.1.50` are routed through the gateway `192.168.1.1`, while other services use the default route. +**Result**: All packets originating from the Service IP `10.96.1.50` (or `fd00::1`) are routed through the gateway `192.168.1.1` (or `fd00::1`), while other services use the default route. ## 🏗️ Architecture @@ -97,7 +104,7 @@ ip route add default via 192.168.1.1 dev eth1 table 100 │ │ │ │ IPRule │ │ IPRuleConfig │ │ Agent │ │ │ │ │ │ │ │ (User-def.) │ │ (Generated) │ │ (Deploy) │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ -│ │ │ │ cidr: ... │ │ serviceIP │ │ image: ... │ │ │ │ +│ │ │ │ cidrs: ... │ │ serviceIP │ │ image: ... │ │ │ │ │ │ │ │ table: 100 │ │ table: 100 │ │ nodeSelect.│ │ │ │ │ │ │ │ priority │ │ state: ... │ │ │ │ │ │ │ │ │ └─────────────┘ └──────────────┘ └────────────┘ │ │ │ @@ -129,7 +136,7 @@ ip route add default via 192.168.1.1 dev eth1 table 100 │ │ │ │ │ │ │ │ │ ip rule show: │ │ │ │ │ │ 1000: from 10.96.1.50 lookup 100 │ │ │ -│ │ │ 1000: from 10.96.1.51 lookup 200 │ │ │ +│ │ │ 1000: from fd00::1 lookup 100 │ │ │ │ │ │ ... │ │ │ │ │ └─────────────────────────────────────────────────────┘ │ │ │ └────────────────────────────────────────────────────────────┘ │ @@ -147,7 +154,7 @@ ip route add default via 192.168.1.1 dev eth1 table 100 │ │ │ 1. Create IPRule │ ├───────────────────────────────>│ - │ (cidr: 192.168.1.0/24) │ + │ (cidrs: [192.168.1.0/24]) │ │ (table: 100, priority: 1000)│ │ │ │ 2. Create Service (LB) │ @@ -488,7 +495,8 @@ kind: IPRule metadata: name: datacenter-a-routing spec: - cidr: "192.168.1.0/24" + cidrs: + - "192.168.1.0/24" table: 100 priority: 1000 ``` @@ -499,7 +507,7 @@ kubectl apply -f iprule-example.yaml **Effect**: All services with LoadBalancer IPs in this range will automatically be configured with IP rules using routing table 100. -### Example 2: Multi-Datacenter Setup +### Example 2: Multi-Datacenter Setup (Dual-Stack) ```yaml --- @@ -508,7 +516,9 @@ kind: IPRule metadata: name: datacenter-a spec: - cidr: "10.0.0.0/16" + cidrs: + - "10.0.0.0/16" + - "fd00:10::/64" table: 100 priority: 1000 --- @@ -517,7 +527,9 @@ kind: IPRule metadata: name: datacenter-b spec: - cidr: "10.1.0.0/16" + cidrs: + - "10.1.0.0/16" + - "fd00:20::/64" table: 200 priority: 1000 --- @@ -526,7 +538,8 @@ kind: IPRule metadata: name: datacenter-c-priority spec: - cidr: "10.2.0.0/16" + cidrs: + - "10.2.0.0/16" table: 300 priority: 2000 ``` @@ -545,9 +558,11 @@ echo "200 datacenter_b" >> /etc/iproute2/rt_tables # Configure routes in table 100 ip route add default via 192.168.1.1 dev eth1 table 100 +ip -6 route add default via fd00::1 dev eth1 table 100 # Configure routes in table 200 ip route add default via 192.168.2.1 dev eth2 table 200 +ip -6 route add default via fd00::2 dev eth2 table 200 # Ensure persistence with NetworkManager or systemd-networkd ``` @@ -573,6 +588,7 @@ kubectl logs -n ip-rule-operator-system deployment/ip-rule-operator-controller-m # Check IP rules on a node kubectl debug node/ -it --image=nicolaka/netshoot ip rule show +ip -6 rule show ``` ## 🔧 Development @@ -718,4 +734,3 @@ For questions or issues: --- **⭐ If you like this project, give it a star on GitHub!** -