From 52bb21fc2e81a2dea416edf505af7a64f60cb683 Mon Sep 17 00:00:00 2001 From: Oliver Frommel Date: Wed, 16 Sep 2026 17:50:30 +0200 Subject: [PATCH] Add IPv6 link-local support to Interface Allow an Interface to carry global unicast IPv6 addresses, or to use only its automatically generated link-local address via the new spec.ipv6.useLinkLocalOnly field. The latter is what unnumbered, interface-based BGP peering runs over. The two are mutually exclusive, enforced by CEL rules and by the webhook, which also rejects link-local addresses in spec.ipv6.addresses and points at useLinkLocalOnly instead. On NX-OS, useLinkLocalOnly maps to the useLinkLocalAddr property of the IPv6 interface object. The property is always sent so that turning it off is reconciled and the payload matches what the device reports back. Unlike IPv4, IPv6 has no primary/secondary distinction: every address is sent as primary, which is what the device accepts and reports. The openconfig provider configures global unicast addresses and rejects useLinkLocalOnly as unsupported. Signed-off-by: Oliver Frommel --- Tiltfile | 2 + api/core/v1alpha1/interface_types.go | 32 +++++++ api/core/v1alpha1/zz_generated.deepcopy.go | 27 ++++++ ...erfaces.networking.metal.ironcore.dev.yaml | 31 +++++++ ...working.metal.ironcore.dev_interfaces.yaml | 31 +++++++ config/samples/v1alpha1_interface.yaml | 41 +++++++++ docs/api-reference/index.md | 19 +++++ hack/provider/main.go | 15 ++++ .../controller/core/interface_controller.go | 22 +++++ internal/provider/cisco/nxos/intf.go | 9 +- internal/provider/cisco/nxos/intf_test.go | 8 ++ internal/provider/cisco/nxos/provider.go | 50 ++++++++++- .../cisco/nxos/testdata/intf_addr6.json | 37 ++++++++ .../cisco/nxos/testdata/intf_addr6.json.txt | 3 + .../cisco/nxos/testdata/intf_lladdr6.json | 21 +++++ .../cisco/nxos/testdata/intf_lladdr6.json.txt | 3 + internal/provider/openconfig/interface.go | 85 ++++++++++++++++++- .../provider/openconfig/interface_test.go | 27 ++++++ internal/provider/provider.go | 15 ++++ .../core/v1alpha1/interface_webhook.go | 27 ++++++ .../core/v1alpha1/interface_webhook_test.go | 30 +++++++ .../interface_loopback_multi_addr.txtar | 53 ++++++++++++ 22 files changed, 582 insertions(+), 6 deletions(-) create mode 100644 internal/provider/cisco/nxos/testdata/intf_addr6.json create mode 100644 internal/provider/cisco/nxos/testdata/intf_addr6.json.txt create mode 100644 internal/provider/cisco/nxos/testdata/intf_lladdr6.json create mode 100644 internal/provider/cisco/nxos/testdata/intf_lladdr6.json.txt diff --git a/Tiltfile b/Tiltfile index ad9857573..f3662dbc2 100644 --- a/Tiltfile +++ b/Tiltfile @@ -83,6 +83,8 @@ k8s_resource(new_name='po10', objects=['po-10:interface'], trigger_mode=TRIGGER_ k8s_resource(new_name='eth1-3', objects=['eth1-3:interface'], trigger_mode=TRIGGER_MODE_MANUAL, auto_init=False, labels=['samples']) k8s_resource(new_name='po20', objects=['po-20:interface'], resource_deps=['eth1-3'], trigger_mode=TRIGGER_MODE_MANUAL, auto_init=False, labels=['samples']) k8s_resource(new_name='svi-10', objects=['svi-10:interface'], resource_deps=['vlan-10'], trigger_mode=TRIGGER_MODE_MANUAL, auto_init=False, labels=['samples']) +k8s_resource(new_name='lo2', objects=['lo2:interface'], trigger_mode=TRIGGER_MODE_MANUAL, auto_init=False, labels=['samples']) +k8s_resource(new_name='eth1-4', objects=['eth1-4:interface'], trigger_mode=TRIGGER_MODE_MANUAL, auto_init=False, labels=['samples']) k8s_yaml('./config/samples/v1alpha1_banner.yaml') k8s_resource(new_name='banner', objects=['banner:banner'], trigger_mode=TRIGGER_MODE_MANUAL, auto_init=False, labels=['samples']) diff --git a/api/core/v1alpha1/interface_types.go b/api/core/v1alpha1/interface_types.go index 4560c50a5..8b48a6332 100644 --- a/api/core/v1alpha1/interface_types.go +++ b/api/core/v1alpha1/interface_types.go @@ -13,6 +13,7 @@ import ( // InterfaceSpec defines the desired state of Interface. // +kubebuilder:validation:XValidation:rule="!has(self.switchport) || !has(self.ipv4)", message="switchport and ipv4 are mutually exclusive" +// +kubebuilder:validation:XValidation:rule="!has(self.switchport) || !has(self.ipv6)", message="switchport and ipv6 are mutually exclusive" // +kubebuilder:validation:XValidation:rule="self.type != 'Loopback' || !has(self.switchport)", message="switchport must not be specified for interfaces of type Loopback" // +kubebuilder:validation:XValidation:rule="self.type == 'Physical' || !has(self.ipv4) || !has(self.ipv4.unnumbered)", message="unnumbered ipv4 configuration can only be used for interfaces of type Physical" // +kubebuilder:validation:XValidation:rule="self.type != 'Aggregate' || has(self.aggregation)", message="aggregation must be specified for interfaces of type Aggregate" @@ -80,6 +81,10 @@ type InterfaceSpec struct { // +optional IPv4 *InterfaceIPv4 `json:"ipv4,omitempty"` + // IPv6 defines the IPv6 configuration for the interface. + // +optional + IPv6 *InterfaceIPv6 `json:"ipv6,omitempty"` + // Aggregation defines the aggregation (bundle) configuration for the interface. // This is only applicable for interfaces of type Aggregate. // +optional @@ -289,6 +294,28 @@ type InterfaceIPv4Unnumbered struct { InterfaceRef LocalObjectReference `json:"interfaceRef"` } +// InterfaceIPv6 defines the IPv6 configuration for an interface. +// +kubebuilder:validation:XValidation:rule="has(self.addresses) || (has(self.useLinkLocalOnly) && self.useLinkLocalOnly)", message="either addresses or useLinkLocalOnly must be specified" +// +kubebuilder:validation:XValidation:rule="!has(self.addresses) || !has(self.useLinkLocalOnly) || !self.useLinkLocalOnly", message="addresses and useLinkLocalOnly are mutually exclusive" +type InterfaceIPv6 struct { + // Addresses defines the list of global unicast IPv6 addresses assigned to + // the interface. Unlike IPv4, all addresses are equal, there is no primary + // or secondary distinction. + // Link-local addresses cannot be assigned here, they are configured through + // UseLinkLocalOnly. + // +optional + // +listType=atomic + // +kubebuilder:validation:MinItems=1 + Addresses []IPPrefix `json:"addresses,omitempty"` + + // UseLinkLocalOnly configures the interface to operate with only its + // automatically generated IPv6 link-local address, without assigning a + // global address. This is what unnumbered, interface-based BGP peering + // requires in order to discover neighbours over their link-local address. + // +optional + UseLinkLocalOnly bool `json:"useLinkLocalOnly,omitempty"` +} + // BFD defines the Bidirectional Forwarding Detection configuration for an interface. type BFD struct { // Enabled indicates whether BFD is enabled on the interface. @@ -600,6 +627,11 @@ func (in *Interface) HasIPv4() bool { return in.Spec.Switchport == nil && in.Spec.IPv4 != nil } +// HasIPv6 reports whether the Interface is configured as a routed IPv6 interface. +func (in *Interface) HasIPv6() bool { + return in.Spec.Switchport == nil && in.Spec.IPv6 != nil +} + // GetConditions implements conditions.Getter. func (in *Interface) GetConditions() []metav1.Condition { return in.Status.Conditions diff --git a/api/core/v1alpha1/zz_generated.deepcopy.go b/api/core/v1alpha1/zz_generated.deepcopy.go index 5e0a83e3a..7ceabafe5 100644 --- a/api/core/v1alpha1/zz_generated.deepcopy.go +++ b/api/core/v1alpha1/zz_generated.deepcopy.go @@ -2490,6 +2490,28 @@ func (in *InterfaceIPv4Unnumbered) DeepCopy() *InterfaceIPv4Unnumbered { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *InterfaceIPv6) DeepCopyInto(out *InterfaceIPv6) { + *out = *in + if in.Addresses != nil { + in, out := &in.Addresses, &out.Addresses + *out = make([]IPPrefix, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new InterfaceIPv6. +func (in *InterfaceIPv6) DeepCopy() *InterfaceIPv6 { + if in == nil { + return nil + } + out := new(InterfaceIPv6) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *InterfaceList) DeepCopyInto(out *InterfaceList) { *out = *in @@ -2561,6 +2583,11 @@ func (in *InterfaceSpec) DeepCopyInto(out *InterfaceSpec) { *out = new(InterfaceIPv4) (*in).DeepCopyInto(*out) } + if in.IPv6 != nil { + in, out := &in.IPv6, &out.IPv6 + *out = new(InterfaceIPv6) + (*in).DeepCopyInto(*out) + } if in.Aggregation != nil { in, out := &in.Aggregation, &out.Aggregation *out = new(Aggregation) diff --git a/charts/network-operator/templates/crd/interfaces.networking.metal.ironcore.dev.yaml b/charts/network-operator/templates/crd/interfaces.networking.metal.ironcore.dev.yaml index 3469fc02a..8170a14a8 100644 --- a/charts/network-operator/templates/crd/interfaces.networking.metal.ironcore.dev.yaml +++ b/charts/network-operator/templates/crd/interfaces.networking.metal.ironcore.dev.yaml @@ -332,6 +332,35 @@ spec: rule: '!has(self.addresses) || !has(self.unnumbered)' - message: anycastGateway and unnumbered are mutually exclusive rule: '!has(self.unnumbered) || !self.anycastGateway' + ipv6: + description: IPv6 defines the IPv6 configuration for the interface. + properties: + addresses: + description: |- + Addresses defines the list of global unicast IPv6 addresses assigned to + the interface. Unlike IPv4, all addresses are equal, there is no primary + or secondary distinction. + Link-local addresses cannot be assigned here, they are configured through + UseLinkLocalOnly. + items: + format: cidr + type: string + minItems: 1 + type: array + x-kubernetes-list-type: atomic + useLinkLocalOnly: + description: |- + UseLinkLocalOnly configures the interface to operate with only its + automatically generated IPv6 link-local address, without assigning a + global address. This is what unnumbered, interface-based BGP peering + requires in order to discover neighbours over their link-local address. + type: boolean + type: object + x-kubernetes-validations: + - message: either addresses or useLinkLocalOnly must be specified + rule: has(self.addresses) || (has(self.useLinkLocalOnly) && self.useLinkLocalOnly) + - message: addresses and useLinkLocalOnly are mutually exclusive + rule: '!has(self.addresses) || !has(self.useLinkLocalOnly) || !self.useLinkLocalOnly' mtu: description: MTU (Maximum Transmission Unit) specifies the size of the largest packet that can be sent over the interface. @@ -515,6 +544,8 @@ spec: x-kubernetes-validations: - message: switchport and ipv4 are mutually exclusive rule: '!has(self.switchport) || !has(self.ipv4)' + - message: switchport and ipv6 are mutually exclusive + rule: '!has(self.switchport) || !has(self.ipv6)' - message: switchport must not be specified for interfaces of type Loopback rule: self.type != 'Loopback' || !has(self.switchport) - message: unnumbered ipv4 configuration can only be used for interfaces diff --git a/config/crd/bases/networking.metal.ironcore.dev_interfaces.yaml b/config/crd/bases/networking.metal.ironcore.dev_interfaces.yaml index 8fc99eb8d..d760f983e 100644 --- a/config/crd/bases/networking.metal.ironcore.dev_interfaces.yaml +++ b/config/crd/bases/networking.metal.ironcore.dev_interfaces.yaml @@ -329,6 +329,35 @@ spec: rule: '!has(self.addresses) || !has(self.unnumbered)' - message: anycastGateway and unnumbered are mutually exclusive rule: '!has(self.unnumbered) || !self.anycastGateway' + ipv6: + description: IPv6 defines the IPv6 configuration for the interface. + properties: + addresses: + description: |- + Addresses defines the list of global unicast IPv6 addresses assigned to + the interface. Unlike IPv4, all addresses are equal, there is no primary + or secondary distinction. + Link-local addresses cannot be assigned here, they are configured through + UseLinkLocalOnly. + items: + format: cidr + type: string + minItems: 1 + type: array + x-kubernetes-list-type: atomic + useLinkLocalOnly: + description: |- + UseLinkLocalOnly configures the interface to operate with only its + automatically generated IPv6 link-local address, without assigning a + global address. This is what unnumbered, interface-based BGP peering + requires in order to discover neighbours over their link-local address. + type: boolean + type: object + x-kubernetes-validations: + - message: either addresses or useLinkLocalOnly must be specified + rule: has(self.addresses) || (has(self.useLinkLocalOnly) && self.useLinkLocalOnly) + - message: addresses and useLinkLocalOnly are mutually exclusive + rule: '!has(self.addresses) || !has(self.useLinkLocalOnly) || !self.useLinkLocalOnly' mtu: description: MTU (Maximum Transmission Unit) specifies the size of the largest packet that can be sent over the interface. @@ -512,6 +541,8 @@ spec: x-kubernetes-validations: - message: switchport and ipv4 are mutually exclusive rule: '!has(self.switchport) || !has(self.ipv4)' + - message: switchport and ipv6 are mutually exclusive + rule: '!has(self.switchport) || !has(self.ipv6)' - message: switchport must not be specified for interfaces of type Loopback rule: self.type != 'Loopback' || !has(self.switchport) - message: unnumbered ipv4 configuration can only be used for interfaces diff --git a/config/samples/v1alpha1_interface.yaml b/config/samples/v1alpha1_interface.yaml index 51fc040b9..e6fb8bf26 100644 --- a/config/samples/v1alpha1_interface.yaml +++ b/config/samples/v1alpha1_interface.yaml @@ -239,3 +239,44 @@ spec: addresses: - 192.168.10.254/24 anycastGateway: true +--- +apiVersion: networking.metal.ironcore.dev/v1alpha1 +kind: Interface +metadata: + labels: + app.kubernetes.io/name: network-operator + app.kubernetes.io/managed-by: kustomize + networking.metal.ironcore.dev/device-name: leaf1 + name: lo2 +spec: + deviceRef: + name: leaf1 + name: lo2 + description: IPv6 Router-ID Leaf1 + adminState: Up + type: Loopback + mtu: 1500 + ipv6: + addresses: + - 2001:db8::10/128 +--- +apiVersion: networking.metal.ironcore.dev/v1alpha1 +kind: Interface +metadata: + labels: + app.kubernetes.io/name: network-operator + app.kubernetes.io/managed-by: kustomize + networking.metal.ironcore.dev/device-name: leaf1 + name: eth1-4 +spec: + deviceRef: + name: leaf1 + name: eth1/4 + description: Unnumbered uplink for interface-based BGP peering + adminState: Up + type: Physical + mtu: 9216 + # Use only the automatically generated IPv6 link-local address, so the link + # needs no addressing of its own. Mutually exclusive with ipv6.addresses. + ipv6: + useLinkLocalOnly: true diff --git a/docs/api-reference/index.md b/docs/api-reference/index.md index f55f0b4e8..f89990a8f 100644 --- a/docs/api-reference/index.md +++ b/docs/api-reference/index.md @@ -2259,6 +2259,7 @@ _Appears in:_ - [IPPrefixPoolSpec](#ipprefixpoolspec) - [IPPrefixSpec](#ipprefixspec) - [InterfaceIPv4](#interfaceipv4) +- [InterfaceIPv6](#interfaceipv6) - [MulticastGroups](#multicastgroups) - [PrefixEntry](#prefixentry) - [RendezvousPoint](#rendezvouspoint) @@ -2434,6 +2435,23 @@ _Appears in:_ | `interfaceRef` _[LocalObjectReference](#localobjectreference)_ | InterfaceRef is a reference to the interface from which to borrow the IP address.
The referenced interface must exist and have at least one IPv4 address configured. | | Required: \{\}
| +#### InterfaceIPv6 + + + +InterfaceIPv6 defines the IPv6 configuration for an interface. + + + +_Appears in:_ +- [InterfaceSpec](#interfacespec) + +| Field | Description | Default | Validation | +| --- | --- | --- | --- | +| `addresses` _[IPPrefix](#ipprefix) array_ | Addresses defines the list of global unicast IPv6 addresses assigned to
the interface. Unlike IPv4, all addresses are equal, there is no primary
or secondary distinction.
Link-local addresses cannot be assigned here, they are configured through
UseLinkLocalOnly. | | Format: cidr
MinItems: 1
Type: string
Optional: \{\}
| +| `useLinkLocalOnly` _boolean_ | UseLinkLocalOnly configures the interface to operate with only its
automatically generated IPv6 link-local address, without assigning a
global address. This is what unnumbered, interface-based BGP peering
requires in order to discover neighbours over their link-local address. | | Optional: \{\}
| + + #### InterfaceSource @@ -2474,6 +2492,7 @@ _Appears in:_ | `mtu` _integer_ | MTU (Maximum Transmission Unit) specifies the size of the largest packet that can be sent over the interface. | | Maximum: 9216
Minimum: 576
Optional: \{\}
| | `switchport` _[Switchport](#switchport)_ | Switchport defines the switchport configuration for the interface.
This is only applicable for Ethernet and Aggregate interfaces. | | Optional: \{\}
| | `ipv4` _[InterfaceIPv4](#interfaceipv4)_ | IPv4 defines the IPv4 configuration for the interface. | | Optional: \{\}
| +| `ipv6` _[InterfaceIPv6](#interfaceipv6)_ | IPv6 defines the IPv6 configuration for the interface. | | Optional: \{\}
| | `aggregation` _[Aggregation](#aggregation)_ | Aggregation defines the aggregation (bundle) configuration for the interface.
This is only applicable for interfaces of type Aggregate. | | Optional: \{\}
| | `vlanRef` _[LocalObjectReference](#localobjectreference)_ | VlanRef is a reference to the VLAN resource that this interface provides routing for.
This is only applicable for interfaces of type RoutedVLAN.
The referenced VLAN must exist in the same namespace. | | Optional: \{\}
| | `vrfRef` _[LocalObjectReference](#localobjectreference)_ | VrfRef is a reference to the VRF resource that this interface belongs to.
If not specified, the interface will be part of the default VRF.
This is only applicable for Layer 3 interfaces.
The referenced VRF must exist in the same namespace. | | Optional: \{\}
| diff --git a/hack/provider/main.go b/hack/provider/main.go index 401d6111f..097e48c4e 100644 --- a/hack/provider/main.go +++ b/hack/provider/main.go @@ -662,6 +662,20 @@ func performCreate(ctx context.Context, prov provider.Provider, obj client.Objec } } + var ipv6 provider.IPv6 + if res.Spec.IPv6 != nil { + switch { + case res.Spec.IPv6.UseLinkLocalOnly: + ipv6 = provider.IPv6LinkLocalOnly{} + case len(res.Spec.IPv6.Addresses) > 0: + addrs := make([]netip.Prefix, len(res.Spec.IPv6.Addresses)) + for i, addr := range res.Spec.IPv6.Addresses { + addrs[i] = addr.Prefix + } + ipv6 = provider.IPv6AddressList(addrs) + } + } + var members []*v1alpha1.Interface if res.Spec.Type == v1alpha1.InterfaceTypeAggregate && len(res.Spec.Aggregation.MemberInterfaceRefs) > 0 { if len(refStore) == 0 { @@ -697,6 +711,7 @@ func performCreate(ctx context.Context, prov provider.Provider, obj client.Objec return ip.EnsureInterface(ctx, &provider.EnsureInterfaceRequest{ Interface: res, IPv4: ipv4, + IPv6: ipv6, Members: members, MultiChassisID: multiChassisID, VLAN: vlan, diff --git a/internal/controller/core/interface_controller.go b/internal/controller/core/interface_controller.go index 8123f5f9b..306c12db5 100644 --- a/internal/controller/core/interface_controller.go +++ b/internal/controller/core/interface_controller.go @@ -369,6 +369,7 @@ func (r *InterfaceReconciler) SetupWithManager(ctx context.Context, mgr ctrl.Man // Only trigger when fields that affect member Physical interface // reconciliation change (e.g. layer, VRF membership, MTU). return !equality.Semantic.DeepEqual(oldIntf.Spec.IPv4, newIntf.Spec.IPv4) || + !equality.Semantic.DeepEqual(oldIntf.Spec.IPv6, newIntf.Spec.IPv6) || !equality.Semantic.DeepEqual(oldIntf.Spec.Switchport, newIntf.Spec.Switchport) || !equality.Semantic.DeepEqual(oldIntf.Spec.VrfRef, newIntf.Spec.VrfRef) || oldIntf.Spec.MTU != newIntf.Spec.MTU @@ -569,6 +570,8 @@ func (r *InterfaceReconciler) reconcile(ctx context.Context, s *scope) (reterr e } } + ipv6 := interfaceIPv6(s.Interface.Spec.IPv6) + if err := s.Provider.Connect(ctx, s.Connection); err != nil { return fmt.Errorf("failed to connect to provider: %w", err) } @@ -583,6 +586,7 @@ func (r *InterfaceReconciler) reconcile(ctx context.Context, s *scope) (reterr e Interface: s.Interface, ProviderConfig: s.ProviderConfig, IPv4: ip, + IPv6: ipv6, Members: members, MultiChassisID: multiChassisID, AggregateParent: aggregateParent, @@ -777,6 +781,24 @@ func (r *InterfaceReconciler) validateLLDPAdjacencyThroughAnnotation(ctx context return v1alpha1.NeighborVerified, nil } +// interfaceIPv6 maps the IPv6 spec of an Interface to the provider representation. +// It returns nil if the Interface has no IPv6 configuration. +func interfaceIPv6(spec *v1alpha1.InterfaceIPv6) provider.IPv6 { + switch { + case spec == nil: + return nil + case spec.UseLinkLocalOnly: + return provider.IPv6LinkLocalOnly{} + case len(spec.Addresses) > 0: + addrs := make([]netip.Prefix, len(spec.Addresses)) + for i, addr := range spec.Addresses { + addrs[i] = addr.Prefix + } + return provider.IPv6AddressList(addrs) + } + return nil +} + func (r *InterfaceReconciler) reconcileIPv4(ctx context.Context, s *scope) (provider.IPv4, error) { switch { case len(s.Interface.Spec.IPv4.Addresses) > 0: diff --git a/internal/provider/cisco/nxos/intf.go b/internal/provider/cisco/nxos/intf.go index ba223f47d..e152e9519 100644 --- a/internal/provider/cisco/nxos/intf.go +++ b/internal/provider/cisco/nxos/intf.go @@ -406,7 +406,14 @@ func (d *AddrDom) Key() string { return d.Name } type AddrItem struct { ID string `json:"id"` Unnumbered string `json:"unnumbered,omitempty"` - AddrItems struct { + + // UseLinkLocalAddr configures the interface to use only its automatically + // generated IPv6 link-local address ("ipv6 address use-link-local-only"). + // Only set for IPv6; the IPv4 object has no such property, so it must stay + // empty there to keep the payload free of it. + UseLinkLocalAddr AdminSt `json:"useLinkLocalAddr,omitempty"` + + AddrItems struct { AddrList gnmiext.List[string, *IntfAddr] `json:"Addr-list,omitzero"` } `json:"addr-items,omitzero"` diff --git a/internal/provider/cisco/nxos/intf_test.go b/internal/provider/cisco/nxos/intf_test.go index 5bb4bc806..dcfdf0506 100644 --- a/internal/provider/cisco/nxos/intf_test.go +++ b/internal/provider/cisco/nxos/intf_test.go @@ -137,6 +137,14 @@ func init() { }) Register("intf_addr4", intfAddr4) + intfAddr6 := &AddrItem{ID: "lo0", Vrf: DefaultVRFName, Is6: true, UseLinkLocalAddr: AdminStDisabled} + intfAddr6.AddrItems.AddrList.Set(&IntfAddr{Addr: "2001:db8:1::1/64", Type: IntfAddrTypePrimary}) + intfAddr6.AddrItems.AddrList.Set(&IntfAddr{Addr: "2001:db8:2::1/64", Type: IntfAddrTypePrimary}) + Register("intf_addr6", intfAddr6) + + // "ipv6 address use-link-local-only", as required for unnumbered peering. + Register("intf_lladdr6", &AddrItem{ID: "eth1/1", Vrf: DefaultVRFName, Is6: true, UseLinkLocalAddr: AdminStEnabled}) + pc := &PortChannel{ AccessVlan: DefaultVLAN, AdminSt: AdminStUp, diff --git a/internal/provider/cisco/nxos/provider.go b/internal/provider/cisco/nxos/provider.go index 8e1a49a42..451898e88 100644 --- a/internal/provider/cisco/nxos/provider.go +++ b/internal/provider/cisco/nxos/provider.go @@ -1276,6 +1276,28 @@ func (p *Provider) EnsureInterface(ctx context.Context, req *provider.EnsureInte } } } + var ipv6Addr *AddrItem + if req.IPv6 != nil { + ipv6Addr = new(AddrItem) + ipv6Addr.ID = name + ipv6Addr.Vrf = vrf + ipv6Addr.Is6 = true + // Always sent so that turning link-local-only off is reconciled, and so + // the payload matches what the device reports back. + ipv6Addr.UseLinkLocalAddr = AdminStDisabled + + switch v := req.IPv6.(type) { + case provider.IPv6AddressList: + // Unlike IPv4, IPv6 has no primary/secondary distinction: an + // interface simply carries multiple equal addresses. + for _, p := range v { + ipv6Addr.AddrItems.AddrList.Set(&IntfAddr{Addr: p.String(), Type: IntfAddrTypePrimary}) + } + + case provider.IPv6LinkLocalOnly: + ipv6Addr.UseLinkLocalAddr = AdminStEnabled + } + } addrs := new(AddrList) if err := p.client.GetConfig(ctx, addrs); err != nil && !errors.Is(err, gnmiext.ErrNil) { @@ -1286,6 +1308,15 @@ func (p *Provider) EnsureInterface(ctx context.Context, req *provider.EnsureInte sb.Delete(a) } } + ipv6Addrs := &AddrList{Is6: true} + if err := p.client.GetConfig(ctx, ipv6Addrs); err != nil && !errors.Is(err, gnmiext.ErrNil) { + return err + } + for _, a := range ipv6Addrs.GetAddrItemsByInterface(name) { + if ipv6Addr == nil || a.Vrf != vrf { + sb.Delete(a) + } + } switch req.Interface.Spec.Type { case v1alpha1.InterfaceTypePhysical: @@ -1323,7 +1354,7 @@ func (p *Provider) EnsureInterface(ctx context.Context, req *provider.EnsureInte // If this Physical interface is a member of an L3 Aggregate (port-channel), // it must be Layer3 on NX-OS even though it has no IP address of its own. - if req.IPv4 != nil || (req.AggregateParent != nil && req.AggregateParent.Spec.IPv4 != nil) { + if req.IPv4 != nil || req.IPv6 != nil || (req.AggregateParent != nil && (req.AggregateParent.Spec.IPv4 != nil || req.AggregateParent.Spec.IPv6 != nil)) { p.Layer = Layer3 p.RtvrfMbrItems = NewVrfMember(name, vrf) p.AccessVlan = string(AdjOperStUnknown) @@ -1420,7 +1451,7 @@ func (p *Provider) EnsureInterface(ctx context.Context, req *provider.EnsureInte pc.UserCfgdFlags |= UserFlagAdminMTU } - if req.IPv4 != nil { + if req.IPv4 != nil || req.IPv6 != nil { pc.Layer = Layer3 pc.RtvrfMbrItems = NewVrfMember(name, vrf) pc.AccessVlan = "unknown" @@ -1577,7 +1608,7 @@ func (p *Provider) EnsureInterface(ctx context.Context, req *provider.EnsureInte } s.Encap = encap - if req.IPv4 != nil { + if req.IPv4 != nil || req.IPv6 != nil { s.RtvrfMbrItems = NewVrfMember(name, vrf) } @@ -1595,7 +1626,7 @@ func (p *Provider) EnsureInterface(ctx context.Context, req *provider.EnsureInte }) } - if (req.Interface.Spec.Type == v1alpha1.InterfaceTypePhysical || req.Interface.Spec.Type == v1alpha1.InterfaceTypeAggregate) && req.IPv4 == nil && (req.AggregateParent == nil || req.AggregateParent.Spec.IPv4 == nil) { + if (req.Interface.Spec.Type == v1alpha1.InterfaceTypePhysical || req.Interface.Spec.Type == v1alpha1.InterfaceTypeAggregate) && req.IPv4 == nil && req.IPv6 == nil && (req.AggregateParent == nil || (req.AggregateParent.Spec.IPv4 == nil && req.AggregateParent.Spec.IPv6 == nil)) { stp := new(SpanningTree) stp.IfName = name stp.Mode = SpanningTreeModeDefault @@ -1632,6 +1663,9 @@ func (p *Provider) EnsureInterface(ctx context.Context, req *provider.EnsureInte if addr != nil { sb.Patch(addr) } + if ipv6Addr != nil { + sb.Patch(ipv6Addr) + } switch { case req.Interface.Spec.BFD != nil && req.Interface.Spec.BFD.Enabled: @@ -1721,6 +1755,14 @@ func (p *Provider) DeleteInterface(ctx context.Context, req *provider.InterfaceR sb.Delete(addr) } + ipv6Addrs := &AddrList{Is6: true} + if err := p.client.GetConfig(ctx, ipv6Addrs); err != nil && !errors.Is(err, gnmiext.ErrNil) { + return err + } + for _, addr := range ipv6Addrs.GetAddrItemsByInterface(name) { + sb.Delete(addr) + } + bfd := new(BFD) bfd.ID = name sb.Delete(bfd) diff --git a/internal/provider/cisco/nxos/testdata/intf_addr6.json b/internal/provider/cisco/nxos/testdata/intf_addr6.json new file mode 100644 index 000000000..7d099255b --- /dev/null +++ b/internal/provider/cisco/nxos/testdata/intf_addr6.json @@ -0,0 +1,37 @@ +{ + "ipv6-items": { + "inst-items": { + "dom-items": { + "Dom-list": [ + { + "name": "default", + "if-items": { + "If-list": [ + { + "id": "lo0", + "useLinkLocalAddr": "disabled", + "addr-items": { + "Addr-list": [ + { + "addr": "2001:db8:1::1/64", + "pref": 0, + "tag": 0, + "type": "primary" + }, + { + "addr": "2001:db8:2::1/64", + "pref": 0, + "tag": 0, + "type": "primary" + } + ] + } + } + ] + } + } + ] + } + } + } +} diff --git a/internal/provider/cisco/nxos/testdata/intf_addr6.json.txt b/internal/provider/cisco/nxos/testdata/intf_addr6.json.txt new file mode 100644 index 000000000..96ae0b5e4 --- /dev/null +++ b/internal/provider/cisco/nxos/testdata/intf_addr6.json.txt @@ -0,0 +1,3 @@ +interface Loopback0 + ipv6 address 2001:db8:1::1/64 + ipv6 address 2001:db8:2::1/64 diff --git a/internal/provider/cisco/nxos/testdata/intf_lladdr6.json b/internal/provider/cisco/nxos/testdata/intf_lladdr6.json new file mode 100644 index 000000000..1cbd30c16 --- /dev/null +++ b/internal/provider/cisco/nxos/testdata/intf_lladdr6.json @@ -0,0 +1,21 @@ +{ + "ipv6-items": { + "inst-items": { + "dom-items": { + "Dom-list": [ + { + "name": "default", + "if-items": { + "If-list": [ + { + "id": "eth1/1", + "useLinkLocalAddr": "enabled" + } + ] + } + } + ] + } + } + } +} diff --git a/internal/provider/cisco/nxos/testdata/intf_lladdr6.json.txt b/internal/provider/cisco/nxos/testdata/intf_lladdr6.json.txt new file mode 100644 index 000000000..990d45124 --- /dev/null +++ b/internal/provider/cisco/nxos/testdata/intf_lladdr6.json.txt @@ -0,0 +1,3 @@ +interface Ethernet1/1 + no switchport + ipv6 address use-link-local-only diff --git a/internal/provider/openconfig/interface.go b/internal/provider/openconfig/interface.go index f57c42bcf..f4192e88a 100644 --- a/internal/provider/openconfig/interface.go +++ b/internal/provider/openconfig/interface.go @@ -19,6 +19,13 @@ var _ provider.InterfaceProvider = (*Provider)(nil) func (p *Provider) EnsureInterface(ctx context.Context, req *provider.EnsureInterfaceRequest) error { spec := req.Interface.Spec + if _, ok := req.IPv6.(provider.IPv6LinkLocalOnly); ok { + return apistatus.NewUnsupportedFieldError(apistatus.FieldViolation{ + Field: "spec.ipv6.useLinkLocalOnly", + Description: "openconfig provider does not support link-local-only IPv6 interfaces", + }) + } + i := &Interface{ Name: spec.Name, Config: &InterfaceConfig{ @@ -113,7 +120,7 @@ func (p *Provider) EnsureInterface(ctx context.Context, req *provider.EnsureInte } } - if req.IPv4 != nil { + if req.IPv4 != nil || req.IPv6 != nil { sub := &Subinterface{ Index: 0, Config: &SubinterfaceConfig{Index: 0, Enabled: true}, @@ -155,6 +162,8 @@ func (p *Provider) EnsureInterface(ctx context.Context, req *provider.EnsureInte } } + sub.IPv6 = newInterfaceIPv6(req.IPv6) + subs := &Subinterfaces{} subs.Subinterface.Set(sub) i.Subinterfaces = subs @@ -313,9 +322,38 @@ func (p *Provider) EnsureSubinterface(ctx context.Context, req *provider.EnsureI } } + sub.IPv6 = newInterfaceIPv6(req.IPv6) + return p.client.Update(ctx, sub) } +// newInterfaceIPv6 builds the IPv6 container of a subinterface, or nil if no +// IPv6 addresses were requested. Link-local-only is rejected by EnsureInterface +// before this is reached. +func newInterfaceIPv6(ipv6 provider.IPv6) *InterfaceIPv6 { + v, ok := ipv6.(provider.IPv6AddressList) + if !ok { + return nil + } + + addrs := &IPv6Addresses{} + for _, prefix := range v { + ip := prefix.Addr().String() + addrs.Address.Set(&IPv6Address{ + IP: ip, + Config: &IPv6AddressConfig{ + IP: ip, + PrefixLength: uint8(prefix.Bits()), //nolint:gosec + Type: IPv6AddressTypeGlobalUnicast, + }, + }) + } + return &InterfaceIPv6{ + Config: &InterfaceIPv6Config{Enabled: true}, + Addresses: addrs, + } +} + // InterfaceType represents the YANG identity for the interface type. type InterfaceType string @@ -476,6 +514,7 @@ type Subinterface struct { Index uint32 `json:"index"` Config *SubinterfaceConfig `json:"config,omitempty"` IPv4 *InterfaceIPv4 `json:"openconfig-if-ip:ipv4,omitempty"` + IPv6 *InterfaceIPv6 `json:"openconfig-if-ip:ipv6,omitempty"` } func (s *Subinterface) Key() uint32 { @@ -500,6 +539,49 @@ type InterfaceIPv4Config struct { Enabled bool `json:"enabled"` } +// InterfaceIPv6 holds the IPv6 container of a subinterface. +type InterfaceIPv6 struct { + Addresses *IPv6Addresses `json:"addresses"` + Config *InterfaceIPv6Config `json:"config"` +} + +// InterfaceIPv6Config holds the config container for IPv6. +type InterfaceIPv6Config struct { + Enabled bool `json:"enabled"` +} + +// IPv6Addresses holds the IPv6 address list container. +type IPv6Addresses struct { + Address gnmiext.List[string, *IPv6Address] `json:"address,omitempty"` +} + +// IPv6Address represents a single IPv6 address entry. +type IPv6Address struct { + IP string `json:"ip"` + Config *IPv6AddressConfig `json:"config,omitempty"` +} + +func (a *IPv6Address) Key() string { + return a.IP +} + +// IPv6AddressType represents the type of an IPv6 address. Unlike IPv4, IPv6 has +// no primary/secondary addresses. Only global unicast is ever configured here: +// link-local addresses are rejected by the webhook in favour of +// spec.ipv6.useLinkLocalOnly, which this provider does not support. +type IPv6AddressType string + +const ( + IPv6AddressTypeGlobalUnicast IPv6AddressType = "GLOBAL_UNICAST" +) + +// IPv6AddressConfig holds the config for a single IPv6 address. +type IPv6AddressConfig struct { + IP string `json:"ip"` + PrefixLength uint8 `json:"prefix-length"` + Type IPv6AddressType `json:"type,omitempty"` +} + // IPv4Addresses holds the IPv4 address list container. type IPv4Addresses struct { Address gnmiext.List[string, *IPv4Address] `json:"address,omitempty"` @@ -626,6 +708,7 @@ type SubinterfaceEntry struct { Index uint32 `json:"index"` Config *SubinterfaceConfig `json:"config,omitempty"` IPv4 *InterfaceIPv4 `json:"openconfig-if-ip:ipv4,omitempty"` + IPv6 *InterfaceIPv6 `json:"openconfig-if-ip:ipv6,omitempty"` Vlan *SubinterfaceVlan `json:"openconfig-vlan:vlan,omitempty"` } diff --git a/internal/provider/openconfig/interface_test.go b/internal/provider/openconfig/interface_test.go index 6711bd486..2dd4f365a 100644 --- a/internal/provider/openconfig/interface_test.go +++ b/internal/provider/openconfig/interface_test.go @@ -35,6 +35,33 @@ func TestOpenConfigTrunkVlansJSON(t *testing.T) { } } +func TestOpenConfigIPv6AddressJSON(t *testing.T) { + intf := &Subinterface{ + Index: 0, + IPv6: &InterfaceIPv6{ + Config: &InterfaceIPv6Config{Enabled: true}, + Addresses: &IPv6Addresses{}, + }, + } + intf.IPv6.Addresses.Address.Set(&IPv6Address{ + IP: "2001:db8::1", + Config: &IPv6AddressConfig{ + IP: "2001:db8::1", + PrefixLength: 64, + Type: IPv6AddressTypeGlobalUnicast, + }, + }) + + got, err := json.Marshal(intf) + if err != nil { + t.Fatalf("json.Marshal() error = %v", err) + } + want := `{"index":0,"openconfig-if-ip:ipv6":{"addresses":{"address":[{"ip":"2001:db8::1","config":{"ip":"2001:db8::1","prefix-length":64,"type":"GLOBAL_UNICAST"}}]},"config":{"enabled":true}}}` + if string(got) != want { + t.Fatalf("json.Marshal() = %s, want %s", got, want) + } +} + func TestOpenConfigTrunkVlansXPath(t *testing.T) { tests := []struct { name string diff --git a/internal/provider/provider.go b/internal/provider/provider.go index 36e789f5a..64a11b3cd 100644 --- a/internal/provider/provider.go +++ b/internal/provider/provider.go @@ -184,6 +184,7 @@ type EnsureInterfaceRequest struct { Interface *v1alpha1.Interface ProviderConfig *ProviderConfig IPv4 IPv4 + IPv6 IPv6 // Members is the list of member interfaces for aggregated interfaces. // This field is only applicable if the interface type is Aggregate. @@ -222,6 +223,20 @@ type IPv4Unnumbered struct { func (IPv4Unnumbered) isIPv4() {} +type IPv6 interface { + isIPv6() +} + +type IPv6AddressList []netip.Prefix + +func (IPv6AddressList) isIPv6() {} + +// IPv6LinkLocalOnly configures the interface to use only its automatically +// generated IPv6 link-local address, without a global address. +type IPv6LinkLocalOnly struct{} + +func (IPv6LinkLocalOnly) isIPv6() {} + type InterfaceStatus struct { // OperStatus indicates whether the interface is operationally up (true) or down (false). OperStatus bool diff --git a/internal/webhook/core/v1alpha1/interface_webhook.go b/internal/webhook/core/v1alpha1/interface_webhook.go index b6bd989c1..769fba9c2 100644 --- a/internal/webhook/core/v1alpha1/interface_webhook.go +++ b/internal/webhook/core/v1alpha1/interface_webhook.go @@ -74,6 +74,12 @@ func validateInterfaceSpec(intf *v1alpha1.Interface) error { } } + if intf.Spec.IPv6 != nil { + if err := validateInterfaceIPv6(intf.Spec.IPv6); err != nil { + errAgg = append(errAgg, err) + } + } + if intf.Spec.Switchport != nil { if err := validateSwitchport(intf.Spec.Switchport); err != nil { errAgg = append(errAgg, err) @@ -153,3 +159,24 @@ func validateInterfaceIPv4(ip *v1alpha1.InterfaceIPv4) error { } return errors.Join(errAgg...) } + +// validateInterfaceIPv6 performs validation on the InterfaceIPv6 spec. +func validateInterfaceIPv6(ip *v1alpha1.InterfaceIPv6) error { + var errAgg []error + for i, cidr := range ip.Addresses { + if !cidr.Prefix.Addr().Is6() { + errAgg = append(errAgg, fmt.Errorf("invalid IPv6 address %q: address is IPv4", cidr.String())) + continue + } + if cidr.Prefix.Addr().IsLinkLocalUnicast() { + errAgg = append(errAgg, fmt.Errorf("invalid IPv6 address %q: link-local addresses cannot be assigned, use useLinkLocalOnly instead", cidr.String())) + continue + } + for j := i + 1; j < len(ip.Addresses); j++ { + if p := ip.Addresses[j].Prefix; cidr.Overlaps(p) { + errAgg = append(errAgg, fmt.Errorf("invalid IPv6 address %q: overlaps with %q", cidr.String(), p.String())) + } + } + } + return errors.Join(errAgg...) +} diff --git a/internal/webhook/core/v1alpha1/interface_webhook_test.go b/internal/webhook/core/v1alpha1/interface_webhook_test.go index 1c5dfc31b..7c153f147 100644 --- a/internal/webhook/core/v1alpha1/interface_webhook_test.go +++ b/internal/webhook/core/v1alpha1/interface_webhook_test.go @@ -66,6 +66,36 @@ var _ = Describe("Interface Webhook", func() { Expect(err.Error()).To(ContainSubstring("address is IPv6")) }) + It("Should allow valid IPv6 addresses", func() { + obj.Spec.IPv6 = &v1alpha1.InterfaceIPv6{ + Addresses: []v1alpha1.IPPrefix{v1alpha1.MustParsePrefix("2001:db8::1/64")}, + } + + Expect(validateInterfaceSpec(obj)).To(Succeed()) + }) + + It("Should reject link-local addresses in the IPv6 address list", func() { + obj.Spec.IPv6 = &v1alpha1.InterfaceIPv6{ + Addresses: []v1alpha1.IPPrefix{v1alpha1.MustParsePrefix("fe80::1/64")}, + } + + Expect(validateInterfaceSpec(obj)).To(MatchError(ContainSubstring("use useLinkLocalOnly instead"))) + }) + + It("Should allow useLinkLocalOnly without addresses", func() { + obj.Spec.IPv6 = &v1alpha1.InterfaceIPv6{UseLinkLocalOnly: true} + + Expect(validateInterfaceSpec(obj)).To(Succeed()) + }) + + It("Should reject IPv4 addresses in IPv6 field", func() { + obj.Spec.IPv6 = &v1alpha1.InterfaceIPv6{ + Addresses: []v1alpha1.IPPrefix{v1alpha1.MustParsePrefix("192.0.2.1/31")}, + } + + Expect(validateInterfaceSpec(obj)).To(MatchError(ContainSubstring("invalid IPv6 address"))) + }) + It("Should reject overlapping IPv4 addresses", func() { obj.Spec.IPv4 = &v1alpha1.InterfaceIPv4{ Addresses: []v1alpha1.IPPrefix{ diff --git a/test/gnmi/testdata/nx.cisco.networking.metal.ironcore.dev/interface_loopback_multi_addr.txtar b/test/gnmi/testdata/nx.cisco.networking.metal.ironcore.dev/interface_loopback_multi_addr.txtar index 996a79086..7de9b6c86 100644 --- a/test/gnmi/testdata/nx.cisco.networking.metal.ironcore.dev/interface_loopback_multi_addr.txtar +++ b/test/gnmi/testdata/nx.cisco.networking.metal.ironcore.dev/interface_loopback_multi_addr.txtar @@ -15,6 +15,10 @@ spec: addresses: - 10.0.0.20/32 - 10.0.0.21/32 + ipv6: + addresses: + - 2001:db8:1::20/128 + - 2001:db8:2::21/128 -- state/preload -- { @@ -80,6 +84,41 @@ spec: } } }, + "ipv6-items": { + "inst-items": { + "dom-items": { + "Dom-list": [ + { + "name": "default", + "if-items": { + "If-list": [ + { + "id": "lo1", + "useLinkLocalAddr": "disabled", + "addr-items": { + "Addr-list": [ + { + "addr": "2001:db8:1::20/128", + "pref": 0, + "tag": 0, + "type": "primary" + }, + { + "addr": "2001:db8:2::21/128", + "pref": 0, + "tag": 0, + "type": "primary" + } + ] + } + } + ] + } + } + ] + } + } + }, "icmpv4-items": { "inst-items": { "dom-items": { @@ -128,6 +167,20 @@ spec: } } }, + "ipv6-items": { + "inst-items": { + "dom-items": { + "Dom-list": [ + { + "name": "default", + "if-items": { + "If-list": [] + } + } + ] + } + } + }, "icmpv4-items": { "inst-items": { "dom-items": {