diff --git a/PROJECT b/PROJECT index 89852d6e2..b206c56fa 100644 --- a/PROJECT +++ b/PROJECT @@ -484,4 +484,16 @@ resources: kind: CommunitySet path: github.com/ironcore-dev/network-operator/api/core/v1alpha1 version: v1alpha1 +- api: + crdVersion: v1 + namespaced: true + controller: true + domain: networking.metal.ironcore.dev + group: core + kind: MacSec + path: github.com/ironcore-dev/network-operator/api/core/v1alpha1 + version: v1alpha1 + webhooks: + validation: true + webhookVersion: v1 version: "3" diff --git a/Tiltfile b/Tiltfile index b9da45e29..ea0325e5a 100644 --- a/Tiltfile +++ b/Tiltfile @@ -109,6 +109,10 @@ k8s_resource(new_name='acl', objects=['acl:accesscontrollist'], trigger_mode=TRI k8s_yaml('./config/samples/v1alpha1_certificate.yaml') k8s_resource(new_name='trustpoint', objects=['network-operator:issuer', 'network-operator-ca:certificate', 'trustpoint:certificate'], trigger_mode=TRIGGER_MODE_MANUAL, auto_init=False, labels=['samples']) +k8s_yaml('./config/samples/v1alpha1_macsec.yaml') +k8s_resource(new_name='macsec-eth1-1', objects=['secure-layer2:macsec'], resource_deps=['leaf1', 'eth1-1'], trigger_mode=TRIGGER_MODE_MANUAL, auto_init=False) +k8s_resource(new_name='macsec-secrets', objects=['secret1:secret', 'secret2-rollover:secret'], trigger_mode=TRIGGER_MODE_MANUAL, auto_init=False) + k8s_yaml('./config/samples/v1alpha1_snmp.yaml') k8s_resource(new_name='snmp', objects=['snmp:snmp'], trigger_mode=TRIGGER_MODE_MANUAL, auto_init=False, labels=['samples']) diff --git a/api/core/v1alpha1/groupversion_info.go b/api/core/v1alpha1/groupversion_info.go index e35c29b27..e90f70a71 100644 --- a/api/core/v1alpha1/groupversion_info.go +++ b/api/core/v1alpha1/groupversion_info.go @@ -60,6 +60,10 @@ const DeviceKind = "Device" // the name of the aggregate interface they belong to. const AggregateLabel = "networking.metal.ironcore.dev/aggregate-name" +// MacSecLabel is a label applied to the interface to indicate +// the name of the MacSec policy associated with the interface. +const MacSecLabel = "networking.metal.ironcore.dev/macsec-name" + // RoutedVLANLabel is a label applied to VLANs to indicate // the name of the RoutedVLAN interface that provides Layer 3 routing for the VLAN. const RoutedVLANLabel = "networking.metal.ironcore.dev/routed-vlan-name" diff --git a/api/core/v1alpha1/macsec_types.go b/api/core/v1alpha1/macsec_types.go new file mode 100644 index 000000000..c483eecf8 --- /dev/null +++ b/api/core/v1alpha1/macsec_types.go @@ -0,0 +1,181 @@ +// SPDX-FileCopyrightText: 2026 SAP SE or an SAP affiliate company and IronCore contributors +// SPDX-License-Identifier: Apache-2.0 + +package v1alpha1 + +import ( + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" +) + +const InterfaceRefIndexKey = "spec.interfaceRef.name" + +// / ConfidentialityOffset represents the number of octets in an Ethernet frame that are sent in unencrypted plain-text. +type ConfidentialityOffset uint8 + +// +kubebuilder:validation:Enum=0;30;50 +const ( + // ZeroBytes represents broadcast traffic. + ZeroBytes ConfidentialityOffset = 0 + // ThirtyBytes represents multicast traffic. + ThirtyBytes ConfidentialityOffset = 30 + // FiftyBytes represents unicast traffic. + FiftyBytes ConfidentialityOffset = 50 +) + +// MacSecSpec defines the desired state of MacSec. +type MacSecSpec struct { + // DeviceName is the name of the Device this object belongs to. The Device object must exist in the same namespace. + // Immutable. + // +required + // +kubebuilder:validation:XValidation:rule="self == oldSelf",message="DeviceRef is immutable" + DeviceRef LocalObjectReference `json:"deviceRef"` + + // ProviderConfigRef is a reference to a resource holding the provider-specific configuration. + // This reference is used to link the MacSec to its provider-specific configuration. + // +optional + ProviderConfigRef *TypedLocalObjectReference `json:"providerConfigRef,omitempty"` + + // Name is the name of the MacSec configuration. + // Immutable. + // +required + // +kubebuilder:validation:MinLength=1 + // +kubebuilder:validation:MaxLength=255 + // +kubebuilder:validation:XValidation:rule="self == oldSelf",message="Name is immutable" + Name string `json:"name"` + + // InterfaceRef is a reference to the interface on which MACsec is enabled. The interface must exist on the Device specified by deviceRef. + // Immutable. + // +required + // +kubebuilder:validation:XValidation:rule="self == oldSelf",message="InterfaceRef is immutable" + InterfaceRef LocalObjectReference `json:"interfaceRef"` + + // PreSharedKeys is a list of pre-shared keys used for MACSec encryption. + // Specify multiple keys if needed for redundancy rotation. + // Sort them in the order they should be used for redundancy rotation. + // +kubebuilder:validation:MinItems=1 + // +required + PreSharedKeys []PreSharedKey `json:"preSharedKeys"` + + // Policy defines the MACSec policy configuration. + // +required + Policy *MacSecPolicy `json:"policy"` +} + +// CryptographicAlgorithm is the MKA key-derivation algorithm used for a pre-shared key. +// +kubebuilder:validation:Enum=AES-128-CMAC;AES-256-CMAC +type CryptographicAlgorithm string + +const ( + // AES128CMAC is the AES-128-CMAC key-derivation algorithm. + AES128CMAC CryptographicAlgorithm = "AES-128-CMAC" + // AES256CMAC is the AES-256-CMAC key-derivation algorithm. + AES256CMAC CryptographicAlgorithm = "AES-256-CMAC" +) + +// PreSharedKey defines a single MACsec pre-shared key: a reference to the Secret +// holding the raw key material plus the key metadata. +type PreSharedKey struct { + // SecretRef references the Secret (in the same namespace) holding the raw key material + // under the data key "key". The Secret must contain only the key material. + // +required + SecretRef LocalObjectReference `json:"secretRef"` + + // ConnectivityKeyName is the connectivity association key name (CKN). + // +required + // +kubebuilder:validation:MinLength=1 + // +kubebuilder:validation:MaxLength=64 + ConnectivityKeyName string `json:"connectivityKeyName"` + + // Algorithm is the cryptographic algorithm used for this key. + // +required + Algorithm CryptographicAlgorithm `json:"algorithm"` + + // StartTime is when this key becomes active. + // The clock and date rendered into the device configuration are taken in UTC, + // so a value with any timezone offset is normalized to UTC before being realized. + // +required + StartTime metav1.Time `json:"startTime"` + + // EndTime is when this key expires. Must be after startTime. + // The clock and date rendered into the device configuration are taken in UTC, + // so a value with any timezone offset is normalized to UTC before being realized. + // +required + // Validates if the string is a valid datetime in RFC3339 format. + EndTime metav1.Time `json:"endTime"` +} + +type MacSecPolicy struct { + // CipherSuite is the MACSec cipher suite to be used. + // +required + CipherSuite string `json:"cipherSuite"` + + // ConfidentialityOffset specifies the number of octets in an Ethernet frame that are sent in unencrypted plain-text + // +required + ConfidentialityOffset ConfidentialityOffset `json:"confidentialityOffset"` + + // KeyServerPriority specifies the key server priority used by the MACsec Key Agreement (MKA) protocol to select the key server when + // MACsec is enabled using static connectivity association key (CAK) security mode. + // +required + KeyServerPriority int `json:"keyServerPriority"` + + // RelayProtection specifies the number of out-of-order packets that can be received before the secure channel is considered to be inoperable. + // A value of 0 means that frames are accepted only in the correct order. + // +required + RelayProtection int `json:"relayProtection"` +} + +// MacSecStatus defines the observed state of MacSec. +type MacSecStatus struct { + // The conditions are a list of status objects that describe the state of the MacSec. + //+listType=map + //+listMapKey=type + //+patchStrategy=merge + //+patchMergeKey=type + //+optional + Conditions []metav1.Condition `json:"conditions,omitempty" patchStrategy:"merge" patchMergeKey:"type"` +} + +// +kubebuilder:object:root=true +// +kubebuilder:subresource:status +// +kubebuilder:printcolumn:name="Device",type="string",JSONPath=".spec.deviceRef.name",description="The device this MacSec is configured on" +// +kubebuilder:printcolumn:name="Interface",type="string",JSONPath=".spec.interfaceRef.name",description="The interface this MacSec is configured on" +// +kubebuilder:printcolumn:name="Ready",type="string",JSONPath=".status.conditions[?(@.type=='Ready')].status",description="Ready status of the MacSec" +// +kubebuilder:printcolumn:name="Configured",type=string,JSONPath=`.status.conditions[?(@.type=="Configured")].status`,priority=1 +// +kubebuilder:printcolumn:name="Paused",type=string,JSONPath=`.status.conditions[?(@.type=="Paused")].status`,priority=1 +// +kubebuilder:printcolumn:name="Age",type="date",JSONPath=".metadata.creationTimestamp" + +// MacSec is the Schema for the macsecs API. +type MacSec struct { + metav1.TypeMeta `json:",inline"` + metav1.ObjectMeta `json:"metadata,omitempty"` + + Spec MacSecSpec `json:"spec,omitempty"` + Status MacSecStatus `json:"status,omitempty"` +} + +// +kubebuilder:object:root=true + +// MacSecList contains a list of MacSec. +type MacSecList struct { + metav1.TypeMeta `json:",inline"` + metav1.ListMeta `json:"metadata,omitempty"` + Items []MacSec `json:"items"` +} + +// GetConditions implements conditions.Getter. +func (m *MacSec) GetConditions() []metav1.Condition { + return m.Status.Conditions +} + +// SetConditions implements conditions.Setter. +func (m *MacSec) SetConditions(conditions []metav1.Condition) { + m.Status.Conditions = conditions +} + +func init() { + SchemeBuilder.Register(func(s *runtime.Scheme) error { + s.AddKnownTypes(GroupVersion, &MacSec{}, &MacSecList{}) + return nil + }) +} diff --git a/api/core/v1alpha1/zz_generated.deepcopy.go b/api/core/v1alpha1/zz_generated.deepcopy.go index 00f1ca972..c31c4cc2f 100644 --- a/api/core/v1alpha1/zz_generated.deepcopy.go +++ b/api/core/v1alpha1/zz_generated.deepcopy.go @@ -3236,6 +3236,136 @@ func (in *MACTableEntryProbe) DeepCopy() *MACTableEntryProbe { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *MacSec) DeepCopyInto(out *MacSec) { + *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 MacSec. +func (in *MacSec) DeepCopy() *MacSec { + if in == nil { + return nil + } + out := new(MacSec) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *MacSec) 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 *MacSecList) DeepCopyInto(out *MacSecList) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ListMeta.DeepCopyInto(&out.ListMeta) + if in.Items != nil { + in, out := &in.Items, &out.Items + *out = make([]MacSec, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new MacSecList. +func (in *MacSecList) DeepCopy() *MacSecList { + if in == nil { + return nil + } + out := new(MacSecList) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *MacSecList) 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 *MacSecPolicy) DeepCopyInto(out *MacSecPolicy) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new MacSecPolicy. +func (in *MacSecPolicy) DeepCopy() *MacSecPolicy { + if in == nil { + return nil + } + out := new(MacSecPolicy) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *MacSecSpec) DeepCopyInto(out *MacSecSpec) { + *out = *in + out.DeviceRef = in.DeviceRef + if in.ProviderConfigRef != nil { + in, out := &in.ProviderConfigRef, &out.ProviderConfigRef + *out = new(TypedLocalObjectReference) + **out = **in + } + out.InterfaceRef = in.InterfaceRef + if in.PreSharedKeys != nil { + in, out := &in.PreSharedKeys, &out.PreSharedKeys + *out = make([]PreSharedKey, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } + if in.Policy != nil { + in, out := &in.Policy, &out.Policy + *out = new(MacSecPolicy) + **out = **in + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new MacSecSpec. +func (in *MacSecSpec) DeepCopy() *MacSecSpec { + if in == nil { + return nil + } + out := new(MacSecSpec) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *MacSecStatus) DeepCopyInto(out *MacSecStatus) { + *out = *in + if in.Conditions != nil { + in, out := &in.Conditions, &out.Conditions + *out = make([]v1.Condition, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new MacSecStatus. +func (in *MacSecStatus) DeepCopy() *MacSecStatus { + if in == nil { + return nil + } + out := new(MacSecStatus) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *ManagementAccess) DeepCopyInto(out *ManagementAccess) { *out = *in @@ -4144,6 +4274,24 @@ func (in *PolicyStatement) DeepCopy() *PolicyStatement { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *PreSharedKey) DeepCopyInto(out *PreSharedKey) { + *out = *in + out.SecretRef = in.SecretRef + in.StartTime.DeepCopyInto(&out.StartTime) + in.EndTime.DeepCopyInto(&out.EndTime) +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new PreSharedKey. +func (in *PreSharedKey) DeepCopy() *PreSharedKey { + if in == nil { + return nil + } + out := new(PreSharedKey) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *PrefixEntry) DeepCopyInto(out *PrefixEntry) { *out = *in diff --git a/charts/network-operator/templates/crd/macsecs.networking.metal.ironcore.dev.yaml b/charts/network-operator/templates/crd/macsecs.networking.metal.ironcore.dev.yaml new file mode 100644 index 000000000..479937c95 --- /dev/null +++ b/charts/network-operator/templates/crd/macsecs.networking.metal.ironcore.dev.yaml @@ -0,0 +1,317 @@ +{{- if .Values.crd.enabled }} +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + annotations: + {{- if .Values.crd.keep }} + "helm.sh/resource-policy": keep + {{- end }} + controller-gen.kubebuilder.io/version: v0.22.0 + name: macsecs.networking.metal.ironcore.dev +spec: + group: networking.metal.ironcore.dev + names: + kind: MacSec + listKind: MacSecList + plural: macsecs + singular: macsec + scope: Namespaced + versions: + - additionalPrinterColumns: + - description: The device this MacSec is configured on + jsonPath: .spec.deviceRef.name + name: Device + type: string + - description: The interface this MacSec is configured on + jsonPath: .spec.interfaceRef.name + name: Interface + type: string + - description: Ready status of the MacSec + jsonPath: .status.conditions[?(@.type=='Ready')].status + name: Ready + type: string + - jsonPath: .status.conditions[?(@.type=="Configured")].status + name: Configured + priority: 1 + type: string + - jsonPath: .status.conditions[?(@.type=="Paused")].status + name: Paused + priority: 1 + type: string + - jsonPath: .metadata.creationTimestamp + name: Age + type: date + name: v1alpha1 + schema: + openAPIV3Schema: + description: MacSec is the Schema for the macsecs API. + 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: MacSecSpec defines the desired state of MacSec. + properties: + deviceRef: + description: |- + DeviceName is the name of the Device this object belongs to. The Device object must exist in the same namespace. + Immutable. + properties: + name: + description: |- + Name of the referent. + More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names + maxLength: 63 + minLength: 1 + type: string + required: + - name + type: object + x-kubernetes-map-type: atomic + x-kubernetes-validations: + - message: DeviceRef is immutable + rule: self == oldSelf + interfaceRef: + description: |- + InterfaceRef is a reference to the interface on which MACsec is enabled. The interface must exist on the Device specified by deviceRef. + Immutable. + properties: + name: + description: |- + Name of the referent. + More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names + maxLength: 63 + minLength: 1 + type: string + required: + - name + type: object + x-kubernetes-map-type: atomic + x-kubernetes-validations: + - message: InterfaceRef is immutable + rule: self == oldSelf + name: + description: |- + Name is the name of the MacSec configuration. + Immutable. + maxLength: 255 + minLength: 1 + type: string + x-kubernetes-validations: + - message: Name is immutable + rule: self == oldSelf + policy: + description: Policy defines the MACSec policy configuration. + properties: + cipherSuite: + description: CipherSuite is the MACSec cipher suite to be used. + type: string + confidentialityOffset: + description: ConfidentialityOffset specifies the number of octets + in an Ethernet frame that are sent in unencrypted plain-text + type: integer + keyServerPriority: + description: |- + KeyServerPriority specifies the key server priority used by the MACsec Key Agreement (MKA) protocol to select the key server when + MACsec is enabled using static connectivity association key (CAK) security mode. + type: integer + relayProtection: + description: |- + RelayProtection specifies the number of out-of-order packets that can be received before the secure channel is considered to be inoperable. + A value of 0 means that frames are accepted only in the correct order. + type: integer + required: + - cipherSuite + - confidentialityOffset + - keyServerPriority + - relayProtection + type: object + preSharedKeys: + description: |- + PreSharedKeys is a list of pre-shared keys used for MACSec encryption. + Specify multiple keys if needed for redundancy rotation. + Sort them in the order they should be used for redundancy rotation. + items: + description: |- + PreSharedKey defines a single MACsec pre-shared key: a reference to the Secret + holding the raw key material plus the key metadata. + properties: + algorithm: + description: Algorithm is the cryptographic algorithm used for + this key. + enum: + - AES-128-CMAC + - AES-256-CMAC + type: string + connectivityKeyName: + description: ConnectivityKeyName is the connectivity association + key name (CKN). + maxLength: 64 + minLength: 1 + type: string + endTime: + description: |- + EndTime is when this key expires. Must be after startTime. + The clock and date rendered into the device configuration are taken in UTC, + so a value with any timezone offset is normalized to UTC before being realized. + Validates if the string is a valid datetime in RFC3339 format. + format: date-time + type: string + secretRef: + description: |- + SecretRef references the Secret (in the same namespace) holding the raw key material + under the data key "key". The Secret must contain only the key material. + properties: + name: + description: |- + Name of the referent. + More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names + maxLength: 63 + minLength: 1 + type: string + required: + - name + type: object + x-kubernetes-map-type: atomic + startTime: + description: |- + StartTime is when this key becomes active. + The clock and date rendered into the device configuration are taken in UTC, + so a value with any timezone offset is normalized to UTC before being realized. + format: date-time + type: string + required: + - algorithm + - connectivityKeyName + - endTime + - secretRef + - startTime + type: object + minItems: 1 + type: array + providerConfigRef: + description: |- + ProviderConfigRef is a reference to a resource holding the provider-specific configuration. + This reference is used to link the MacSec to its provider-specific configuration. + properties: + apiVersion: + description: APIVersion is the api group version of the resource + being referenced. + maxLength: 253 + minLength: 1 + pattern: ^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*\/)?([a-z0-9]([-a-z0-9]*[a-z0-9])?)$ + type: string + kind: + description: |- + Kind of the resource being referenced. + Kind must consist of alphanumeric characters or '-', start with an alphabetic character, and end with an alphanumeric character. + maxLength: 63 + minLength: 1 + pattern: ^[a-zA-Z]([-a-zA-Z0-9]*[a-zA-Z0-9])?$ + type: string + name: + description: |- + Name of the resource being referenced. + Name must consist of lower case alphanumeric characters, '-' or '.', and must start and end with an alphanumeric character. + 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: + - apiVersion + - kind + - name + type: object + x-kubernetes-map-type: atomic + required: + - deviceRef + - interfaceRef + - name + - policy + - preSharedKeys + type: object + status: + description: MacSecStatus defines the observed state of MacSec. + properties: + conditions: + description: The conditions are a list of status objects that describe + the state of the MacSec. + 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 + type: array + x-kubernetes-list-map-keys: + - type + x-kubernetes-list-type: map + type: object + type: object + served: true + storage: true + subresources: + status: {} +{{- end }} diff --git a/charts/network-operator/templates/rbac/macsec-admin-role.yaml b/charts/network-operator/templates/rbac/macsec-admin-role.yaml new file mode 100644 index 000000000..571b71a0d --- /dev/null +++ b/charts/network-operator/templates/rbac/macsec-admin-role.yaml @@ -0,0 +1,31 @@ +{{- if .Values.rbac.helpers.enabled }} +apiVersion: rbac.authorization.k8s.io/v1 +{{- if .Values.rbac.namespaced }} +kind: Role +{{- else }} +kind: ClusterRole +{{- end }} +metadata: +{{- if .Values.rbac.namespaced }} + namespace: {{ .Release.Namespace }} +{{- end }} + labels: + app.kubernetes.io/managed-by: {{ .Release.Service }} + app.kubernetes.io/name: {{ include "network-operator.name" . }} + helm.sh/chart: {{ .Chart.Name }}-{{ .Chart.Version | replace "+" "_" }} + app.kubernetes.io/instance: {{ .Release.Name }} + name: {{ include "network-operator.resourceName" (dict "suffix" "macsec-admin-role" "context" $) }} +rules: +- apiGroups: + - networking.metal.ironcore.dev + resources: + - macsecs + verbs: + - '*' +- apiGroups: + - networking.metal.ironcore.dev + resources: + - macsecs/status + verbs: + - get +{{- end }} diff --git a/charts/network-operator/templates/rbac/macsec-editor-role.yaml b/charts/network-operator/templates/rbac/macsec-editor-role.yaml new file mode 100644 index 000000000..eb29932a6 --- /dev/null +++ b/charts/network-operator/templates/rbac/macsec-editor-role.yaml @@ -0,0 +1,37 @@ +{{- if .Values.rbac.helpers.enabled }} +apiVersion: rbac.authorization.k8s.io/v1 +{{- if .Values.rbac.namespaced }} +kind: Role +{{- else }} +kind: ClusterRole +{{- end }} +metadata: +{{- if .Values.rbac.namespaced }} + namespace: {{ .Release.Namespace }} +{{- end }} + labels: + app.kubernetes.io/managed-by: {{ .Release.Service }} + app.kubernetes.io/name: {{ include "network-operator.name" . }} + helm.sh/chart: {{ .Chart.Name }}-{{ .Chart.Version | replace "+" "_" }} + app.kubernetes.io/instance: {{ .Release.Name }} + name: {{ include "network-operator.resourceName" (dict "suffix" "macsec-editor-role" "context" $) }} +rules: +- apiGroups: + - networking.metal.ironcore.dev + resources: + - macsecs + verbs: + - create + - delete + - get + - list + - patch + - update + - watch +- apiGroups: + - networking.metal.ironcore.dev + resources: + - macsecs/status + verbs: + - get +{{- end }} diff --git a/charts/network-operator/templates/rbac/macsec-viewer-role.yaml b/charts/network-operator/templates/rbac/macsec-viewer-role.yaml new file mode 100644 index 000000000..e75fa8112 --- /dev/null +++ b/charts/network-operator/templates/rbac/macsec-viewer-role.yaml @@ -0,0 +1,33 @@ +{{- if .Values.rbac.helpers.enabled }} +apiVersion: rbac.authorization.k8s.io/v1 +{{- if .Values.rbac.namespaced }} +kind: Role +{{- else }} +kind: ClusterRole +{{- end }} +metadata: +{{- if .Values.rbac.namespaced }} + namespace: {{ .Release.Namespace }} +{{- end }} + labels: + app.kubernetes.io/managed-by: {{ .Release.Service }} + app.kubernetes.io/name: {{ include "network-operator.name" . }} + helm.sh/chart: {{ .Chart.Name }}-{{ .Chart.Version | replace "+" "_" }} + app.kubernetes.io/instance: {{ .Release.Name }} + name: {{ include "network-operator.resourceName" (dict "suffix" "macsec-viewer-role" "context" $) }} +rules: +- apiGroups: + - networking.metal.ironcore.dev + resources: + - macsecs + verbs: + - get + - list + - watch +- apiGroups: + - networking.metal.ironcore.dev + resources: + - macsecs/status + verbs: + - get +{{- end }} diff --git a/charts/network-operator/templates/rbac/manager-role.yaml b/charts/network-operator/templates/rbac/manager-role.yaml index 670fdaed0..7c84723ad 100644 --- a/charts/network-operator/templates/rbac/manager-role.yaml +++ b/charts/network-operator/templates/rbac/manager-role.yaml @@ -91,6 +91,7 @@ rules: - interfaces - isis - lldps + - macsecs - managementaccesses - networkvirtualizationedges - ntp @@ -132,6 +133,7 @@ rules: - interfaces/finalizers - isis/finalizers - lldps/finalizers + - macsecs/finalizers - managementaccesses/finalizers - networkvirtualizationedges/finalizers - ntp/finalizers @@ -167,6 +169,7 @@ rules: - interfaces/status - isis/status - lldps/status + - macsecs/status - managementaccesses/status - networkvirtualizationedges/status - ntp/status diff --git a/charts/network-operator/templates/webhook/validating-webhook-configuration.yaml b/charts/network-operator/templates/webhook/validating-webhook-configuration.yaml index f080ad35e..ca3bc8c6b 100644 --- a/charts/network-operator/templates/webhook/validating-webhook-configuration.yaml +++ b/charts/network-operator/templates/webhook/validating-webhook-configuration.yaml @@ -108,6 +108,26 @@ webhooks: resources: - interfaces sideEffects: None +- admissionReviewVersions: + - v1 + clientConfig: + service: + name: {{ include "network-operator.resourceName" (dict "suffix" "webhook-service" "context" $) }} + namespace: {{ .Release.Namespace }} + path: /validate-networking-metal-ironcore-dev-v1alpha1-macsec + failurePolicy: Fail + name: macsec-v1alpha1.kb.io + rules: + - apiGroups: + - networking.metal.ironcore.dev + apiVersions: + - v1alpha1 + operations: + - CREATE + - UPDATE + resources: + - macsec + sideEffects: None - admissionReviewVersions: - v1 clientConfig: diff --git a/cmd/main.go b/cmd/main.go index bbaa46fee..d8c09bcf1 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -630,6 +630,18 @@ func main() { //nolint:gocyclo os.Exit(1) } + if err := (&corecontroller.MacSecReconciler{ + Client: mgr.GetClient(), + Scheme: mgr.GetScheme(), + Recorder: mgr.GetEventRecorder("macsec-controller"), + WatchFilterValue: watchFilterValue, + Locker: locker, + RequeueInterval: requeueInterval, + }).SetupWithManager(ctx, mgr); err != nil { + setupLog.Error(err, "unable to create controller", "controller", "MacSec") + os.Exit(1) + } + if err := (&corecontroller.ConfigBackupReconciler{ Client: mgr.GetClient(), Scheme: mgr.GetScheme(), @@ -766,7 +778,10 @@ func main() { //nolint:gocyclo setupLog.Error(err, "Failed to create webhook", "webhook", "DHCPRelay") os.Exit(1) } - + if err := webhookv1alpha1.SetupMacSecWebhookWithManager(mgr); err != nil { + setupLog.Error(err, "Failed to create webhook", "webhook", "MacSec") + os.Exit(1) + } if err := webhooknxv1alpha1.SetupNetworkVirtualizationEdgeConfigWebhookWithManager(mgr); err != nil { setupLog.Error(err, "unable to create webhook", "webhook", "NetworkVirtualizationEdgeConfig") os.Exit(1) diff --git a/config/crd/bases/networking.metal.ironcore.dev_macsecs.yaml b/config/crd/bases/networking.metal.ironcore.dev_macsecs.yaml new file mode 100644 index 000000000..5e7c48217 --- /dev/null +++ b/config/crd/bases/networking.metal.ironcore.dev_macsecs.yaml @@ -0,0 +1,313 @@ +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + annotations: + controller-gen.kubebuilder.io/version: v0.22.0 + name: macsecs.networking.metal.ironcore.dev +spec: + group: networking.metal.ironcore.dev + names: + kind: MacSec + listKind: MacSecList + plural: macsecs + singular: macsec + scope: Namespaced + versions: + - additionalPrinterColumns: + - description: The device this MacSec is configured on + jsonPath: .spec.deviceRef.name + name: Device + type: string + - description: The interface this MacSec is configured on + jsonPath: .spec.interfaceRef.name + name: Interface + type: string + - description: Ready status of the MacSec + jsonPath: .status.conditions[?(@.type=='Ready')].status + name: Ready + type: string + - jsonPath: .status.conditions[?(@.type=="Configured")].status + name: Configured + priority: 1 + type: string + - jsonPath: .status.conditions[?(@.type=="Paused")].status + name: Paused + priority: 1 + type: string + - jsonPath: .metadata.creationTimestamp + name: Age + type: date + name: v1alpha1 + schema: + openAPIV3Schema: + description: MacSec is the Schema for the macsecs API. + 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: MacSecSpec defines the desired state of MacSec. + properties: + deviceRef: + description: |- + DeviceName is the name of the Device this object belongs to. The Device object must exist in the same namespace. + Immutable. + properties: + name: + description: |- + Name of the referent. + More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names + maxLength: 63 + minLength: 1 + type: string + required: + - name + type: object + x-kubernetes-map-type: atomic + x-kubernetes-validations: + - message: DeviceRef is immutable + rule: self == oldSelf + interfaceRef: + description: |- + InterfaceRef is a reference to the interface on which MACsec is enabled. The interface must exist on the Device specified by deviceRef. + Immutable. + properties: + name: + description: |- + Name of the referent. + More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names + maxLength: 63 + minLength: 1 + type: string + required: + - name + type: object + x-kubernetes-map-type: atomic + x-kubernetes-validations: + - message: InterfaceRef is immutable + rule: self == oldSelf + name: + description: |- + Name is the name of the MacSec configuration. + Immutable. + maxLength: 255 + minLength: 1 + type: string + x-kubernetes-validations: + - message: Name is immutable + rule: self == oldSelf + policy: + description: Policy defines the MACSec policy configuration. + properties: + cipherSuite: + description: CipherSuite is the MACSec cipher suite to be used. + type: string + confidentialityOffset: + description: ConfidentialityOffset specifies the number of octets + in an Ethernet frame that are sent in unencrypted plain-text + type: integer + keyServerPriority: + description: |- + KeyServerPriority specifies the key server priority used by the MACsec Key Agreement (MKA) protocol to select the key server when + MACsec is enabled using static connectivity association key (CAK) security mode. + type: integer + relayProtection: + description: |- + RelayProtection specifies the number of out-of-order packets that can be received before the secure channel is considered to be inoperable. + A value of 0 means that frames are accepted only in the correct order. + type: integer + required: + - cipherSuite + - confidentialityOffset + - keyServerPriority + - relayProtection + type: object + preSharedKeys: + description: |- + PreSharedKeys is a list of pre-shared keys used for MACSec encryption. + Specify multiple keys if needed for redundancy rotation. + Sort them in the order they should be used for redundancy rotation. + items: + description: |- + PreSharedKey defines a single MACsec pre-shared key: a reference to the Secret + holding the raw key material plus the key metadata. + properties: + algorithm: + description: Algorithm is the cryptographic algorithm used for + this key. + enum: + - AES-128-CMAC + - AES-256-CMAC + type: string + connectivityKeyName: + description: ConnectivityKeyName is the connectivity association + key name (CKN). + maxLength: 64 + minLength: 1 + type: string + endTime: + description: |- + EndTime is when this key expires. Must be after startTime. + The clock and date rendered into the device configuration are taken in UTC, + so a value with any timezone offset is normalized to UTC before being realized. + Validates if the string is a valid datetime in RFC3339 format. + format: date-time + type: string + secretRef: + description: |- + SecretRef references the Secret (in the same namespace) holding the raw key material + under the data key "key". The Secret must contain only the key material. + properties: + name: + description: |- + Name of the referent. + More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names + maxLength: 63 + minLength: 1 + type: string + required: + - name + type: object + x-kubernetes-map-type: atomic + startTime: + description: |- + StartTime is when this key becomes active. + The clock and date rendered into the device configuration are taken in UTC, + so a value with any timezone offset is normalized to UTC before being realized. + format: date-time + type: string + required: + - algorithm + - connectivityKeyName + - endTime + - secretRef + - startTime + type: object + minItems: 1 + type: array + providerConfigRef: + description: |- + ProviderConfigRef is a reference to a resource holding the provider-specific configuration. + This reference is used to link the MacSec to its provider-specific configuration. + properties: + apiVersion: + description: APIVersion is the api group version of the resource + being referenced. + maxLength: 253 + minLength: 1 + pattern: ^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*\/)?([a-z0-9]([-a-z0-9]*[a-z0-9])?)$ + type: string + kind: + description: |- + Kind of the resource being referenced. + Kind must consist of alphanumeric characters or '-', start with an alphabetic character, and end with an alphanumeric character. + maxLength: 63 + minLength: 1 + pattern: ^[a-zA-Z]([-a-zA-Z0-9]*[a-zA-Z0-9])?$ + type: string + name: + description: |- + Name of the resource being referenced. + Name must consist of lower case alphanumeric characters, '-' or '.', and must start and end with an alphanumeric character. + 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: + - apiVersion + - kind + - name + type: object + x-kubernetes-map-type: atomic + required: + - deviceRef + - interfaceRef + - name + - policy + - preSharedKeys + type: object + status: + description: MacSecStatus defines the observed state of MacSec. + properties: + conditions: + description: The conditions are a list of status objects that describe + the state of the MacSec. + 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 + type: array + x-kubernetes-list-map-keys: + - type + x-kubernetes-list-type: map + type: object + type: object + served: true + storage: true + subresources: + status: {} diff --git a/config/crd/kustomization.yaml b/config/crd/kustomization.yaml index 00d734840..5fae4859e 100644 --- a/config/crd/kustomization.yaml +++ b/config/crd/kustomization.yaml @@ -33,6 +33,7 @@ resources: - bases/networking.metal.ironcore.dev_aaa.yaml - bases/networking.metal.ironcore.dev_probes.yaml - bases/networking.metal.ironcore.dev_consoleconnections.yaml +- bases/networking.metal.ironcore.dev_macsecs.yaml - bases/pool.networking.metal.ironcore.dev_indexpools.yaml - bases/pool.networking.metal.ironcore.dev_ipaddresspools.yaml - bases/pool.networking.metal.ironcore.dev_ipprefixpools.yaml diff --git a/config/default/kustomization.yaml b/config/default/kustomization.yaml index fd3c7529c..0d954de55 100644 --- a/config/default/kustomization.yaml +++ b/config/default/kustomization.yaml @@ -199,36 +199,36 @@ replacements: index: 1 create: true # -# - source: # Uncomment the following block if you have a DefaultingWebhook (--defaulting ) -# kind: Certificate -# group: cert-manager.io -# version: v1 -# name: serving-cert -# fieldPath: .metadata.namespace # Namespace of the certificate CR -# targets: -# - select: -# kind: MutatingWebhookConfiguration -# fieldPaths: -# - .metadata.annotations.[cert-manager.io/inject-ca-from] -# options: -# delimiter: '/' -# index: 0 -# create: true -# - source: -# kind: Certificate -# group: cert-manager.io -# version: v1 -# name: serving-cert -# fieldPath: .metadata.name -# targets: -# - select: -# kind: MutatingWebhookConfiguration -# fieldPaths: -# - .metadata.annotations.[cert-manager.io/inject-ca-from] -# options: -# delimiter: '/' -# index: 1 -# create: true + - source: # Uncomment the following block if you have a DefaultingWebhook (--defaulting ) + kind: Certificate + group: cert-manager.io + version: v1 + name: serving-cert + fieldPath: .metadata.namespace # Namespace of the certificate CR + targets: + - select: + kind: MutatingWebhookConfiguration + fieldPaths: + - .metadata.annotations.[cert-manager.io/inject-ca-from] + options: + delimiter: '/' + index: 0 + create: true + - source: + kind: Certificate + group: cert-manager.io + version: v1 + name: serving-cert + fieldPath: .metadata.name + targets: + - select: + kind: MutatingWebhookConfiguration + fieldPaths: + - .metadata.annotations.[cert-manager.io/inject-ca-from] + options: + delimiter: '/' + index: 1 + create: true # # - source: # Uncomment the following block if you have a ConversionWebhook (--conversion) # kind: Certificate diff --git a/config/rbac/kustomization.yaml b/config/rbac/kustomization.yaml index d2e2f300e..2a69a294a 100644 --- a/config/rbac/kustomization.yaml +++ b/config/rbac/kustomization.yaml @@ -64,6 +64,9 @@ resources: - lldp_admin_role.yaml - lldp_editor_role.yaml - lldp_viewer_role.yaml +- macsec_admin_role.yaml +- macsec_editor_role.yaml +- macsec_viewer_role.yaml - managementaccess_admin_role.yaml - managementaccess_editor_role.yaml - managementaccess_viewer_role.yaml diff --git a/config/rbac/macsec_admin_role.yaml b/config/rbac/macsec_admin_role.yaml new file mode 100644 index 000000000..81f6da500 --- /dev/null +++ b/config/rbac/macsec_admin_role.yaml @@ -0,0 +1,27 @@ +# This rule is not used by the project network-operator itself. +# It is provided to allow the cluster admin to help manage permissions for users. +# +# Grants full permissions ('*') over networking.metal.ironcore.dev. +# This role is intended for users authorized to modify roles and bindings within the cluster, +# enabling them to delegate specific permissions to other users or groups as needed. + +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + labels: + app.kubernetes.io/name: network-operator + app.kubernetes.io/managed-by: kustomize + name: macsec-admin-role +rules: +- apiGroups: + - networking.metal.ironcore.dev + resources: + - macsecs + verbs: + - '*' +- apiGroups: + - networking.metal.ironcore.dev + resources: + - macsecs/status + verbs: + - get diff --git a/config/rbac/macsec_editor_role.yaml b/config/rbac/macsec_editor_role.yaml new file mode 100644 index 000000000..966f3dd39 --- /dev/null +++ b/config/rbac/macsec_editor_role.yaml @@ -0,0 +1,33 @@ +# This rule is not used by the project network-operator itself. +# It is provided to allow the cluster admin to help manage permissions for users. +# +# Grants permissions to create, update, and delete resources within the networking.metal.ironcore.dev. +# This role is intended for users who need to manage these resources +# but should not control RBAC or manage permissions for others. + +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + labels: + app.kubernetes.io/name: network-operator + app.kubernetes.io/managed-by: kustomize + name: macsec-editor-role +rules: +- apiGroups: + - networking.metal.ironcore.dev + resources: + - macsecs + verbs: + - create + - delete + - get + - list + - patch + - update + - watch +- apiGroups: + - networking.metal.ironcore.dev + resources: + - macsecs/status + verbs: + - get diff --git a/config/rbac/macsec_viewer_role.yaml b/config/rbac/macsec_viewer_role.yaml new file mode 100644 index 000000000..1c8ccb06f --- /dev/null +++ b/config/rbac/macsec_viewer_role.yaml @@ -0,0 +1,29 @@ +# This rule is not used by the project network-operator itself. +# It is provided to allow the cluster admin to help manage permissions for users. +# +# Grants read-only access to networking.metal.ironcore.dev resources. +# This role is intended for users who need visibility into these resources +# without permissions to modify them. It is ideal for monitoring purposes and limited-access viewing. + +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + labels: + app.kubernetes.io/name: network-operator + app.kubernetes.io/managed-by: kustomize + name: macsec-viewer-role +rules: +- apiGroups: + - networking.metal.ironcore.dev + resources: + - macsecs + verbs: + - get + - list + - watch +- apiGroups: + - networking.metal.ironcore.dev + resources: + - macsecs/status + verbs: + - get diff --git a/config/rbac/role.yaml b/config/rbac/role.yaml index aa673593a..f90da4361 100644 --- a/config/rbac/role.yaml +++ b/config/rbac/role.yaml @@ -85,6 +85,7 @@ rules: - interfaces - isis - lldps + - macsecs - managementaccesses - networkvirtualizationedges - ntp @@ -126,6 +127,7 @@ rules: - interfaces/finalizers - isis/finalizers - lldps/finalizers + - macsecs/finalizers - managementaccesses/finalizers - networkvirtualizationedges/finalizers - ntp/finalizers @@ -161,6 +163,7 @@ rules: - interfaces/status - isis/status - lldps/status + - macsecs/status - managementaccesses/status - networkvirtualizationedges/status - ntp/status diff --git a/config/samples/kustomization.yaml b/config/samples/kustomization.yaml index 1b8e71f68..feb6a55a2 100644 --- a/config/samples/kustomization.yaml +++ b/config/samples/kustomization.yaml @@ -41,6 +41,7 @@ resources: - v1alpha1_consoleconnection.yaml - v1alpha1_network.yaml - v1alpha1_networkattachment.yaml +- v1alpha1_macsec.yaml - cisco/nx/v1alpha1_bordergateway.yaml - cisco/nx/v1alpha1_managementaccessconfig.yaml - cisco/nx/v1alpha1_nveconfig.yaml diff --git a/config/samples/v1alpha1_macsec.yaml b/config/samples/v1alpha1_macsec.yaml new file mode 100644 index 000000000..98de8c9b6 --- /dev/null +++ b/config/samples/v1alpha1_macsec.yaml @@ -0,0 +1,54 @@ +apiVersion: networking.metal.ironcore.dev/v1alpha1 +kind: MacSec +metadata: + labels: + app.kubernetes.io/name: network-operator + app.kubernetes.io/managed-by: kustomize + networking.metal.ironcore.dev/device-name: leaf1 + name: secure-layer2 +spec: + deviceRef: + name: leaf1 + interfaceRef: + name: eth1-1 + name: secure-layer2 + preSharedKeys: + - secretRef: + name: secret1 + connectivityKeyName: "1234567" + algorithm: AES-128-CMAC + startTime: "2026-01-01T00:00:00Z" + endTime: "2027-01-01T09:34:01Z" + - secretRef: + name: secret2-rollover + connectivityKeyName: "7654321" + algorithm: AES-128-CMAC + startTime: "2027-01-01T00:00:00Z" + endTime: "2028-01-01T09:34:01Z" + policy: + cipherSuite: GCM-AES-XPN-256 + confidentialityOffset: 30 + keyServerPriority: 8 + relayProtection: 32 +--- +apiVersion: v1 +kind: Secret +metadata: + name: secret1 + labels: + app.kubernetes.io/name: network-operator + app.kubernetes.io/managed-by: kustomize +type: Opaque +data: + key: dGVzdC1wc2s= # base64-decoded value: test-psk +--- +apiVersion: v1 +kind: Secret +metadata: + name: secret2-rollover + labels: + app.kubernetes.io/name: network-operator + app.kubernetes.io/managed-by: kustomize +type: Opaque +data: + key: dGVzdC1wc2s= # base64-decoded value: test-psk diff --git a/config/webhook/manifests.yaml b/config/webhook/manifests.yaml index cf5ae4a31..c91516e15 100644 --- a/config/webhook/manifests.yaml +++ b/config/webhook/manifests.yaml @@ -104,6 +104,26 @@ webhooks: resources: - interfaces sideEffects: None +- admissionReviewVersions: + - v1 + clientConfig: + service: + name: webhook-service + namespace: system + path: /validate-networking-metal-ironcore-dev-v1alpha1-macsec + failurePolicy: Fail + name: macsec-v1alpha1.kb.io + rules: + - apiGroups: + - networking.metal.ironcore.dev + apiVersions: + - v1alpha1 + operations: + - CREATE + - UPDATE + resources: + - macsec + sideEffects: None - admissionReviewVersions: - v1 clientConfig: diff --git a/docs/api-reference/index.md b/docs/api-reference/index.md index c735245e9..46d6c1bd7 100644 --- a/docs/api-reference/index.md +++ b/docs/api-reference/index.md @@ -344,6 +344,7 @@ Package v1alpha1 contains API Schema definitions for the networking.metal.ironco - [ISIS](#isis) - [Interface](#interface) - [LLDP](#lldp) +- [MacSec](#macsec) - [ManagementAccess](#managementaccess) - [NTP](#ntp) - [NetworkVirtualizationEdge](#networkvirtualizationedge) @@ -1530,6 +1531,19 @@ _Appears in:_ | `Extended` | CommunitySetTypeExtended is an extended BGP community-list.
| +#### ConfidentialityOffset + +_Underlying type:_ _integer_ + +/ ConfidentialityOffset represents the number of octets in an Ethernet frame that are sent in unencrypted plain-text. + + + +_Appears in:_ +- [MacSecPolicy](#macsecpolicy) + + + #### ConfigBackup @@ -1925,6 +1939,24 @@ _Appears in:_ | `mode` _[LACPMode](#lacpmode)_ | Mode defines the LACP mode for the aggregate interface. | | Enum: [Active Passive Static]
Required: \{\}
| +#### CryptographicAlgorithm + +_Underlying type:_ _string_ + +CryptographicAlgorithm is the MKA key-derivation algorithm used for a pre-shared key. + +_Validation:_ +- Enum: [AES-128-CMAC AES-256-CMAC] + +_Appears in:_ +- [PreSharedKey](#presharedkey) + +| Field | Description | +| --- | --- | +| `AES-128-CMAC` | AES128CMAC is the AES-128-CMAC key-derivation algorithm.
| +| `AES-256-CMAC` | AES256CMAC is the AES-256-CMAC key-derivation algorithm.
| + + #### DFElectionMode _Underlying type:_ _string_ @@ -3000,6 +3032,7 @@ _Appears in:_ - [KeepAlive](#keepalive) - [LLDPInterface](#lldpinterface) - [LLDPSpec](#lldpspec) +- [MacSecSpec](#macsecspec) - [ManagementAccessSpec](#managementaccessspec) - [NTPSpec](#ntpspec) - [NetworkAttachmentSpec](#networkattachmentspec) @@ -3011,6 +3044,7 @@ _Appears in:_ - [PIMInterface](#piminterface) - [PIMSpec](#pimspec) - [Peer](#peer) +- [PreSharedKey](#presharedkey) - [PrefixSetMatchCondition](#prefixsetmatchcondition) - [PrefixSetSpec](#prefixsetspec) - [ProbeSpec](#probespec) @@ -3085,6 +3119,81 @@ _Appears in:_ | `vlan` _[VLANSource](#vlansource)_ | VLAN constrains the lookup to a specific VLAN. | | Optional: \{\}
| +#### MacSec + + + +MacSec is the Schema for the macsecs API. + + + + + +| Field | Description | Default | Validation | +| --- | --- | --- | --- | +| `apiVersion` _string_ | `networking.metal.ironcore.dev/v1alpha1` | | | +| `kind` _string_ | `MacSec` | | | +| `metadata` _[ObjectMeta](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.35/#objectmeta-v1-meta)_ | Refer to Kubernetes API documentation for fields of `metadata`. | | | +| `spec` _[MacSecSpec](#macsecspec)_ | | | | +| `status` _[MacSecStatus](#macsecstatus)_ | | | | + + +#### MacSecPolicy + + + + + + + +_Appears in:_ +- [MacSecSpec](#macsecspec) + +| Field | Description | Default | Validation | +| --- | --- | --- | --- | +| `cipherSuite` _string_ | CipherSuite is the MACSec cipher suite to be used. | | Required: \{\}
| +| `confidentialityOffset` _[ConfidentialityOffset](#confidentialityoffset)_ | ConfidentialityOffset specifies the number of octets in an Ethernet frame that are sent in unencrypted plain-text | | Required: \{\}
| +| `keyServerPriority` _integer_ | KeyServerPriority specifies the key server priority used by the MACsec Key Agreement (MKA) protocol to select the key server when
MACsec is enabled using static connectivity association key (CAK) security mode. | | Required: \{\}
| +| `relayProtection` _integer_ | RelayProtection specifies the number of out-of-order packets that can be received before the secure channel is considered to be inoperable.
A value of 0 means that frames are accepted only in the correct order. | | Required: \{\}
| + + +#### MacSecSpec + + + +MacSecSpec defines the desired state of MacSec. + + + +_Appears in:_ +- [MacSec](#macsec) + +| Field | Description | Default | Validation | +| --- | --- | --- | --- | +| `deviceRef` _[LocalObjectReference](#localobjectreference)_ | DeviceName is the name of the Device this object belongs to. The Device object must exist in the same namespace.
Immutable. | | Required: \{\}
| +| `providerConfigRef` _[TypedLocalObjectReference](#typedlocalobjectreference)_ | ProviderConfigRef is a reference to a resource holding the provider-specific configuration.
This reference is used to link the MacSec to its provider-specific configuration. | | Optional: \{\}
| +| `name` _string_ | Name is the name of the MacSec configuration.
Immutable. | | MaxLength: 255
MinLength: 1
Required: \{\}
| +| `interfaceRef` _[LocalObjectReference](#localobjectreference)_ | InterfaceRef is a reference to the interface on which MACsec is enabled. The interface must exist on the Device specified by deviceRef.
Immutable. | | Required: \{\}
| +| `preSharedKeys` _[PreSharedKey](#presharedkey) array_ | PreSharedKeys is a list of pre-shared keys used for MACSec encryption.
Specify multiple keys if needed for redundancy rotation.
Sort them in the order they should be used for redundancy rotation. | | MinItems: 1
Required: \{\}
| +| `policy` _[MacSecPolicy](#macsecpolicy)_ | Policy defines the MACSec policy configuration. | | Required: \{\}
| + + +#### MacSecStatus + + + +MacSecStatus defines the observed state of MacSec. + + + +_Appears in:_ +- [MacSec](#macsec) + +| Field | Description | Default | Validation | +| --- | --- | --- | --- | +| `conditions` _[Condition](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.35/#condition-v1-meta) array_ | The conditions are a list of status objects that describe the state of the MacSec. | | Optional: \{\}
| + + #### ManagementAccess @@ -3792,6 +3901,27 @@ _Appears in:_ | `Local` | PortIDTypeLocal is an alphanumeric string that and is locally assigned
| +#### PreSharedKey + + + +PreSharedKey defines a single MACsec pre-shared key: a reference to the Secret +holding the raw key material plus the key metadata. + + + +_Appears in:_ +- [MacSecSpec](#macsecspec) + +| Field | Description | Default | Validation | +| --- | --- | --- | --- | +| `secretRef` _[LocalObjectReference](#localobjectreference)_ | SecretRef references the Secret (in the same namespace) holding the raw key material
under the data key "key". The Secret must contain only the key material. | | Required: \{\}
| +| `connectivityKeyName` _string_ | ConnectivityKeyName is the connectivity association key name (CKN). | | MaxLength: 64
MinLength: 1
Required: \{\}
| +| `algorithm` _[CryptographicAlgorithm](#cryptographicalgorithm)_ | Algorithm is the cryptographic algorithm used for this key. | | Enum: [AES-128-CMAC AES-256-CMAC]
Required: \{\}
| +| `startTime` _[Time](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.35/#time-v1-meta)_ | StartTime is when this key becomes active.
The clock and date rendered into the device configuration are taken in UTC,
so a value with any timezone offset is normalized to UTC before being realized. | | Required: \{\}
| +| `endTime` _[Time](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.35/#time-v1-meta)_ | EndTime is when this key expires. Must be after startTime.
The clock and date rendered into the device configuration are taken in UTC,
so a value with any timezone offset is normalized to UTC before being realized.
Validates if the string is a valid datetime in RFC3339 format. | | Required: \{\}
| + + #### PrefixEntry @@ -4720,6 +4850,7 @@ _Appears in:_ - [IndexSpec](#indexspec) - [InterfaceSpec](#interfacespec) - [LLDPSpec](#lldpspec) +- [MacSecSpec](#macsecspec) - [ManagementAccessSpec](#managementaccessspec) - [NTPSpec](#ntpspec) - [NetworkVirtualizationEdgeSpec](#networkvirtualizationedgespec) diff --git a/internal/controller/core/macsec_controller.go b/internal/controller/core/macsec_controller.go new file mode 100644 index 000000000..87f81e1ae --- /dev/null +++ b/internal/controller/core/macsec_controller.go @@ -0,0 +1,557 @@ +// SPDX-FileCopyrightText: 2026 SAP SE or an SAP affiliate company and IronCore contributors +// SPDX-License-Identifier: Apache-2.0 + +package core + +import ( + "context" + "errors" + "fmt" + "time" + + corev1 "k8s.io/api/core/v1" + + "k8s.io/apimachinery/pkg/api/equality" + apierrors "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/api/meta" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + kerrors "k8s.io/apimachinery/pkg/util/errors" + "k8s.io/client-go/tools/events" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/builder" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" + "sigs.k8s.io/controller-runtime/pkg/event" + "sigs.k8s.io/controller-runtime/pkg/handler" + "sigs.k8s.io/controller-runtime/pkg/predicate" + "sigs.k8s.io/controller-runtime/pkg/reconcile" + + "github.com/ironcore-dev/network-operator/api/core/v1alpha1" + "github.com/ironcore-dev/network-operator/internal/apistatus" + "github.com/ironcore-dev/network-operator/internal/conditions" + "github.com/ironcore-dev/network-operator/internal/deviceutil" + "github.com/ironcore-dev/network-operator/internal/paused" + "github.com/ironcore-dev/network-operator/internal/provider" + "github.com/ironcore-dev/network-operator/internal/resourcelock" +) + +// MacSecReconciler reconciles a MacSec object +type MacSecReconciler struct { + client.Client + Scheme *runtime.Scheme + + // WatchFilterValue is the label value used to filter events prior to reconciliation. + WatchFilterValue string + + // Recorder is used to record events for the controller. + // More info: https://book.kubebuilder.io/reference/raising-events + Recorder events.EventRecorder + + // Locker is used to synchronize operations on resources targeting the same device. + Locker *resourcelock.ResourceLocker + + // RequeueInterval is the duration after which the controller should requeue the reconciliation, + // regardless of changes. + RequeueInterval time.Duration +} + +// +kubebuilder:rbac:groups=networking.metal.ironcore.dev,resources=macsecs,verbs=get;list;watch;create;update;patch;delete +// +kubebuilder:rbac:groups=networking.metal.ironcore.dev,resources=macsecs/status,verbs=get;update;patch +// +kubebuilder:rbac:groups=networking.metal.ironcore.dev,resources=macsecs/finalizers,verbs=update +// +kubebuilder:rbac:groups=networking.metal.ironcore.dev,resources=devices,verbs=get;list;watch +// +kubebuilder:rbac:groups=networking.metal.ironcore.dev,resources=interfaces,verbs=get;list;watch +// +kubebuilder:rbac:groups=events.k8s.io,resources=events,verbs=create;patch + +// Reconcile is part of the main kubernetes reconciliation loop which aims to +// move the current state of the cluster closer to the desired state. +// +// For more details, check Reconcile and its Result here: +// - https://pkg.go.dev/sigs.k8s.io/controller-runtime@v0.21.0/pkg/reconcile +func (r *MacSecReconciler) Reconcile(ctx context.Context, req ctrl.Request) (_ ctrl.Result, reterr error) { + log := ctrl.LoggerFrom(ctx) + log.V(3).Info("Reconciling resource") + + obj := new(v1alpha1.MacSec) + if err := r.Get(ctx, req.NamespacedName, obj); err != nil { + if apierrors.IsNotFound(err) { + // If the custom resource is not found then it usually means that it was deleted or not created + // In this way, we will stop the reconciliation + log.Info("Resource not found. Ignoring since object must be deleted") + return ctrl.Result{}, nil + } + // Error reading the object - requeue the request. + log.Error(err, "Failed to get resource") + return ctrl.Result{}, err + } + + device, err := deviceutil.GetDeviceByName(ctx, r, obj.Namespace, obj.Spec.DeviceRef.Name) + if err != nil { + return ctrl.Result{}, err + } + + prov, err := provider.LoadProvider[provider.MacSecProvider](device.Spec.Provider) + if err != nil { + reason := v1alpha1.NotImplementedReason + if _, ok := errors.AsType[provider.NotFoundError](err); ok { + reason = v1alpha1.ProviderNotFoundReason + } + if meta.SetStatusCondition(&obj.Status.Conditions, metav1.Condition{ + Type: v1alpha1.ReadyCondition, + Status: metav1.ConditionFalse, + Reason: reason, + Message: err.Error(), + }) { + return ctrl.Result{}, r.Status().Update(ctx, obj) + } + return ctrl.Result{}, nil + } + + orig := obj.DeepCopy() + if isPaused, err := paused.EnsureCondition(ctx, r.Client, device, obj); isPaused || err != nil { + return ctrl.Result{}, err + } + + release, err := r.Locker.AcquireLock(ctx, device.Name, obj) + if err != nil { + if errors.Is(err, resourcelock.ErrLockAlreadyHeld) { + log.Info("Device is already locked, requeuing reconciliation") + return ctrl.Result{RequeueAfter: Jitter(time.Second), Priority: new(LockWaitPriorityHigh)}, nil + } + log.Error(err, "Failed to acquire device lock") + return ctrl.Result{}, err + } + defer func() { + if err := release(ctx); err != nil { + log.Error(err, "Failed to release device lock") + reterr = kerrors.NewAggregate([]error{reterr, err}) + } + }() + + conn, err := deviceutil.GetDeviceConnection(ctx, r, device) + if err != nil { + return ctrl.Result{}, err + } + + s := &macSecScope{ + Device: device, + MacSec: obj, + Connection: conn, + Provider: prov, + } + + if !obj.DeletionTimestamp.IsZero() { + if controllerutil.ContainsFinalizer(obj, v1alpha1.FinalizerName) { + if err := r.finalize(ctx, s); err != nil { + log.Error(err, "Failed to finalize MacSec resource") + return ctrl.Result{}, err + } + controllerutil.RemoveFinalizer(obj, v1alpha1.FinalizerName) + if err := r.Update(ctx, obj); err != nil { + log.Error(err, "Failed to remove finalizer from MacSec resource") + return ctrl.Result{}, err + } + } + log.Info("Resource is being deleted, skipping reconciliation") + return ctrl.Result{}, nil + } + + if obj.Spec.ProviderConfigRef != nil { + s.ProviderConfig, err = provider.GetProviderConfig(ctx, r, obj.Namespace, obj.Spec.ProviderConfigRef) + if err != nil { + return ctrl.Result{}, err + } + } + + // More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/finalizers + if !controllerutil.ContainsFinalizer(obj, v1alpha1.FinalizerName) { + controllerutil.AddFinalizer(obj, v1alpha1.FinalizerName) + if err := r.Update(ctx, obj); err != nil { + log.Error(err, "Failed to add finalizer to resource") + return ctrl.Result{}, err + } + log.Info("Added finalizer to resource") + return ctrl.Result{}, nil + } + + if conditions.InitializeConditions(obj, v1alpha1.ReadyCondition, v1alpha1.ConfiguredCondition) { + log.Info("Initializing status conditions") + return ctrl.Result{}, r.Status().Update(ctx, obj) + } + + // Always attempt to update the metadata/status after reconciliation + defer func() { + if !equality.Semantic.DeepEqual(orig.ObjectMeta, obj.ObjectMeta) { + // Pass obj.DeepCopy() to avoid Patch() modifying obj and interfering with status update below + if err := r.Patch(ctx, obj.DeepCopy(), client.MergeFrom(orig)); err != nil { + log.Error(err, "Failed to update resource metadata") + reterr = kerrors.NewAggregate([]error{reterr, err}) + } + } + if !equality.Semantic.DeepEqual(orig.Status, obj.Status) { + if err := r.Status().Patch(ctx, obj, client.MergeFrom(orig)); err != nil { + log.Error(err, "Failed to update status") + reterr = kerrors.NewAggregate([]error{reterr, err}) + } + } + }() + + if err := r.reconcile(ctx, s); err != nil { + log.Error(err, "Failed to reconcile resource") + return ctrl.Result{}, apistatus.WrapTerminalError(err) + } + + return ctrl.Result{RequeueAfter: Jitter(r.RequeueInterval)}, nil +} + +// SetupWithManager sets up the controller with the Manager. +func (r *MacSecReconciler) SetupWithManager(ctx context.Context, mgr ctrl.Manager) error { + labelSelector := metav1.LabelSelector{} + if r.WatchFilterValue != "" { + labelSelector.MatchLabels = map[string]string{v1alpha1.WatchLabel: r.WatchFilterValue} + } + + filter, err := predicate.LabelSelectorPredicate(labelSelector) + if err != nil { + return fmt.Errorf("failed to create label selector predicate: %w", err) + } + + if err := mgr.GetFieldIndexer().IndexField(ctx, &v1alpha1.MacSec{}, v1alpha1.DeviceRefIndexKey, func(obj client.Object) []string { + m := obj.(*v1alpha1.MacSec) + return []string{m.Spec.DeviceRef.Name} + }); err != nil { + return err + } + + if err := mgr.GetFieldIndexer().IndexField(ctx, &v1alpha1.MacSec{}, v1alpha1.InterfaceRefIndexKey, func(obj client.Object) []string { + m := obj.(*v1alpha1.MacSec) + return []string{m.Spec.InterfaceRef.Name} + }); err != nil { + return err + } + + return ctrl.NewControllerManagedBy(mgr). + For(&v1alpha1.MacSec{}). + Named("macsec"). + WithEventFilter(filter). + // Watches enqueues MacSecs for referenced Secret resources. + Watches( + &corev1.Secret{}, + handler.EnqueueRequestsFromMapFunc(r.secretToMacSec), + builder.WithPredicates(predicate.Funcs{ + GenericFunc: func(e event.GenericEvent) bool { + return false + }, + }), + ). + // Watches enqueues MacSec for updatesi in referenced Interface resources. + // Watches enqueues MacSecs for updates in referenced Interface resources. + Watches( + &v1alpha1.Interface{}, + handler.EnqueueRequestsFromMapFunc(r.interfaceToMacSecs), + builder.WithPredicates(predicate.Funcs{ + UpdateFunc: func(e event.UpdateEvent) bool { + return false + }, + GenericFunc: func(e event.GenericEvent) bool { + return false + }, + }), + ). + // Watches enqueues MacSecs for updates in referenced Device resources. + // Triggers on create, delete, and update events when the device's effective pause state changes. + Watches( + &v1alpha1.Device{}, + handler.EnqueueRequestsFromMapFunc(r.deviceToMacSecs), + builder.WithPredicates(predicate.Funcs{ + UpdateFunc: func(e event.UpdateEvent) bool { + return paused.DevicePausedChanged(e.ObjectOld, e.ObjectNew) + }, + GenericFunc: func(e event.GenericEvent) bool { + return false + }, + }), + ). + Complete(r) +} + +// macSecScope holds the different objects that are read and used during the reconcile. +type macSecScope struct { + Device *v1alpha1.Device + MacSec *v1alpha1.MacSec + Interface *v1alpha1.Interface + Connection *deviceutil.Connection + ProviderConfig *provider.ProviderConfig + Secrets []corev1.Secret + Provider provider.MacSecProvider +} + +func (r *MacSecReconciler) reconcile(ctx context.Context, s *macSecScope) (reterr error) { + if s.MacSec.Labels == nil { + s.MacSec.Labels = make(map[string]string) + } + + s.MacSec.Labels[v1alpha1.DeviceLabel] = s.Device.Name + + // Ensure the MacSec is owned by the Device. + if !controllerutil.HasControllerReference(s.MacSec) { + if err := controllerutil.SetOwnerReference(s.Device, s.MacSec, r.Scheme, controllerutil.WithBlockOwnerDeletion(true)); err != nil { + return err + } + } + + defer func() { + conditions.RecomputeReady(s.MacSec) + }() + + intf, err := r.reconcileInterface(ctx, s) + if err != nil { + return err + } + s.Interface = intf + + if err := r.reconcilePreSharedKeys(ctx, s); err != nil { + return err + } + + if err := s.Provider.Connect(ctx, s.Connection); err != nil { + return fmt.Errorf("failed to connect to provider: %w", err) + } + defer func() { + if err := s.Provider.Disconnect(ctx, s.Connection); err != nil { + reterr = kerrors.NewAggregate([]error{reterr, err}) + } + }() + // Ensure the MacSec is realized on the provider. + err = s.Provider.EnsureMacSec(ctx, &provider.EnsureMacSecRequest{ + MacSec: s.MacSec, + Secrets: s.Secrets, + SourceInterface: s.Interface, + }) + + cond := conditions.FromError(err) + conditions.Set(s.MacSec, cond) + + return err +} + +func (r *MacSecReconciler) finalize(ctx context.Context, s *macSecScope) (reterr error) { + // Resolve the bound interface so the provider can remove the interface + // binding. If the interface is already gone, there is nothing left to unbind. + intf := new(v1alpha1.Interface) + key := client.ObjectKey{Name: s.MacSec.Spec.InterfaceRef.Name, Namespace: s.MacSec.Namespace} + if err := r.Get(ctx, key, intf); err != nil { + if !apierrors.IsNotFound(err) { + return fmt.Errorf("failed to get referenced interface %q: %w", key, err) + } + intf = nil + } + + if err := s.Provider.Connect(ctx, s.Connection); err != nil { + return fmt.Errorf("failed to connect to provider: %w", err) + } + defer func() { + if err := s.Provider.Disconnect(ctx, s.Connection); err != nil { + reterr = kerrors.NewAggregate([]error{reterr, err}) + } + }() + + return s.Provider.DeleteMacSec(ctx, &provider.DeleteMacSecRequest{ + MacSec: s.MacSec, + SourceInterface: intf, + }) +} + +// preSharedKeyDataKey is the Secret data key holding the raw MACsec key material. +const preSharedKeyDataKey = "key" + +// reconcilePreSharedKeys validates that all pre-shared key secrets referenced in the MacSec +// spec exist and contain the raw key material, storing them on the scope for the provider. +func (r *MacSecReconciler) reconcilePreSharedKeys(ctx context.Context, s *macSecScope) error { + secrets := make([]corev1.Secret, 0, len(s.MacSec.Spec.PreSharedKeys)) + for i := range s.MacSec.Spec.PreSharedKeys { + psk := &s.MacSec.Spec.PreSharedKeys[i] + key := client.ObjectKey{Namespace: s.MacSec.Namespace, Name: psk.SecretRef.Name} + secret := new(corev1.Secret) + if err := r.Get(ctx, key, secret); err != nil { + if apierrors.IsNotFound(err) { + conditions.Set(s.MacSec, metav1.Condition{ + Type: v1alpha1.ConfiguredCondition, + Status: metav1.ConditionFalse, + Reason: v1alpha1.SecretNotFoundReason, + Message: fmt.Sprintf("referenced pre-shared key secret %q not found", key), + }) + return reconcile.TerminalError(fmt.Errorf("referenced pre-shared key secret %q not found", key)) + } + return fmt.Errorf("failed to get referenced pre-shared key secret %q: %w", key, err) + } + if _, ok := secret.Data[preSharedKeyDataKey]; !ok { + conditions.Set(s.MacSec, metav1.Condition{ + Type: v1alpha1.ConfiguredCondition, + Status: metav1.ConditionFalse, + Reason: v1alpha1.SecretNotFoundReason, + Message: fmt.Sprintf("pre-shared key secret %q does not contain field data.%q", key, preSharedKeyDataKey), + }) + return reconcile.TerminalError(fmt.Errorf("pre-shared key secret %q does not contain field data.%q", key, preSharedKeyDataKey)) + } + + secrets = append(secrets, *secret) + } + s.Secrets = secrets + return nil +} + +func (r *MacSecReconciler) reconcileInterface(ctx context.Context, s *macSecScope) (*v1alpha1.Interface, error) { + key := client.ObjectKey{ + Name: s.MacSec.Spec.InterfaceRef.Name, + Namespace: s.MacSec.Namespace, + } + + intf := new(v1alpha1.Interface) + if err := r.Get(ctx, key, intf); err != nil { + if apierrors.IsNotFound(err) { + conditions.Set(s.MacSec, metav1.Condition{ + Type: v1alpha1.ConfiguredCondition, + Status: metav1.ConditionFalse, + Reason: v1alpha1.InterfaceNotFoundReason, + Message: fmt.Sprintf("referenced interface %q not found", key), + }) + return nil, reconcile.TerminalError(fmt.Errorf("referenced interface %q not found", key)) + } + return nil, fmt.Errorf("failed to get referenced interface %q: %w", key, err) + } + + if intf.Spec.DeviceRef.Name != s.Device.Name { + conditions.Set(s.MacSec, metav1.Condition{ + Type: v1alpha1.ConfiguredCondition, + Status: metav1.ConditionFalse, + Reason: v1alpha1.CrossDeviceReferenceReason, + Message: fmt.Sprintf("referenced interface %q does not belong to device %q", intf.Name, s.Device.Name), + }) + return nil, reconcile.TerminalError(fmt.Errorf("referenced interface %q does not belong to device %q", intf.Name, s.Device.Name)) + } + + // Ensure the referenced interface is of type Physical + if intf.Spec.Type != v1alpha1.InterfaceTypePhysical { + conditions.Set(s.MacSec, metav1.Condition{ + Type: v1alpha1.ConfiguredCondition, + Status: metav1.ConditionFalse, + Reason: v1alpha1.InvalidInterfaceTypeReason, + Message: fmt.Sprintf("referenced interface %q is not of type %q", intf.Name, v1alpha1.InterfaceTypePhysical), + }) + return nil, reconcile.TerminalError(fmt.Errorf("referenced interface %q is not of type %q", intf.Name, v1alpha1.InterfaceTypePhysical)) + } + + // Set the MacSec label on the interface if it is not already set + if intf.Labels == nil { + intf.Labels = make(map[string]string) + } + + if intf.Labels[v1alpha1.MacSecLabel] != s.MacSec.Name { + intf.Labels[v1alpha1.MacSecLabel] = s.MacSec.Name + if err := r.Update(ctx, intf); err != nil { + return nil, fmt.Errorf("failed to update member interface %q labels: %w", intf.Name, err) + } + } + + return intf, nil +} + +// secretToMacSec is a [handler.MapFunc] to be used to enqueue requests for reconciliation +// for a MacSec to update when one of its referenced Secrets gets updated. +func (r *MacSecReconciler) secretToMacSec(ctx context.Context, obj client.Object) []ctrl.Request { + secret, ok := obj.(*corev1.Secret) + if !ok { + panic(fmt.Sprintf("Expected a Secret but got a %T", obj)) + } + + log := ctrl.LoggerFrom(ctx) + + macsecs := new(v1alpha1.MacSecList) + if err := r.List(ctx, macsecs); err != nil { + log.Error(err, "Failed to list MacSecs") + return nil + } + + requests := []ctrl.Request{} + for _, macsec := range macsecs.Items { + // Check if this secret is referenced by any of the pre-shared keys + for _, psk := range macsec.Spec.PreSharedKeys { + if psk.SecretRef.Name == secret.Name && macsec.Namespace == secret.Namespace { + log.Info("Enqueuing MacSec for reconciliation") + requests = append(requests, ctrl.Request{ + Name: macsec.Name, + Namespace: macsec.Namespace, + }) + } + } + } + + return requests +} + +// interfaceToMacSecs is a [handler.MapFunc] to be used to enqueue requests for reconciliation +// for MacSecs when their referenced Interface resources get updated. +func (r *MacSecReconciler) interfaceToMacSecs(ctx context.Context, obj client.Object) []ctrl.Request { + intf, ok := obj.(*v1alpha1.Interface) + if !ok { + panic(fmt.Sprintf("Expected an Interface but got a %T", obj)) + } + + log := ctrl.LoggerFrom(ctx) + + macsecs := new(v1alpha1.MacSecList) + if err := r.List( + ctx, macsecs, + client.InNamespace(intf.Namespace), + client.MatchingFields{v1alpha1.InterfaceRefIndexKey: intf.Name}, + ); err != nil { + log.Error(err, "Failed to list MacSecs") + return nil + } + + requests := make([]ctrl.Request, 0, len(macsecs.Items)) + for i := range macsecs.Items { + m := &macsecs.Items[i] + log.V(2).Info("Enqueuing MacSec for reconciliation", "MacSec", m.Name) + requests = append(requests, ctrl.Request{ + Name: m.Name, + Namespace: m.Namespace, + }) + } + + return requests +} + +// deviceToMacSecs is a [handler.MapFunc] to be used to enqueue requests for reconciliation +// for MacSecs when their referenced Device's effective pause state changes. +func (r *MacSecReconciler) deviceToMacSecs(ctx context.Context, obj client.Object) []ctrl.Request { + device, ok := obj.(*v1alpha1.Device) + if !ok { + panic(fmt.Sprintf("Expected a Device but got a %T", obj)) + } + + log := ctrl.LoggerFrom(ctx) + + macsecs := new(v1alpha1.MacSecList) + if err := r.List( + ctx, macsecs, + client.InNamespace(device.Namespace), + client.MatchingFields{v1alpha1.DeviceRefIndexKey: device.Name}, + ); err != nil { + log.Error(err, "Failed to list MacSecs") + return nil + } + + requests := make([]ctrl.Request, 0, len(macsecs.Items)) + for i := range macsecs.Items { + m := &macsecs.Items[i] + log.V(2).Info("Enqueuing MacSec for reconciliation", "MacSec", m.Name) + requests = append(requests, ctrl.Request{ + Name: m.Name, + Namespace: m.Namespace, + }) + } + + return requests +} diff --git a/internal/controller/core/macsec_controller_test.go b/internal/controller/core/macsec_controller_test.go new file mode 100644 index 000000000..a3d1ab32b --- /dev/null +++ b/internal/controller/core/macsec_controller_test.go @@ -0,0 +1,311 @@ +// SPDX-FileCopyrightText: 2025 SAP SE or an SAP affiliate company and IronCore contributors +// SPDX-License-Identifier: Apache-2.0 + +package core + +import ( + "time" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + corev1 "k8s.io/api/core/v1" + apierrors "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/api/meta" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" + + "github.com/ironcore-dev/network-operator/api/core/v1alpha1" +) + +var _ = Describe("MacSec Controller", func() { + Context("When reconciling a resource", func() { + const secretName = "macsec-secret" + + var ( + name string + intf string + key client.ObjectKey + device *v1alpha1.Device + ) + + BeforeEach(func() { + By("Creating the Device that owns the MacSec resource") + device = &v1alpha1.Device{ + GenerateName: "test-macsec-", + Namespace: metav1.NamespaceDefault, + Spec: v1alpha1.DeviceSpec{ + Endpoint: v1alpha1.Endpoint{ + Address: "192.168.10.2:9339", + }, + Provider: "test-provider", + }, + } + Expect(k8sClient.Create(ctx, device)).To(Succeed()) + name = device.Name + key = client.ObjectKey{Name: name, Namespace: metav1.NamespaceDefault} + + By("Creating the Interface referenced by the MacSec resource") + intf = name + iface := &v1alpha1.Interface{ + Name: intf, + Namespace: metav1.NamespaceDefault, + Spec: v1alpha1.InterfaceSpec{ + DeviceRef: v1alpha1.LocalObjectReference{Name: name}, + Name: intf, + AdminState: v1alpha1.AdminStateUp, + MTU: 9000, + Type: v1alpha1.InterfaceTypePhysical, + }, + } + Expect(k8sClient.Create(ctx, iface)).To(Succeed()) + + By("Creating the pre-shared key Secret referenced by the MacSec resource") + secret := &corev1.Secret{ + Name: secretName, + Namespace: metav1.NamespaceDefault, + Data: map[string][]byte{ + "key": []byte("0123456789abcdef0123456789abcdef"), + }, + Type: corev1.SecretTypeOpaque, + } + Expect(k8sClient.Create(ctx, secret)).To(Succeed()) + }) + + AfterEach(func() { + By("Cleaning up the MacSec resource") + macSec := &v1alpha1.MacSec{} + macSec.Name = name + macSec.Namespace = metav1.NamespaceDefault + Expect(client.IgnoreNotFound(k8sClient.Delete(ctx, macSec))).To(Succeed()) + + By("Verifying the resource is removed from the provider") + Eventually(func(g Gomega) { + g.Expect(testDevices.StateFor(name).MacSec.Has("sven-test")).To(BeFalse()) + }).Should(Succeed()) + + By("Waiting for the MacSec to be fully deleted") + Eventually(func(g Gomega) { + err := k8sClient.Get(ctx, key, &v1alpha1.MacSec{}) + g.Expect(apierrors.IsNotFound(err)).To(BeTrue()) + }).Should(Succeed()) + + By("Cleaning up the Interface resource") + iface := &v1alpha1.Interface{} + iface.Name = intf + iface.Namespace = metav1.NamespaceDefault + Expect(client.IgnoreNotFound(k8sClient.Delete(ctx, iface))).To(Succeed()) + + By("Cleaning up the Secret resource") + secret := &corev1.Secret{} + secret.Name = secretName + secret.Namespace = metav1.NamespaceDefault + Expect(client.IgnoreNotFound(k8sClient.Delete(ctx, secret))).To(Succeed()) + + By("Cleaning up the Device resource") + dev := &v1alpha1.Device{} + dev.Name = name + dev.Namespace = metav1.NamespaceDefault + Expect(client.IgnoreNotFound(k8sClient.Delete(ctx, dev))).To(Succeed()) + }) + + It("Should successfully reconcile a MacSec resource", func() { + By("Creating a MacSec resource") + macSec := &v1alpha1.MacSec{ + Name: name, + Namespace: metav1.NamespaceDefault, + Spec: v1alpha1.MacSecSpec{ + DeviceRef: v1alpha1.LocalObjectReference{Name: name}, + InterfaceRef: v1alpha1.LocalObjectReference{Name: intf}, + Name: "sven-test", + PreSharedKeys: []v1alpha1.PreSharedKey{{ + SecretRef: v1alpha1.LocalObjectReference{Name: secretName}, + ConnectivityKeyName: "someID", + Algorithm: v1alpha1.AES256CMAC, + StartTime: metav1.Now(), + EndTime: metav1.NewTime(metav1.Now().Add(time.Hour)), + }}, + Policy: &v1alpha1.MacSecPolicy{CipherSuite: "GCM-AES-256"}, + }, + } + Expect(k8sClient.Create(ctx, macSec)).To(Succeed()) + + By("Adding a finalizer to the resource") + Eventually(func(g Gomega) { + resource := &v1alpha1.MacSec{} + g.Expect(k8sClient.Get(ctx, key, resource)).To(Succeed()) + g.Expect(controllerutil.ContainsFinalizer(resource, v1alpha1.FinalizerName)).To(BeTrue()) + }).Should(Succeed()) + + By("Adding the device label to the resource") + Eventually(func(g Gomega) { + resource := &v1alpha1.MacSec{} + g.Expect(k8sClient.Get(ctx, key, resource)).To(Succeed()) + g.Expect(resource.Labels).To(HaveKeyWithValue(v1alpha1.DeviceLabel, name)) + }).Should(Succeed()) + + By("Adding the device as an owner reference") + Eventually(func(g Gomega) { + resource := &v1alpha1.MacSec{} + g.Expect(k8sClient.Get(ctx, key, resource)).To(Succeed()) + g.Expect(resource.OwnerReferences).To(HaveLen(1)) + g.Expect(resource.OwnerReferences[0].Kind).To(Equal("Device")) + g.Expect(resource.OwnerReferences[0].Name).To(Equal(name)) + }).Should(Succeed()) + + By("Updating the resource status") + Eventually(func(g Gomega) { + resource := &v1alpha1.MacSec{} + g.Expect(k8sClient.Get(ctx, key, resource)).To(Succeed()) + + ready := meta.FindStatusCondition(resource.Status.Conditions, v1alpha1.ReadyCondition) + g.Expect(ready).NotTo(BeNil()) + g.Expect(ready.Status).To(Equal(metav1.ConditionTrue)) + + configured := meta.FindStatusCondition(resource.Status.Conditions, v1alpha1.ConfiguredCondition) + g.Expect(configured).NotTo(BeNil()) + g.Expect(configured.Status).To(Equal(metav1.ConditionTrue)) + + paused := meta.FindStatusCondition(resource.Status.Conditions, v1alpha1.PausedCondition) + g.Expect(paused).NotTo(BeNil()) + g.Expect(paused.Status).To(Equal(metav1.ConditionFalse)) + }).Should(Succeed()) + + By("Ensuring the resource is created in the provider") + Eventually(func(g Gomega) { + g.Expect(testDevices.StateFor(name).MacSec.Has("sven-test")).To(BeTrue()) + }).Should(Succeed()) + }) + + It("Should report NotConfigured when the referenced interface is missing", func() { + By("Creating a MacSec referencing a non-existent interface") + macSec := &v1alpha1.MacSec{ + Name: name, + Namespace: metav1.NamespaceDefault, + Spec: v1alpha1.MacSecSpec{ + DeviceRef: v1alpha1.LocalObjectReference{Name: name}, + InterfaceRef: v1alpha1.LocalObjectReference{Name: "does-not-exist"}, + Name: "sven-test", + PreSharedKeys: []v1alpha1.PreSharedKey{{ + SecretRef: v1alpha1.LocalObjectReference{Name: secretName}, + ConnectivityKeyName: "1234", + Algorithm: v1alpha1.AES256CMAC, + StartTime: metav1.Now(), + EndTime: metav1.NewTime(metav1.Now().Add(time.Hour)), + }}, + Policy: &v1alpha1.MacSecPolicy{ + CipherSuite: "GCM-AES-256", + ConfidentialityOffset: v1alpha1.ZeroBytes, + }, + }, + } + Expect(k8sClient.Create(ctx, macSec)).To(Succeed()) + + By("Reporting the Configured condition as InterfaceNotFound") + Eventually(func(g Gomega) { + resource := &v1alpha1.MacSec{} + g.Expect(k8sClient.Get(ctx, key, resource)).To(Succeed()) + configured := meta.FindStatusCondition(resource.Status.Conditions, v1alpha1.ConfiguredCondition) + g.Expect(configured).NotTo(BeNil()) + g.Expect(configured.Status).To(Equal(metav1.ConditionFalse)) + g.Expect(configured.Reason).To(Equal(v1alpha1.InterfaceNotFoundReason)) + }).Should(Succeed()) + }) + + It("Should report NotConfigured when the interface belongs to another device", func() { + By("Creating a second device and an interface on it") + otherDevice := &v1alpha1.Device{ + GenerateName: "test-macsec-other-", + Namespace: metav1.NamespaceDefault, + Spec: v1alpha1.DeviceSpec{ + Endpoint: v1alpha1.Endpoint{Address: "192.168.10.3:9339"}, + Provider: "test-provider", + }, + } + Expect(k8sClient.Create(ctx, otherDevice)).To(Succeed()) + + otherIntf := &v1alpha1.Interface{ + GenerateName: "test-macsec-other-", + Namespace: metav1.NamespaceDefault, + Spec: v1alpha1.InterfaceSpec{ + DeviceRef: v1alpha1.LocalObjectReference{Name: otherDevice.Name}, + Name: "other-interface", + AdminState: v1alpha1.AdminStateUp, + MTU: 9000, + Type: v1alpha1.InterfaceTypePhysical, + }, + } + Expect(k8sClient.Create(ctx, otherIntf)).To(Succeed()) + DeferCleanup(func() { + Expect(client.IgnoreNotFound(k8sClient.Delete(ctx, otherIntf))).To(Succeed()) + Expect(client.IgnoreNotFound(k8sClient.Delete(ctx, otherDevice))).To(Succeed()) + }) + + By("Creating a MacSec on the device referencing the other device's interface") + macSec := &v1alpha1.MacSec{ + Name: name, + Namespace: metav1.NamespaceDefault, + Spec: v1alpha1.MacSecSpec{ + DeviceRef: v1alpha1.LocalObjectReference{Name: name}, + InterfaceRef: v1alpha1.LocalObjectReference{Name: otherIntf.Name}, + Name: "sven-test", + PreSharedKeys: []v1alpha1.PreSharedKey{{ + SecretRef: v1alpha1.LocalObjectReference{Name: secretName}, + ConnectivityKeyName: "someID", + Algorithm: v1alpha1.AES256CMAC, + StartTime: metav1.Now(), + EndTime: metav1.NewTime(metav1.Now().Add(time.Hour)), + }}, + Policy: &v1alpha1.MacSecPolicy{ + CipherSuite: "GCM-AES-256", + ConfidentialityOffset: v1alpha1.ZeroBytes, + }, + }, + } + Expect(k8sClient.Create(ctx, macSec)).To(Succeed()) + Eventually(func(g Gomega) { + resource := &v1alpha1.MacSec{} + g.Expect(k8sClient.Get(ctx, key, resource)).To(Succeed()) + configured := meta.FindStatusCondition(resource.Status.Conditions, v1alpha1.ConfiguredCondition) + g.Expect(configured).NotTo(BeNil()) + g.Expect(configured.Status).To(Equal(metav1.ConditionFalse)) + g.Expect(configured.Reason).To(Equal(v1alpha1.CrossDeviceReferenceReason)) + }).Should(Succeed()) + }) + + It("Should report NotConfigured when a pre-shared key secret is missing", func() { + By("Creating a MacSec referencing a non-existent pre-shared key secret") + macSec := &v1alpha1.MacSec{ + Name: name, + Namespace: metav1.NamespaceDefault, + Spec: v1alpha1.MacSecSpec{ + DeviceRef: v1alpha1.LocalObjectReference{Name: name}, + InterfaceRef: v1alpha1.LocalObjectReference{Name: intf}, + Name: "sven-test", + PreSharedKeys: []v1alpha1.PreSharedKey{{ + SecretRef: v1alpha1.LocalObjectReference{Name: "missing-psk-secret"}, + ConnectivityKeyName: "someID", + Algorithm: v1alpha1.AES256CMAC, + StartTime: metav1.Now(), + EndTime: metav1.NewTime(metav1.Now().Add(time.Hour)), + }}, + Policy: &v1alpha1.MacSecPolicy{ + CipherSuite: "GCM-AES-256", + ConfidentialityOffset: v1alpha1.ZeroBytes, + }, + }, + } + Expect(k8sClient.Create(ctx, macSec)).To(Succeed()) + + By("Reporting the Configured condition as SecretNotFound") + Eventually(func(g Gomega) { + resource := &v1alpha1.MacSec{} + g.Expect(k8sClient.Get(ctx, key, resource)).To(Succeed()) + configured := meta.FindStatusCondition(resource.Status.Conditions, v1alpha1.ConfiguredCondition) + g.Expect(configured).NotTo(BeNil()) + g.Expect(configured.Status).To(Equal(metav1.ConditionFalse)) + g.Expect(configured.Reason).To(Equal(v1alpha1.SecretNotFoundReason)) + }).Should(Succeed()) + }) + }) +}) diff --git a/internal/controller/core/suite_test.go b/internal/controller/core/suite_test.go index 3fa346061..2f62c5967 100644 --- a/internal/controller/core/suite_test.go +++ b/internal/controller/core/suite_test.go @@ -386,6 +386,15 @@ var _ = BeforeSuite(func() { }).SetupWithManager(ctx, k8sManager) Expect(err).NotTo(HaveOccurred()) + err = (&MacSecReconciler{ + Client: k8sManager.GetClient(), + Scheme: k8sManager.GetScheme(), + Recorder: recorder, + Locker: testLocker, + RequeueInterval: time.Second, + }).SetupWithManager(ctx, k8sManager) + Expect(err).NotTo(HaveOccurred()) + go func() { defer GinkgoRecover() err = k8sManager.Start(ctx) @@ -460,6 +469,7 @@ var ( _ provider.ConfigBackupProvider = (*Provider)(nil) _ provider.ProbeProvider = (*Provider)(nil) _ provider.StaticRouteProvider = (*Provider)(nil) + _ provider.MacSecProvider = (*Provider)(nil) ) // DeviceState holds per-device mutable state for testing. Each device created in @@ -506,6 +516,7 @@ type DeviceState struct { ConfigBackups []*provider.ConfigBackupFile StorageTotal int64 StaticRoutes sets.Set[string] + MacSec sets.Set[string] } func NewDeviceState() *DeviceState { @@ -529,6 +540,7 @@ func NewDeviceState() *DeviceState { EthernetSegments: make(map[string]string), StorageTotal: int64(1024 * 1024 * 100), StaticRoutes: sets.New[string](), + MacSec: sets.New[string](), } } @@ -1280,3 +1292,23 @@ func (s *DeviceState) SetLLDPNeighbor(interfaceName, sysName, chassisID, portID TTL: time.Duration(ttl) * time.Second, } } + +func (p *Provider) EnsureMacSec(_ context.Context, req *provider.EnsureMacSecRequest) error { + s := p.devices.StateFor(p.deviceName) + s.Lock() + defer s.Unlock() + s.MacSec.Insert(req.MacSec.Spec.Name) + return nil +} + +func (p *Provider) DeleteMacSec(_ context.Context, req *provider.DeleteMacSecRequest) error { + s := p.devices.StateFor(p.deviceName) + s.Lock() + defer s.Unlock() + s.MacSec.Delete(req.MacSec.Spec.Name) + return nil +} + +func (p *Provider) GetMacSecStatus(_ context.Context, _ *provider.EnsureMacSecRequest) (provider.MacSecStatus, error) { + return provider.MacSecStatus{}, nil +} diff --git a/internal/provider/cisco/encryption/type7.go b/internal/provider/cisco/encryption/type7.go new file mode 100644 index 000000000..804878211 --- /dev/null +++ b/internal/provider/cisco/encryption/type7.go @@ -0,0 +1,58 @@ +// SPDX-FileCopyrightText: SAP SE or an SAP affiliate company and IronCore contributors +// SPDX-License-Identifier: Apache-2.0 + +package encryption + +import ( + "fmt" + "strings" +) + +// ciscoType7Key is Cisco's fixed Vigenère translation table (the key) used by +// service-password-encryption (Type 7). All 53 bytes are used. +const ciscoType7Key = "dsfd;kfoA,.iyewrkldJKDHSUBsgvca69834ncxv9873254k;fg87" + +// EncryptType7 encodes plaintext as a Cisco Type 7 password string. +// +// Cisco Type 7 is a weak, reversible obfuscation (NOT real encryption) used by +// service-password-encryption. ciscoType7Key is the fixed Vigenère key: each +// plaintext byte is XORed against a byte of the key, and the result is emitted +// as a two-digit decimal seed index (the salt) followed by the uppercase hex of +// each XORed byte. +// Devices such as IOS-XR require it because leaves like the +// key-chain password (xr:Proprietary-password) reject plaintext. +func EncryptType7(plaintext string, salt int) (string, error) { + var b strings.Builder + + key, err := GenerateKey(salt, plaintext) + if err != nil { + return "", err + } + fmt.Fprintf(&b, "%02d", salt) + for i := range plaintext { + fmt.Fprintf(&b, "%02X", plaintext[i]^key[i]) + } + return b.String(), nil +} + +// SaltCiscoKey returns the Cisco Type 7 key rotated by the given salt. +// For deterministic output, pass a fixed salt +func SaltCiscoKey(salt int) (string, error) { + if salt < 0 || salt >= len(ciscoType7Key) { + return "", fmt.Errorf("invalid salt: %d", salt) + } + return ciscoType7Key[salt:] + ciscoType7Key[:salt], nil +} + +// GenerateKey produces a key of the same length as the plaintext by repeating the salted Cisco key. +func GenerateKey(salt int, plaintext string) (string, error) { + saltedKey, err := SaltCiscoKey(salt) + if err != nil { + return "", err + } + key := make([]byte, len(plaintext)) + for i := range plaintext { + key[i] = saltedKey[i%len(saltedKey)] + } + return string(key), nil +} diff --git a/internal/provider/cisco/encryption/type7_test.go b/internal/provider/cisco/encryption/type7_test.go new file mode 100644 index 000000000..45bb8a822 --- /dev/null +++ b/internal/provider/cisco/encryption/type7_test.go @@ -0,0 +1,96 @@ +// SPDX-FileCopyrightText: 2026 SAP SE or an SAP affiliate company and IronCore contributors +// SPDX-License-Identifier: Apache-2.0 + +package encryption + +import ( + "strings" + "testing" +) + +func TestEncryptType7(t *testing.T) { + tests := []struct { + name string + plaintext string + salt int + want string + }{ + //{name: "empty", plaintext: "", salt: 0, want: "00"}, + { + name: "psk", plaintext: "HelloWorld", salt: 0, + want: "002C160A08543C091D2D48", + }, + { + name: "psk", plaintext: "HelloWorld", salt: 50, + want: "502F5D5B081C310B490702", + }, + //{name: "psk with salt", plaintext: "GEEKSFORGEEKS", salt: 0, want: "GCYCZFMLYLEIM"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got, err := EncryptType7(tt.plaintext, tt.salt); err != nil || got != tt.want { + t.Errorf("EncryptType7(%q, %d) = %q, want %q, err: %v", tt.plaintext, tt.salt, got, tt.want, err) + } + }) + } +} + +func TestGenerateKey(t *testing.T) { + // With salt 0 and a plaintext shorter than the key, GenerateKey repeats the + // unrotated key and the trailing rotation is a no-op, so the result is simply + // the first len(plaintext) characters of ciscoType7Key. + shortPlaintext := "HelloWorld" + wantShort := ciscoType7Key[:len(shortPlaintext)] + + // With a plaintext longer than the 53-byte key, the per-byte repetition wraps: + // positions 0..52 are the full salted key (rotated by salt 5) and positions + // 53..59 start over from its beginning. + salt := 5 + overflowPlaintext := strings.Repeat("x", 60) + wantOverflow := "kfoA,.iyewrkldJKDHSUBsgvca69834ncxv9873254k;fg87dsfd;kfoA,.i" + + tests := []struct { + name string + salt int + plaintext string + want string + wantErr bool + }{ + {name: "salt 0 matches start of key", salt: 0, plaintext: shortPlaintext, want: wantShort}, + {name: "overflow wraps key", salt: salt, plaintext: overflowPlaintext, want: wantOverflow}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := GenerateKey(tt.salt, tt.plaintext) + if (err != nil) != tt.wantErr { + t.Fatalf("GenerateKey(%d, %q) error = %v, wantErr %v", tt.salt, tt.plaintext, err, tt.wantErr) + } + if err == nil && got != tt.want { + t.Errorf("GenerateKey(%d, %q) = %q, want %q", tt.salt, tt.plaintext, got, tt.want) + } + }) + } +} + +func TestSaltCiscoKey(t *testing.T) { + tests := []struct { + name string + salt int + want string + wantErr bool + }{ + {name: "salt 0 returns key unrotated", salt: 0, want: ciscoType7Key}, + {name: "salt 53 is out of range", salt: 53, wantErr: true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := SaltCiscoKey(tt.salt) + if (err != nil) != tt.wantErr { + t.Fatalf("SaltCiscoKey(%d) error = %v, wantErr %v", tt.salt, err, tt.wantErr) + } + if err == nil && got != tt.want { + t.Errorf("SaltCiscoKey(%d) = %q, want %q", tt.salt, got, tt.want) + } + }) + } +} diff --git a/internal/provider/cisco/iosxr/intf.go b/internal/provider/cisco/iosxr/intf.go index 7c03cf431..b904e03aa 100644 --- a/internal/provider/cisco/iosxr/intf.go +++ b/internal/provider/cisco/iosxr/intf.go @@ -96,6 +96,24 @@ type Iface struct { Mode gnmiext.Empty `json:"interface-virtual,omitzero"` Bundle Bundle `json:"Cisco-IOS-XR-bundlemgr-cfg:bundle,omitzero"` SubInterface VlanSubInterface `json:"Cisco-IOS-XR-l2-eth-infra-cfg:vlan-sub-configuration,omitzero"` + + // MacSec binds a MACsec policy and PSK keychain to the interface. Only set for + // physical interfaces that reference a MacSec resource. + // Cisco-IOS-XR-um-macsec-cfg:macsec + MacSec IfaceMacSec `json:"Cisco-IOS-XR-crypto-macsec-mka-if-cfg:macsec,omitzero"` +} + +// IfaceMacSec is the interface-level MACsec binding. It references a MacSecPolicy +// and KeyChain already configured on the device by their shared name. +type IfaceMacSec struct { + PSKKeyChain PSKKeyChain `json:"psk-key-chain,omitzero"` +} + +type PSKKeyChain struct { + // KeyChainName is the name of the keychain to use, matching KeyChain.Name. + KeyChainName string `json:"key-chain-name,omitzero"` + // Policy is the name of the MACsec policy to apply, matching MacSecPolicy.Name. + Policy string `json:"policy-name,omitzero"` } type BundleMember struct { diff --git a/internal/provider/cisco/iosxr/macsec.go b/internal/provider/cisco/iosxr/macsec.go new file mode 100644 index 000000000..31c716fdd --- /dev/null +++ b/internal/provider/cisco/iosxr/macsec.go @@ -0,0 +1,229 @@ +// SPDX-FileCopyrightText: 2026 SAP SE or an SAP affiliate company and IronCore contributors +// SPDX-License-Identifier: Apache-2.0 + +package iosxr + +import ( + "fmt" + "strings" + "time" + + "github.com/ironcore-dev/network-operator/api/core/v1alpha1" + "github.com/ironcore-dev/network-operator/internal/apistatus" + "github.com/ironcore-dev/network-operator/internal/transport/gnmiext" +) + +var ( + _ gnmiext.DataElement = (*MacSecPolicy)(nil) + _ gnmiext.DataElement = (*KeyChain)(nil) + _ gnmiext.DataElement = (*KeyChainOperData)(nil) +) + +const ConfOffsetPrefix = "CONF-OFFSET-%d" + +// preSharedKeyDataKey is the Secret data key holding the raw MACsec key material. +const preSharedKeyDataKey = "key" + +type CipherSuite string + +const ( + CipherSuiteGcmAes256 CipherSuite = "GCM-AES-256" + CipherSuiteGcmAes128 CipherSuite = "GCM-AES-128" + CipherSuiteGcmAesXpn256 CipherSuite = "GCM-AES-XPN-256" + CipherSuiteGcmAesXpn128 CipherSuite = "GCM-AES-XPN-128" +) + +type CryptographicAlgorithm string + +const ( + CryptographicAlgorithmMd5 CryptographicAlgorithm = "md5" + CryptographicAlgorithmSha1 CryptographicAlgorithm = "sha-1" + CryptographicAlgorithmHmacMd5 CryptographicAlgorithm = "hmac-md5" + CryptographicAlgorithmHmacSha1_20 CryptographicAlgorithm = "hmac-sha1-20" + CryptographicAlgorithmHmacSha1_12 CryptographicAlgorithm = "hmac-sha1-12" + CryptographicAlgorithmHmacSha1_96 CryptographicAlgorithm = "hmac-sha1-96" + CryptographicAlgorithmHmacSha256 CryptographicAlgorithm = "hmac-sha-256" + CryptographicAlgorithmAes128Cmac96 CryptographicAlgorithm = "aes-128-cmac-96" + CryptographicAlgorithmAes256Cmac96 CryptographicAlgorithm = "aes-256-cmac-96" +) + +// ExtractMacSecAlgorithm maps a spec [v1alpha1.CryptographicAlgorithm] to the +// IOS-XR device cryptographic-algorithm string used in a MACsec key. +func ExtractMacSecAlgorithm(algorithm v1alpha1.CryptographicAlgorithm) (string, error) { + switch algorithm { + case v1alpha1.AES128CMAC: + return string(CryptographicAlgorithmAes128Cmac96), nil + case v1alpha1.AES256CMAC: + return string(CryptographicAlgorithmAes256Cmac96), nil + default: + return "", apistatus.NewInvalidArgumentError(apistatus.FieldViolation{ + Field: "spec.preSharedKeys[].algorithm", + Description: fmt.Sprintf("unsupported cryptographic algorithm: %q", algorithm), + }) + } +} + +type SecurityPolicy string + +const ( + ShouldSecurePolicy SecurityPolicy = "should-secure" + MustSecurePolicy SecurityPolicy = "must-secure" +) + +type MacSecPolicy struct { + Name string `json:"-"` + CipherSuite CipherSuite `json:"cipher-suite"` + ConfOffset string `json:"conf-offset"` + KeyServerPriority int `json:"key-server-priority"` + RelayProtection int `json:"window-size"` + SecurityPolicy +} + +func (m *MacSecPolicy) XPath() string { + return "Cisco-IOS-XR-um-macsec-cfg:macsec-policy/policy-names/policy-name[policy-name=" + m.Name + "]" +} + +func ExtractCipherSuite(cipherSuite string) (CipherSuite, error) { + switch cipherSuite { + case "GCM-AES-256": + return CipherSuiteGcmAes256, nil + case "GCM-AES-128": + return CipherSuiteGcmAes128, nil + case "GCM-AES-XPN-256": + return CipherSuiteGcmAesXpn256, nil + case "GCM-AES-XPN-128": + return CipherSuiteGcmAesXpn128, nil + default: + return "", apistatus.NewInvalidArgumentError(apistatus.FieldViolation{ + Field: "spec.policy.cipherSuite", + Description: fmt.Sprintf("unsupported cipher suite: %q", cipherSuite), + }) + } +} + +func ExtractCryptographicAlgorithm(cipherSuite string) (string, error) { + switch cipherSuite { + case "md5": + return string(CryptographicAlgorithmMd5), nil + case "sha-1": + return string(CryptographicAlgorithmSha1), nil + case "hmac-md5": + return string(CryptographicAlgorithmHmacMd5), nil + case "hmac-sha1-20": + return string(CryptographicAlgorithmHmacSha1_20), nil + case "hmac-sha1-12": + return string(CryptographicAlgorithmHmacSha1_12), nil + case "hmac-sha1-96": + return string(CryptographicAlgorithmHmacSha1_96), nil + case "hmac-sha-256": + return string(CryptographicAlgorithmHmacSha256), nil + case "aes-128-cmac-96": + return string(CryptographicAlgorithmAes128Cmac96), nil + default: + return "", apistatus.NewInvalidArgumentError(apistatus.FieldViolation{ + Field: "spec.preSharedKeys[].algorithm", + Description: fmt.Sprintf("unsupported cryptographic algorithm: %q", cipherSuite), + }) + } +} + +type KeyChain struct { + Name string `json:"key-chain-name"` + Keys Keys `json:"keys"` +} + +type Keys struct { + Key []Key `json:"key"` +} + +type Key struct { + KeyName string `json:"key-name"` + CryptographicAlgorithm string `json:"cryptographic-algorithm"` + PreSharedKey PreSharedKey `json:"key-string"` + StartLifetime Lifetime `json:"send-lifetime"` + AcceptLifetime Lifetime `json:"accept-lifetime"` +} + +type PreSharedKey struct { + Password string `json:"password" sensitive:"true"` +} + +type Lifetime struct { + Duration uint32 `json:"duration"` + StartTime CiscoTime `json:"start-time"` +} + +type CiscoTime struct { + DayOfMonth int `json:"day-of-month"` + Hour int `json:"hour"` + Minute int `json:"minute"` + Month string `json:"month"` + Second int `json:"second"` + Year int `json:"year"` +} + +func (k *KeyChain) XPath() string { + return "Cisco-IOS-XR-um-key-chain-cfg:key/chains/chain[key-chain-name=" + k.Name + "]" +} + +// NewLifetime builds a send/accept lifetime from the key's start and end times. +// The device model expresses this as a start clock plus a duration running to the end. +func NewLifetime(start, end time.Time) Lifetime { + start, end = start.UTC(), end.UTC() + t := new(CiscoTime) + t.DayOfMonth = start.Day() + t.Hour = start.Hour() + t.Minute = start.Minute() + t.Month = strings.ToLower(start.Month().String()) + t.Second = start.Second() + t.Year = start.Year() + + return Lifetime{ + Duration: uint32(end.Sub(start).Seconds()), + StartTime: *t, + } +} + +func (k *KeyChainOperData) XPath() string { + return "Cisco-IOS-XR-lib-keychain-oper:keychain/keys/key[key-name=" + k.Name + "]" +} + +// KeyChain operational data structures for reading state information +type KeyChainOperData struct { + Name string `json:"-"` + Key KeyOper `json:"key"` +} + +type KeyOper struct { + Keys []KeyStatus `json:"key-id"` +} + +type KeyStatus struct { + ID string `json:"key-id"` + AcceptLifetime StatusLifetime `json:"accept-lifetime"` + CryptographicAlgorithm string `json:"cryptographic-algorithm"` + SendLifetime StatusLifetime `json:"send-lifetime"` + Type string `json:"type"` +} + +type StatusLifetime struct { + Duration string `json:"duration"` + IsAlwaysValid bool `json:"is-always-valid"` + IsValidNow bool `json:"is-valid-now"` + Start string `json:"start"` +} + +type KeyIDOperational struct { + AcceptLifetime LifetimeOperational `json:"accept-lifetime"` + CryptographicAlgorithm string `json:"cryptographic-algorithm"` + KeyID string `json:"key-id"` + SendLifetime LifetimeOperational `json:"send-lifetime"` + Type string `json:"type"` +} + +type LifetimeOperational struct { + Duration string `json:"duration"` + IsAlwaysValid bool `json:"is-always-valid"` + IsValidNow bool `json:"is-valid-now"` + Start string `json:"start"` +} diff --git a/internal/provider/cisco/iosxr/provider.go b/internal/provider/cisco/iosxr/provider.go index e72db642f..845e8d56b 100644 --- a/internal/provider/cisco/iosxr/provider.go +++ b/internal/provider/cisco/iosxr/provider.go @@ -12,15 +12,17 @@ import ( "time" "github.com/go-logr/logr" + corev1 "k8s.io/api/core/v1" + + "google.golang.org/grpc" "github.com/ironcore-dev/network-operator/api/core/v1alpha1" "github.com/ironcore-dev/network-operator/internal/apistatus" "github.com/ironcore-dev/network-operator/internal/deviceutil" "github.com/ironcore-dev/network-operator/internal/provider" + "github.com/ironcore-dev/network-operator/internal/provider/cisco/encryption" "github.com/ironcore-dev/network-operator/internal/transport/gnmiext" "github.com/ironcore-dev/network-operator/internal/transport/grpcext" - - "google.golang.org/grpc" ) // timeout is the default timeout for all HTTP/gRPC requests made by the provider. @@ -36,6 +38,7 @@ var ( _ provider.PrefixSetProvider = &Provider{} _ provider.RoutingPolicyProvider = &Provider{} _ provider.StaticRouteProvider = &Provider{} + _ provider.MacSecProvider = &Provider{} ) type Provider struct { @@ -219,13 +222,12 @@ func (p *Provider) EnsureInterface(ctx context.Context, req *provider.EnsureInte iface = ifaceBundeConf } + setMacSec(iface, req.Interface.GetLabels()) + // (fixme): for the moment it is enough to keep this static // option1: extend existing interface spec // option2: create a custom iosxr config - iface.Shutdown = gnmiext.Empty(false) - if req.Interface.Spec.AdminState == v1alpha1.AdminStateDown { - iface.Shutdown = gnmiext.Empty(true) - } + setShutdown(iface, req.Interface.Spec.AdminState) iface.Active = "act" conf = append(conf, iface) @@ -243,10 +245,7 @@ func (p *Provider) EnsureInterface(ctx context.Context, req *provider.EnsureInte Mode: gnmiext.Empty(true), } - iface.Shutdown = gnmiext.Empty(false) - if req.Interface.Spec.AdminState == v1alpha1.AdminStateDown { - iface.Shutdown = gnmiext.Empty(true) - } + setShutdown(&iface, req.Interface.Spec.AdminState) bundleID, subinterfaceID, err := ExtractBundleAndSubinterfaceID(name) if err != nil { @@ -274,6 +273,9 @@ func (p *Provider) EnsureInterface(ctx context.Context, req *provider.EnsureInte } conf = append(conf, &iface) } + + setMacSec(&iface, req.Interface.GetLabels()) + return updateInterface(ctx, p.client, conf...) case v1alpha1.InterfaceTypeSubinterface: @@ -284,10 +286,7 @@ func (p *Provider) EnsureInterface(ctx context.Context, req *provider.EnsureInte ModeNoPhysical: "default", } - iface.Shutdown = gnmiext.Empty(false) - if req.Interface.Spec.AdminState == v1alpha1.AdminStateDown { - iface.Shutdown = gnmiext.Empty(true) - } + setShutdown(&iface, req.Interface.Spec.AdminState) _, subinterfaceID, err := ExtractBundleAndSubinterfaceID(name) if err != nil { @@ -322,6 +321,8 @@ func (p *Provider) EnsureInterface(ctx context.Context, req *provider.EnsureInte iface.Vrf = req.VRF.Spec.Name } + setMacSec(&iface, req.Interface.GetLabels()) + conf = append(conf, &iface) return updateInterface(ctx, p.client, conf...) case v1alpha1.InterfaceTypeLoopback, v1alpha1.InterfaceTypeRoutedVLAN: @@ -331,6 +332,24 @@ func (p *Provider) EnsureInterface(ctx context.Context, req *provider.EnsureInte } } +// setShutdown sets the interface shutdown state from the desired admin state. +func setShutdown(iface *Iface, state v1alpha1.AdminState) { + iface.Shutdown = gnmiext.Empty(state == v1alpha1.AdminStateDown) +} + +// setMacSec configures the MACsec PSK key chain on the interface when the +// MACsec label is present. +func setMacSec(iface *Iface, labels map[string]string) { + if policy := labels[v1alpha1.MacSecLabel]; policy != "" { + iface.MacSec = IfaceMacSec{ + PSKKeyChain: PSKKeyChain{ + KeyChainName: policy, + Policy: policy, + }, + } + } +} + func NewMTU(intName string, mtu int32) (MTUs, error) { owner, err := ExtractOwnerFromInterfaceName(intName) if err != nil { @@ -749,6 +768,94 @@ func (p *Provider) DeleteStaticRoute(ctx context.Context, req *provider.DeleteSt return p.client.Delete(ctx, staticRoute) } +func (p *Provider) EnsureMacSec(ctx context.Context, req *provider.EnsureMacSecRequest) error { + // Configure MacSec Policy + sb := new(gnmiext.SetBuilder) + + cipherSuite, err := ExtractCipherSuite(req.MacSec.Spec.Policy.CipherSuite) + if err != nil { + return err + } + + policy := &MacSecPolicy{ + Name: req.MacSec.Spec.Name, + CipherSuite: cipherSuite, + ConfOffset: fmt.Sprintf(ConfOffsetPrefix, req.MacSec.Spec.Policy.ConfidentialityOffset), + KeyServerPriority: req.MacSec.Spec.Policy.KeyServerPriority, + RelayProtection: req.MacSec.Spec.Policy.RelayProtection, + } + sb.Update(policy) + + // Configure KeyChain + chain := new(KeyChain) + chain.Name = req.MacSec.Spec.Name + + secrets := make(map[string]*corev1.Secret, len(req.Secrets)) + for i := range req.Secrets { + secrets[req.Secrets[i].Name] = &req.Secrets[i] + } + + for i := range req.MacSec.Spec.PreSharedKeys { + psk := &req.MacSec.Spec.PreSharedKeys[i] + secret, ok := secrets[psk.SecretRef.Name] + if !ok { + return fmt.Errorf("pre-shared key secret %q not found", psk.SecretRef.Name) + } + + lifeTime := NewLifetime(psk.StartTime.Time, psk.EndTime.Time) + algorithm, err := ExtractMacSecAlgorithm(psk.Algorithm) + if err != nil { + return err + } + + password, err := encryption.EncryptType7(string(secret.Data[preSharedKeyDataKey]), 0) + if err != nil { + return apistatus.NewInvalidArgumentError(apistatus.FieldViolation{ + Field: "spec.preSharedKeys[].keyRef", + Description: fmt.Sprintf("failed to encode key material for %q: %v", psk.ConnectivityKeyName, err), + }) + } + + key := Key{ + KeyName: psk.ConnectivityKeyName, + CryptographicAlgorithm: algorithm, + PreSharedKey: PreSharedKey{Password: password}, + StartLifetime: lifeTime, + AcceptLifetime: lifeTime, + } + chain.Keys.Key = append(chain.Keys.Key, key) + } + + if len(chain.Keys.Key) == 0 { + return errors.New("no keys provided for MacSec KeyChain") + } + + sb.Update(chain) + + return p.client.Do(ctx, sb) +} + +func (p *Provider) DeleteMacSec(ctx context.Context, req *provider.DeleteMacSecRequest) error { + policy := new(MacSecPolicy) + policy.Name = req.MacSec.Spec.Name + + keyChain := new(KeyChain) + keyChain.Name = req.MacSec.Spec.Name + + return p.client.Delete(ctx, policy, keyChain) +} + +func (p *Provider) GetMacSecStatus(ctx context.Context, req *provider.EnsureMacSecRequest) (provider.MacSecStatus, error) { + status := new(KeyChainOperData) + status.Name = req.MacSec.Spec.Name + + err := p.client.GetState(ctx, status) + if err != nil { + return provider.MacSecStatus{}, fmt.Errorf("failed to get MacSec status for %s: %w", req.MacSec.Spec.Name, err) + } + return provider.MacSecStatus{}, nil +} + func init() { provider.Register("iosxr.cisco.networking.metal.ironcore.dev", NewProvider) } diff --git a/internal/provider/cisco/nxos/macsec.go b/internal/provider/cisco/nxos/macsec.go new file mode 100644 index 000000000..f6d6758d8 --- /dev/null +++ b/internal/provider/cisco/nxos/macsec.go @@ -0,0 +1,212 @@ +// SPDX-FileCopyrightText: SAP SE or an SAP affiliate company and IronCore contributors +// SPDX-License-Identifier: Apache-2.0 + +package nxos + +import ( + "fmt" + "time" + + "github.com/ironcore-dev/network-operator/api/core/v1alpha1" + "github.com/ironcore-dev/network-operator/internal/apistatus" + "github.com/ironcore-dev/network-operator/internal/transport/gnmiext" +) + +var ( + _ gnmiext.DataElement = (*MacSecPolicy)(nil) + _ gnmiext.DataElement = (*KeyChain)(nil) + _ gnmiext.DataElement = (*MacSecInterface)(nil) +) + +// preSharedKeyDataKey is the Secret data key holding the raw MACsec key material. +const preSharedKeyDataKey = "key" + +// CipherSuite is the MACsec cipher suite as expected by the NX-OS DME model. +type CipherSuite string + +const ( + CipherSuiteGcmAes256 CipherSuite = "GCM-AES-256" + CipherSuiteGcmAes128 CipherSuite = "GCM-AES-128" + CipherSuiteGcmAesXpn256 CipherSuite = "GCM-AES-XPN-256" + CipherSuiteGcmAesXpn128 CipherSuite = "GCM-AES-XPN-128" +) + +// ExtractCipherSuite validates the API cipher suite string and maps it to the +// NX-OS DME value. +func ExtractCipherSuite(cipherSuite string) (CipherSuite, error) { + switch cipherSuite { + case string(CipherSuiteGcmAes256): + return CipherSuiteGcmAes256, nil + case string(CipherSuiteGcmAes128): + return CipherSuiteGcmAes128, nil + case string(CipherSuiteGcmAesXpn256): + return CipherSuiteGcmAesXpn256, nil + case string(CipherSuiteGcmAesXpn128): + return CipherSuiteGcmAesXpn128, nil + default: + return "", apistatus.NewInvalidArgumentError(apistatus.FieldViolation{ + Field: "spec.policy.cipherSuite", + Description: fmt.Sprintf("unsupported cipher suite: %q", cipherSuite), + }) + } +} + +// ConfOffset is the MACsec confidentiality offset as expected by the NX-OS DME +// model (macsec_ConfOffset enumeration). +type ConfOffset string + +const ( + ConfOffset0 ConfOffset = "CONF-OFFSET-0" + ConfOffset30 ConfOffset = "CONF-OFFSET-30" + ConfOffset50 ConfOffset = "CONF-OFFSET-50" +) + +// ExtractConfOffset validates the API confidentiality offset and maps it to the +// NX-OS DME enum value. +func ExtractConfOffset(offset uint16) (ConfOffset, error) { + switch offset { + case 0: + return ConfOffset0, nil + case 30: + return ConfOffset30, nil + case 50: + return ConfOffset50, nil + default: + return "", apistatus.NewInvalidArgumentError(apistatus.FieldViolation{ + Field: "spec.policy.confidentialityOffset", + Description: fmt.Sprintf("unsupported confidentiality offset: %d", offset), + }) + } +} + +type MacSecPolicy struct { + PolicyName string `json:"policyName"` + CipherSuite CipherSuite `json:"cipherSuite,omitzero"` + ConfOffset ConfOffset `json:"confOffset,omitzero"` + KeyServerPriority int `json:"keySvrPrio,omitzero"` + ReplayWindow int `json:"replayWindow,omitzero"` +} + +func (m *MacSecPolicy) XPath() string { + return "System/macsec-items/inst-items/policy-items/Policy-list[policyName=" + m.PolicyName + "]" +} + +func (*MacSecPolicy) IsListItem() {} + +// MacSecInterface binds a MACsec policy and keychain to an interface under +// System/macsec-items/inst-items/if-items/If-list. +// +// Only the fields modeled by the API are carried here. fallbackKeychainName and +// other device knobs are intentionally omitted so the device retains its +// platform defaults and no false diff is produced on subsequent reconciles. +type MacSecInterface struct { + ID string `json:"id"` + KeychainName string `json:"keychainName,omitzero"` + PolicyName string `json:"policyName,omitzero"` +} + +func (m *MacSecInterface) XPath() string { + return "System/macsec-items/inst-items/if-items/If-list[id=" + m.ID + "]" +} + +func (*MacSecInterface) IsListItem() {} + +// KeyChain is the MACsec keychain realized separately from the policy. +// +// The keys nest under the DME container macseckeyid-items, whose MacsecKeyId-list +// holds one entry per key. +type KeyChain struct { + Name string `json:"keychainName"` + Keys MacSecKeyID `json:"macseckeyid-items,omitzero"` +} + +// MacSecKeyID is the macseckeyid-items container wrapping the key list. +type MacSecKeyID struct { + KeyList gnmiext.List[string, *MacSecKey] `json:"MacsecKeyId-list,omitzero"` +} + +func (k *KeyChain) XPath() string { + return "System/kcmgr-items/keychains-items/macseckeychain-items/MacsecKeychain-list[keychainName=" + k.Name + "]" +} + +func (*KeyChain) IsListItem() {} + +// CryptographicAlgo is the MACsec key cryptographic algorithm as expected by +// the NX-OS DME model (kcmgr_cryptographicAlgoAes). +type CryptographicAlgo string + +const ( + CryptographicAlgoAes128Cmac CryptographicAlgo = "AES_128_CMAC" + CryptographicAlgoAes256Cmac CryptographicAlgo = "AES_256_CMAC" +) + +// ExtractCryptographicAlgo validates the API algorithm and maps it to the +// NX-OS DME value. +func ExtractCryptographicAlgo(algorithm v1alpha1.CryptographicAlgorithm) (CryptographicAlgo, error) { + switch algorithm { + case v1alpha1.AES128CMAC: + return CryptographicAlgoAes128Cmac, nil + case v1alpha1.AES256CMAC: + return CryptographicAlgoAes256Cmac, nil + default: + return "", apistatus.NewInvalidArgumentError(apistatus.FieldViolation{ + Field: "spec.preSharedKeys[].algorithm", + Description: fmt.Sprintf("unsupported cryptographic algorithm: %s", algorithm), + }) + } +} + +// EncryptType is the MACsec key-string encryption type (kcmgr_encryptionTypeMacsec). +type EncryptType string + +const ( + EncryptTypeUnencrypted EncryptType = "unencrypted" + EncryptTypeType7 EncryptType = "type7" + EncryptTypeType6 EncryptType = "type6" +) + +// MacSecKey is a single pre-shared key within a keychain. +// +// MACsec keys have only a send-lifetime; the device has no accept-lifetime +// container for them (that exists for classic keychains only). +type MacSecKey struct { + ID string `json:"keyId"` + OctetString string `json:"keyHexString,omitzero" sensitive:"true"` + EncryptType EncryptType `json:"encryptType,omitzero"` + CryptoAlg CryptographicAlgo `json:"cryptographicAlgo,omitzero"` + SendLifetime Lifetime `json:"macsecsendlifetime-items,omitzero"` +} + +func (k *MacSecKey) Key() string { return k.ID } + +// Lifetime is the MACsec send-lifetime realized under +// macseckeyid-items/MacsecKeyId-list/macsecsendlifetime-items. The start time +// is split into a clock string ("HH:MM:SS") and separate day/month/year leaves, +// matching the NX-OS DME model. +// +// Infinite is always sent as "disabled": the device reports it on every Get, so +// omitting it would produce a false diff and a Set on every reconcile. +type Lifetime struct { + StartTime string `json:"startTime,omitzero"` + StartDay uint16 `json:"startDay,omitzero"` + StartMonth string `json:"startMonth,omitzero"` + StartYear uint16 `json:"startYear,omitzero"` + Duration uint32 `json:"duration,omitzero"` + Infinite string `json:"infinite"` +} + +// NewLifetime builds the MACsec send-lifetime from the key's start and end +// times: a start clock plus the duration running until the end time. +func NewLifetime(start, end time.Time) Lifetime { + start, end = start.UTC(), end.UTC() + return Lifetime{ + StartTime: start.Format(time.TimeOnly), + //nolint:gosec // Day() is 1-31 and Year() is a valid calendar year, both fit uint16 + StartDay: uint16(start.Day()), + StartMonth: start.Format("Jan"), + //nolint:gosec // Year() is a valid calendar year, fits uint16 + StartYear: uint16(start.Year()), + Duration: uint32(end.Sub(start).Seconds()), + Infinite: "disabled", + } +} diff --git a/internal/provider/cisco/nxos/provider.go b/internal/provider/cisco/nxos/provider.go index 82a790b9e..b3280aed6 100644 --- a/internal/provider/cisco/nxos/provider.go +++ b/internal/provider/cisco/nxos/provider.go @@ -31,12 +31,14 @@ import ( "github.com/go-logr/logr" systempb "github.com/openconfig/gnoi/system" "google.golang.org/grpc" + corev1 "k8s.io/api/core/v1" nxv1alpha1 "github.com/ironcore-dev/network-operator/api/cisco/nx/v1alpha1" "github.com/ironcore-dev/network-operator/api/core/v1alpha1" "github.com/ironcore-dev/network-operator/internal/apistatus" "github.com/ironcore-dev/network-operator/internal/deviceutil" "github.com/ironcore-dev/network-operator/internal/provider" + "github.com/ironcore-dev/network-operator/internal/provider/cisco/encryption" "github.com/ironcore-dev/network-operator/internal/transport/gnmiext" "github.com/ironcore-dev/network-operator/internal/transport/grpcext" "github.com/ironcore-dev/network-operator/internal/transport/nxapi" @@ -56,6 +58,7 @@ var ( _ provider.EVPNInstanceProvider = (*Provider)(nil) _ provider.InterfaceProvider = (*Provider)(nil) _ provider.ISISProvider = (*Provider)(nil) + _ provider.MacSecProvider = (*Provider)(nil) _ provider.ManagementAccessProvider = (*Provider)(nil) _ provider.NTPProvider = (*Provider)(nil) _ provider.OSPFProvider = (*Provider)(nil) @@ -4839,6 +4842,113 @@ func (p *Provider) DeleteStaticRoute(ctx context.Context, req *provider.DeleteSt return p.client.Delete(ctx, route) } +// EnsureMacSec realizes the MacSec policy and keychain on the device. The +// macsec feature is enabled first (in its own Set RPC via p.Do) before the +// dependent policy and keychain configuration is pushed. +func (p *Provider) EnsureMacSec(ctx context.Context, req *provider.EnsureMacSecRequest) error { + sb := new(gnmiext.SetBuilder).Limit(maxSetOperations) + + f := new(Feature) + f.Name = "macsec" + f.AdminSt = AdminStEnabled + sb.Update(f) + + cipherSuite, err := ExtractCipherSuite(req.MacSec.Spec.Policy.CipherSuite) + if err != nil { + return err + } + + confOffset, err := ExtractConfOffset(uint16(req.MacSec.Spec.Policy.ConfidentialityOffset)) + if err != nil { + return err + } + + policy := &MacSecPolicy{ + PolicyName: req.MacSec.Spec.Name, + CipherSuite: cipherSuite, + ConfOffset: confOffset, + KeyServerPriority: req.MacSec.Spec.Policy.KeyServerPriority, + ReplayWindow: req.MacSec.Spec.Policy.RelayProtection, + } + sb.Patch(policy) + + chain := &KeyChain{Name: req.MacSec.Spec.Name} + + secrets := make(map[string]*corev1.Secret, len(req.Secrets)) + for i := range req.Secrets { + secrets[req.Secrets[i].Name] = &req.Secrets[i] + } + + for i := range req.MacSec.Spec.PreSharedKeys { + psk := &req.MacSec.Spec.PreSharedKeys[i] + secret, ok := secrets[psk.SecretRef.Name] + if !ok { + return fmt.Errorf("pre-shared key secret %q not found", psk.SecretRef.Name) + } + + lifetime := NewLifetime(psk.StartTime.Time, psk.EndTime.Time) + cryptoAlg, err := ExtractCryptographicAlgo(psk.Algorithm) + if err != nil { + return err + } + + keyHexString, err := encryption.EncryptType7(string(secret.Data[preSharedKeyDataKey]), 0) + if err != nil { + return apistatus.NewInvalidArgumentError(apistatus.FieldViolation{ + Field: "spec.preSharedKeys[].keyRef", + Description: fmt.Sprintf("failed to encode key material for %q: %v", psk.ConnectivityKeyName, err), + }) + } + + key := MacSecKey{ + ID: psk.ConnectivityKeyName, + OctetString: keyHexString, + EncryptType: EncryptTypeType7, + CryptoAlg: cryptoAlg, + SendLifetime: lifetime, + } + chain.Keys.KeyList.Set(&key) + } + sb.Update(chain) + + name, err := ShortName(req.SourceInterface.Spec.Name) + if err != nil { + return err + } + + intf := MacSecInterface{ + ID: name, + KeychainName: req.MacSec.Spec.Name, + PolicyName: req.MacSec.Spec.Name, + } + sb.Patch(&intf) + + return p.Do(ctx, sb) +} + +// DeleteMacSec removes the MacSec policy and keychain from the device, along +// with the interface binding that references them. +func (p *Provider) DeleteMacSec(ctx context.Context, req *provider.DeleteMacSecRequest) error { + elems := []gnmiext.DataElement{ + &MacSecPolicy{PolicyName: req.MacSec.Spec.Name}, + &KeyChain{Name: req.MacSec.Spec.Name}, + } + if req.SourceInterface != nil { + name, err := ShortName(req.SourceInterface.Spec.Name) + if err != nil { + return err + } + elems = append(elems, &MacSecInterface{ID: name}) + } + return p.client.Delete(ctx, elems...) +} + +// GetMacSecStatus is not yet implemented; the controller does not consume it. +func (p *Provider) GetMacSecStatus(ctx context.Context, req *provider.EnsureMacSecRequest) (provider.MacSecStatus, error) { + // TODO(sven-rosenzweig): Implement MacSec status retrieval + return provider.MacSecStatus{}, nil +} + func init() { provider.Register("nx.cisco.networking.metal.ironcore.dev", NewProvider) } diff --git a/internal/provider/provider.go b/internal/provider/provider.go index fc4fc20d0..63147d893 100644 --- a/internal/provider/provider.go +++ b/internal/provider/provider.go @@ -1,5 +1,6 @@ // SPDX-FileCopyrightText: SAP SE or an SAP affiliate company and IronCore contributors // SPDX-License-Identifier: Apache-2.0 + package provider import ( @@ -14,6 +15,7 @@ import ( "sync" "time" + corev1 "k8s.io/api/core/v1" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" "k8s.io/apimachinery/pkg/runtime" "sigs.k8s.io/controller-runtime/pkg/client" @@ -777,6 +779,43 @@ type DeleteRoutingPolicyRequest struct { Name string } +// MacSecProvider is the interface for the realization of the MacSec objects over different providers. +type MacSecProvider interface { + Provider + + // EnsureMacSec call is responsible for MacSec realization on the provider. + EnsureMacSec(context.Context, *EnsureMacSecRequest) error + // DeleteMacSec call is responsible for MacSec deletion on the provider. + DeleteMacSec(context.Context, *DeleteMacSecRequest) error + // GetMacSecStatus call is responsible for retrieving the current status of the MacSec from the provider. + GetMacSecStatus(context.Context, *EnsureMacSecRequest) (MacSecStatus, error) +} + +type EnsureMacSecRequest struct { + MacSec *v1alpha1.MacSec + Secrets []corev1.Secret + // SourceInterface is the interface on which MacSec is configured. + SourceInterface *v1alpha1.Interface +} + +type DeleteMacSecRequest struct { + MacSec *v1alpha1.MacSec + // SourceInterface is the interface on which MacSec is configured. + SourceInterface *v1alpha1.Interface +} + +type MacSecStatus struct { + // OverallStatus indicates whether all keys are valid + OperStatus bool + // KeyValidities provides the validity status for each individual key configured on the device. + KeyValidities []KeyValidity +} + +type KeyValidity struct { + KeyName string + Valid bool +} + type NVEProvider interface { Provider diff --git a/internal/transport/gnmiext/client.go b/internal/transport/gnmiext/client.go index a8563ee75..79a0579d9 100644 --- a/internal/transport/gnmiext/client.go +++ b/internal/transport/gnmiext/client.go @@ -408,7 +408,7 @@ func (c *client) set(ctx context.Context, r *gnmipb.SetRequest, patch bool, elem if err != nil { return err } - c.logger.V(1).Info("Updating", "path", el.XPath(), "payload", string(b), "operation", op) + c.logger.V(1).Info("Updating", "path", el.XPath(), "payload", redactPayload(el), "operation", op) u := &gnmipb.Update{ Path: path, Val: c.Encode(b), @@ -437,7 +437,7 @@ func (c *client) delete(_ context.Context, r *gnmipb.SetRequest, elements ...Dat if err != nil { return err } - c.logger.V(1).Info("Resetting to default", "path", el.XPath(), "payload", string(b)) + c.logger.V(1).Info("Resetting to default", "path", el.XPath(), "payload", redactPayload(el)) r.Replace = append(r.Replace, &gnmipb.Update{ Path: path, Val: c.Encode(b), diff --git a/internal/transport/gnmiext/redact.go b/internal/transport/gnmiext/redact.go new file mode 100644 index 000000000..b58bc8565 --- /dev/null +++ b/internal/transport/gnmiext/redact.go @@ -0,0 +1,102 @@ +// SPDX-FileCopyrightText: SAP SE or an SAP affiliate company and IronCore contributors +// SPDX-License-Identifier: Apache-2.0 + +package gnmiext + +import ( + "encoding/json" + "reflect" + "strings" +) + +// sensitiveTag marks a struct field whose value must be masked before logging. +// Fields tagged `sensitive:"true"` still carry their real value in the gNMI Set +// payload sent to the device; only the logged representation is redacted. +const sensitiveTag = "sensitive" + +const redactedValue = "****" + +// redactPayload returns a loggable JSON representation of a gNMI payload value +// with every field tagged `sensitive:"true"` replaced by redactedValue. The +// original value is never mutated. On failure it falls back to a fixed +// placeholder rather than risking leaking the raw value. +func redactPayload(el any) string { + b, err := json.Marshal(redactValue(reflect.ValueOf(el))) + if err != nil { + return "" + } + return string(b) +} + +// redactValue returns a JSON-marshalable copy of rv with sensitive struct +// fields masked. Non-struct values are returned as-is via their interface. +func redactValue(rv reflect.Value) any { + if !rv.IsValid() { + return nil + } + switch rv.Kind() { + case reflect.Pointer, reflect.Interface: + if rv.IsNil() { + return nil + } + return redactValue(rv.Elem()) + case reflect.Struct: + out := make(map[string]any, rv.NumField()) + rt := rv.Type() + for i := range rt.NumField() { + field := rt.Field(i) + if !field.IsExported() { + continue + } + name, omit := jsonFieldName(field) + if name == "-" { + continue + } + fv := rv.Field(i) + if omit && fv.IsZero() { + continue + } + if field.Tag.Get(sensitiveTag) == "true" { + out[name] = redactedValue + continue + } + out[name] = redactValue(fv) + } + return out + case reflect.Slice, reflect.Array: + out := make([]any, rv.Len()) + for i := range rv.Len() { + out[i] = redactValue(rv.Index(i)) + } + return out + case reflect.Map: + out := make(map[string]any, rv.Len()) + for _, k := range rv.MapKeys() { + out[k.String()] = redactValue(rv.MapIndex(k)) + } + return out + default: + return rv.Interface() + } +} + +// jsonFieldName returns the JSON object key for a struct field and whether the +// field is omitted when empty (json:",omitempty"/",omitzero"). A field with no +// json tag falls back to its Go name, matching encoding/json. +func jsonFieldName(field reflect.StructField) (name string, omitEmpty bool) { + tag, ok := field.Tag.Lookup("json") + if !ok { + return field.Name, false + } + parts := strings.Split(tag, ",") + name = parts[0] + if name == "" { + name = field.Name + } + for _, opt := range parts[1:] { + if opt == "omitempty" || opt == "omitzero" { + omitEmpty = true + } + } + return name, omitEmpty +} diff --git a/internal/webhook/core/v1alpha1/macsec_webhook.go b/internal/webhook/core/v1alpha1/macsec_webhook.go new file mode 100644 index 000000000..55a7eda8a --- /dev/null +++ b/internal/webhook/core/v1alpha1/macsec_webhook.go @@ -0,0 +1,76 @@ +// SPDX-FileCopyrightText: SAP SE or an SAP affiliate company and IronCore contributors +// SPDX-License-Identifier: Apache-2.0 + +package v1alpha1 + +import ( + "context" + "fmt" + "time" + + ctrl "sigs.k8s.io/controller-runtime" + logf "sigs.k8s.io/controller-runtime/pkg/log" + "sigs.k8s.io/controller-runtime/pkg/webhook/admission" + + "github.com/ironcore-dev/network-operator/api/core/v1alpha1" +) + +// log is for logging in this package. +var macseclog = logf.Log.WithName("macsec-resource") + +// SetupMacSecWebhookWithManager registers the webhook for MacSec in the manager. +func SetupMacSecWebhookWithManager(mgr ctrl.Manager) error { + return ctrl.NewWebhookManagedBy(mgr, &v1alpha1.MacSec{}). + WithValidator(&MacSecCustomValidator{}). + Complete() +} + +// +kubebuilder:webhook:path=/validate-networking-metal-ironcore-dev-v1alpha1-macsec,mutating=false,failurePolicy=Fail,sideEffects=None,groups=networking.metal.ironcore.dev,resources=macsec,verbs=create;update,versions=v1alpha1,name=macsec-v1alpha1.kb.io,admissionReviewVersions=v1 + +// MacSecCustomValidator struct is responsible for validating the MacSec resource +// when it is created, updated, or deleted. +type MacSecCustomValidator struct{} + +// ValidateCreate implements webhook.CustomValidator so a webhook will be registered for the type MacSec. +func (v *MacSecCustomValidator) ValidateCreate(_ context.Context, obj *v1alpha1.MacSec) (admission.Warnings, error) { + macseclog.Info("Validation for MacSec upon creation", "name", obj.GetName()) + + for _, psk := range obj.Spec.PreSharedKeys { + if err := validatePreSharedKeyTimes(&psk); err != nil { + return nil, err + } + } + + return nil, nil +} + +// ValidateUpdate implements webhook.CustomValidator so a webhook will be registered for the type MacSec. +func (v *MacSecCustomValidator) ValidateUpdate(_ context.Context, oldObj, newObj *v1alpha1.MacSec) (admission.Warnings, error) { + macseclog.Info("Validation for MacSec upon update", "name", newObj.GetName()) + + for _, psk := range newObj.Spec.PreSharedKeys { + if err := validatePreSharedKeyTimes(&psk); err != nil { + return nil, err + } + } + + return nil, nil +} + +// ValidateDelete implements webhook.CustomValidator so a webhook will be registered for the type MacSec. +func (v *MacSecCustomValidator) ValidateDelete(_ context.Context, obj *v1alpha1.MacSec) (admission.Warnings, error) { + macseclog.Info("Validation for MacSec upon deletion", "name", obj.GetName()) + return nil, nil +} + +func validatePreSharedKeyTimes(key *v1alpha1.PreSharedKey) error { + if key.StartTime.After(key.EndTime.Time) { + return fmt.Errorf("pre-shared key start time %v is after end time %v", key.StartTime.Time, key.EndTime.Time) + } + + currTime := time.Now() + if !key.EndTime.After(currTime) { + return fmt.Errorf("pre-shared key end time %v is not after current time %v", key.EndTime.Time, currTime) + } + return nil +} diff --git a/internal/webhook/core/v1alpha1/macsec_webhook_test.go b/internal/webhook/core/v1alpha1/macsec_webhook_test.go new file mode 100644 index 000000000..5d83edf55 --- /dev/null +++ b/internal/webhook/core/v1alpha1/macsec_webhook_test.go @@ -0,0 +1,127 @@ +// SPDX-FileCopyrightText: SAP SE or an SAP affiliate company and IronCore contributors +// SPDX-License-Identifier: Apache-2.0 + +package v1alpha1 + +import ( + "time" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + "github.com/ironcore-dev/network-operator/api/core/v1alpha1" +) + +var _ = Describe("MacSec Webhook", func() { + var ( + obj *v1alpha1.MacSec + oldObj *v1alpha1.MacSec + validator MacSecCustomValidator + ) + + BeforeEach(func() { + now := time.Now() + obj = &v1alpha1.MacSec{ + Name: "test-macsec", + Namespace: "default", + Spec: v1alpha1.MacSecSpec{ + DeviceRef: v1alpha1.LocalObjectReference{Name: "test-device"}, + Name: "test-macsec", + InterfaceRef: v1alpha1.LocalObjectReference{Name: "test-interface"}, + PreSharedKeys: []v1alpha1.PreSharedKey{ + { + SecretRef: v1alpha1.LocalObjectReference{Name: "test-secret"}, + ConnectivityKeyName: "abc123", + Algorithm: v1alpha1.AES256CMAC, + StartTime: metav1.NewTime(now), + EndTime: metav1.NewTime(now.Add(time.Hour)), + }, + }, + Policy: &v1alpha1.MacSecPolicy{ + CipherSuite: "GCM-AES-256", + ConfidentialityOffset: v1alpha1.ZeroBytes, + KeyServerPriority: 16, + RelayProtection: 0, + }, + }, + } + oldObj = obj.DeepCopy() + validator = MacSecCustomValidator{} + }) + + Context("When creating MacSec under Validating Webhook", func() { + It("Should admit a valid MacSec", func() { + _, err := validator.ValidateCreate(ctx, obj) + Expect(err).NotTo(HaveOccurred()) + }) + + It("Should admit multiple valid pre-shared keys", func() { + now := time.Now() + obj.Spec.PreSharedKeys = append(obj.Spec.PreSharedKeys, v1alpha1.PreSharedKey{ + SecretRef: v1alpha1.LocalObjectReference{Name: "test-secret-2"}, + ConnectivityKeyName: "def456", + Algorithm: v1alpha1.AES128CMAC, + StartTime: metav1.NewTime(now.Add(time.Hour)), + EndTime: metav1.NewTime(now.Add(2 * time.Hour)), + }) + _, err := validator.ValidateCreate(ctx, obj) + Expect(err).NotTo(HaveOccurred()) + }) + + It("Should deny a pre-shared key whose start time is after its end time", func() { + now := time.Now() + obj.Spec.PreSharedKeys[0].StartTime = metav1.NewTime(now.Add(2 * time.Hour)) + obj.Spec.PreSharedKeys[0].EndTime = metav1.NewTime(now.Add(time.Hour)) + _, err := validator.ValidateCreate(ctx, obj) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("start time")) + Expect(err.Error()).To(ContainSubstring("after end time")) + }) + + It("Should deny a pre-shared key whose end time is in the past", func() { + now := time.Now() + obj.Spec.PreSharedKeys[0].StartTime = metav1.NewTime(now.Add(-2 * time.Hour)) + obj.Spec.PreSharedKeys[0].EndTime = metav1.NewTime(now.Add(-time.Hour)) + _, err := validator.ValidateCreate(ctx, obj) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("end time")) + Expect(err.Error()).To(ContainSubstring("not after current time")) + }) + + It("Should deny when one of several pre-shared keys is invalid", func() { + now := time.Now() + obj.Spec.PreSharedKeys = append(obj.Spec.PreSharedKeys, v1alpha1.PreSharedKey{ + SecretRef: v1alpha1.LocalObjectReference{Name: "test-secret-2"}, + ConnectivityKeyName: "def456", + Algorithm: v1alpha1.AES128CMAC, + StartTime: metav1.NewTime(now.Add(time.Hour)), + EndTime: metav1.NewTime(now.Add(-time.Hour)), + }) + _, err := validator.ValidateCreate(ctx, obj) + Expect(err).To(HaveOccurred()) + }) + }) + + Context("When updating MacSec under Validating Webhook", func() { + It("Should admit update with valid pre-shared keys", func() { + _, err := validator.ValidateUpdate(ctx, oldObj, obj) + Expect(err).NotTo(HaveOccurred()) + }) + + It("Should deny update introducing an invalid pre-shared key", func() { + now := time.Now() + obj.Spec.PreSharedKeys[0].StartTime = metav1.NewTime(now.Add(2 * time.Hour)) + obj.Spec.PreSharedKeys[0].EndTime = metav1.NewTime(now.Add(time.Hour)) + _, err := validator.ValidateUpdate(ctx, oldObj, obj) + Expect(err).To(HaveOccurred()) + }) + }) + + Context("When deleting MacSec under Validating Webhook", func() { + It("Should admit deletion", func() { + _, err := validator.ValidateDelete(ctx, obj) + Expect(err).NotTo(HaveOccurred()) + }) + }) +}) diff --git a/internal/webhook/core/v1alpha1/webhook_suite_test.go b/internal/webhook/core/v1alpha1/webhook_suite_test.go index e7c01eedf..3b2f4a911 100644 --- a/internal/webhook/core/v1alpha1/webhook_suite_test.go +++ b/internal/webhook/core/v1alpha1/webhook_suite_test.go @@ -121,6 +121,9 @@ var _ = BeforeSuite(func() { err = SetupDHCPRelayWebhookWithManager(mgr) Expect(err).NotTo(HaveOccurred()) + err = SetupMacSecWebhookWithManager(mgr) + Expect(err).NotTo(HaveOccurred()) + // +kubebuilder:scaffold:webhook go func() { diff --git a/test/gnmi/gnmi_suite_test.go b/test/gnmi/gnmi_suite_test.go index fe1b15995..ebc5d6d33 100644 --- a/test/gnmi/gnmi_suite_test.go +++ b/test/gnmi/gnmi_suite_test.go @@ -186,6 +186,15 @@ func registerControllers(ctx context.Context, mgr ctrl.Manager, recorder *events }).SetupWithManager(ctx, mgr) Expect(err).NotTo(HaveOccurred()) + err = (&core.MacSecReconciler{ + Client: mgr.GetClient(), + Scheme: mgr.GetScheme(), + Recorder: recorder, + Locker: locker, + RequeueInterval: time.Minute, + }).SetupWithManager(ctx, mgr) + Expect(err).NotTo(HaveOccurred()) + err = (&core.VLANReconciler{ Client: mgr.GetClient(), Scheme: mgr.GetScheme(), diff --git a/test/gnmi/testdata/nx.cisco.networking.metal.ironcore.dev/macsec.txtar b/test/gnmi/testdata/nx.cisco.networking.metal.ironcore.dev/macsec.txtar new file mode 100644 index 000000000..66218012f --- /dev/null +++ b/test/gnmi/testdata/nx.cisco.networking.metal.ironcore.dev/macsec.txtar @@ -0,0 +1,229 @@ +-- interface/eth1-1 -- +apiVersion: networking.metal.ironcore.dev/v1alpha1 +kind: Interface +metadata: + name: eth1-1 + namespace: default +spec: + deviceRef: + name: device + name: eth1/1 + description: macse interface + adminState: Up + type: Physical + +-- secret/secret1 -- +apiVersion: v1 +kind: Secret +metadata: + name: secret1 + namespace: default +type: Opaque +data: + key: dGVzdC1wc2s= + +-- macsec/secure-layer2 -- +apiVersion: networking.metal.ironcore.dev/v1alpha1 +kind: MacSec +metadata: + name: secure-layer2 + namespace: default +spec: + deviceRef: + name: device + interfaceRef: + name: eth1-1 + name: secure-layer2 + preSharedKeys: + - secretRef: + name: secret1 + connectivityKeyName: "1234567" + algorithm: AES-128-CMAC + startTime: "2026-01-01T00:00:00Z" + endTime: "2027-01-01T09:34:01Z" + policy: + cipherSuite: GCM-AES-256 + confidentialityOffset: 30 + keyServerPriority: 8 + relayProtection: 32 + +-- state/preload -- +{ + "System": { + "procsys-items": { + "bootTime": "1700000000" + } + } +} + +-- state/expect -- + +{ + "System": { + "procsys-items": { + "bootTime": "1700000000" + }, + "fm-items": { + "macsec-items": { + "adminSt": "enabled" + } + }, + "kcmgr-items": { + "keychains-items": { + "macseckeychain-items": { + "MacsecKeychain-list": [ + { + "keychainName": "secure-layer2", + "macseckeyid-items": { + "MacsecKeyId-list": [ + { + "cryptographicAlgo": "AES_128_CMAC", + "encryptType": "type7", + "keyHexString": "0010161510161B1504", + "keyId": "1234567", + "macsecsendlifetime-items": { + "duration": 31570441, + "infinite": "disabled", + "startDay": 1, + "startMonth": "Jan", + "startTime": "00:00:00", + "startYear": 2026 + } + } + ] + } + } + ] + } + } + }, + "macsec-items": { + "inst-items": { + "policy-items": { + "Policy-list": [ + { + "cipherSuite": "GCM-AES-256", + "confOffset": "CONF-OFFSET-30", + "keySvrPrio": 8, + "policyName": "secure-layer2", + "replayWindow": 32 + } + ] + }, + "if-items": { + "If-list": [ + { + "id": "eth1/1", + "keychainName": "secure-layer2", + "policyName": "secure-layer2" + } + ] + } + } + }, + "intf-items": { + "phys-items": { + "PhysIf-list": [ + { + "accessVlan": "vlan-1", + "adminSt": "up", + "descr": "macse interface", + "FECMode": "auto", + "id": "eth1/1", + "layer": "Layer2", + "mtu": 1500, + "medium": "broadcast", + "mode": "access", + "nativeVlan": "vlan-1", + "userCfgdFlags": "admin_layer,admin_state", + "physExtd-items": { + "bufferBoost": "enable" + } + } + ] + } + }, + "stp-items": { + "inst-items": { + "if-items": { + "If-list": [ + { + "bpdufilter": "default", + "bpduguard": "default", + "id": "eth1/1", + "mode": "default" + } + ] + } + } + } + } +} + +-- state/delete -- + +{ + "System": { + "procsys-items": { + "bootTime": "1700000000" + }, + "fm-items": { + "macsec-items": { + "adminSt": "enabled" + } + }, + "intf-items": { + "phys-items": { + "PhysIf-list": [ + { + "accessVlan": "vlan-1", + "descr": "DME_UNSET_PROPERTY_MARKER", + "FECMode": "auto", + "id": "eth1/1", + "layer": "Layer2", + "mtu": 1500, + "medium": "broadcast", + "mode": "access", + "nativeVlan": "vlan-1", + "userCfgdFlags": "", + "physExtd-items": { + "bufferBoost": "enable" + }, + "trunkVlans": "1-4094" + } + ] + } + }, + "macsec-items": { + "inst-items": { + "policy-items": { + "Policy-list": [] + }, + "if-items": { + "If-list": [] + } + } + }, + "kcmgr-items": { + "keychains-items": { + "macseckeychain-items": { + "MacsecKeychain-list": [] + } + } + }, + "stp-items": { + "inst-items": { + "if-items": { + "If-list": [ + { + "bpdufilter": "default", + "bpduguard": "default", + "id": "eth1/1", + "mode": "default" + } + ] + } + } + } + } +}