diff --git a/api/core/v1alpha1/device_types.go b/api/core/v1alpha1/device_types.go index da2ca0f6..d25be908 100644 --- a/api/core/v1alpha1/device_types.go +++ b/api/core/v1alpha1/device_types.go @@ -4,6 +4,9 @@ package v1alpha1 import ( + "crypto/rand" + "fmt" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" ) @@ -16,7 +19,7 @@ type DeviceSpec struct { // Bootstrap is an optional configuration for the device bootstrap process. // It can be used to provide initial configuration templates or scripts that are applied during the device provisioning. // +optional - Bootstrap *Bootstrap `json:"bootstrap,omitempty"` + Provisioning *Provisioning `json:"provisioning,omitempty"` } // Endpoint contains the connection information for the device. @@ -48,11 +51,40 @@ type TLS struct { Certificate *CertificateSource `json:"certificate,omitempty"` } -// Bootstrap defines the configuration for device bootstrap. -type Bootstrap struct { - // Template defines the multiline string template that contains the initial configuration for the device. +// Provisioning defines the configuration for device bootstrap. +type Provisioning struct { + // Image defines the image to be used for provisioning the device. // +required - Template TemplateSource `json:"template"` + Image Image `json:"image"` + + // BootScript defines the script delivered by a TFTP server to the device during bootstrapping. + // +optional + BootScript TemplateSource `json:"bootScript"` +} + +// ChecksumType defines the type of checksum used for image verification. +// +kubebuilder:validation:Enum=SHA256;MD5 +type ChecksumType string + +const ( + ChecksumTypeSHA256 ChecksumType = "SHA256" + ChecksumTypeMD5 ChecksumType = "MD5" +) + +type Image struct { + // URL is the location of the image to be used for provisioning. + // +required + URL string `json:"url"` + + // Checksum is the checksum of the image for verification. + // +required + // kubebuilder:validation:MinLength=1 + Checksum string `json:"checksum"` + + // ChecksumType is the type of the checksum (e.g., sha256, md5). + // +required + // +kubebuilder:default=MD5 + ChecksumType ChecksumType `json:"checksumType"` } // TemplateSource defines a source for template content. @@ -105,6 +137,14 @@ type DeviceStatus struct { // +optional FirmwareVersion string `json:"firmwareVersion,omitempty"` + // Provisioning is the list of provisioning attempts for the Device. + //+listType=map + //+listMapKey=startTime + //+patchStrategy=merge + //+patchMergeKey=startTime + //+optional + Provisioning []ProvisioningInfo `json:"provisioning,omitempty"` + // Ports is the list of ports on the Device. // +optional Ports []DevicePort `json:"ports,omitempty"` @@ -122,6 +162,41 @@ type DeviceStatus struct { Conditions []metav1.Condition `json:"conditions,omitempty" patchStrategy:"merge" patchMergeKey:"type"` } +type ProvisioningInfo struct { + StartTime metav1.Time `json:"startTime"` + EndTime metav1.Time `json:"endTime,omitempty"` + Token string `json:"token"` + Error string `json:"error,omitempty"` +} + +func (d *Device) GetActiveProvisioning() *ProvisioningInfo { + for i := range d.Status.Provisioning { + if d.Status.Provisioning[i].EndTime.IsZero() { + return &d.Status.Provisioning[i] + } + } + return nil +} + +func (d *Device) CreateProvisioningEntry() (*ProvisioningInfo, error) { + + if d.Status.Phase != DevicePhaseProvisioning { + return nil, fmt.Errorf("device is in phase %s, expected %s", d.Status.Phase, DevicePhaseProvisioning) + } + active := d.GetActiveProvisioning() + if active != nil { + return nil, fmt.Errorf("device has an active provisioning with StartTime %s", active.StartTime.String()) + } + token := make([]byte, 32) + rand.Read(token) + entry := ProvisioningInfo{ + StartTime: metav1.Now(), + Token: fmt.Sprintf("%x", token), + } + d.Status.Provisioning = append(d.Status.Provisioning, entry) + return &d.Status.Provisioning[len(d.Status.Provisioning)-1], nil +} + type DevicePort struct { // Name is the name of the port. // +required @@ -137,7 +212,7 @@ type DevicePort struct { // Transceiver is the type of transceiver plugged into the port, if any. // +optional - Trasceiver string `json:"transceiver,omitempty"` + Transceiver string `json:"transceiver,omitempty"` // InterfaceRef is the reference to the corresponding Interface resource // configuring this port, if any. @@ -146,7 +221,7 @@ type DevicePort struct { } // DevicePhase represents the current phase of the Device as it's being provisioned and managed by the operator. -// +kubebuilder:validation:Enum=Pending;Provisioning;Active;Failed +// +kubebuilder:validation:Enum=Pending;Provisioning;Active;Failed;ProvisioningCompleted type DevicePhase string const ( @@ -154,6 +229,8 @@ const ( DevicePhasePending DevicePhase = "Pending" // DevicePhaseProvisioning indicates that the device is being provisioned. DevicePhaseProvisioning DevicePhase = "Provisioning" + // DevicePhaseProvisioningCompleted indicates that the device provisioning has completed and the operator is performing post-provisioning tasks. + DevicePhaseProvisioningCompleted DevicePhase = "ProvisioningCompleted" // DevicePhaseActive indicates that the device has been successfully provisioned and is now ready for use. DevicePhaseActive DevicePhase = "Active" // DevicePhaseFailed indicates that the device provisioning has failed. @@ -211,11 +288,6 @@ func (d *Device) GetSecretRefs() []SecretReference { refs = append(refs, d.Spec.Endpoint.TLS.Certificate.SecretRef) } } - if d.Spec.Bootstrap != nil { - if d.Spec.Bootstrap.Template.SecretRef != nil { - refs = append(refs, d.Spec.Bootstrap.Template.SecretRef.SecretReference) - } - } for i := range refs { if refs[i].Namespace == "" { refs[i].Namespace = d.Namespace @@ -227,11 +299,6 @@ func (d *Device) GetSecretRefs() []SecretReference { // GetConfigMapRefs returns the list of configmaps referenced in the [Device] resource. func (d *Device) GetConfigMapRefs() []ConfigMapReference { refs := []ConfigMapReference{} - if d.Spec.Bootstrap != nil { - if d.Spec.Bootstrap.Template.ConfigMapRef != nil { - refs = append(refs, d.Spec.Bootstrap.Template.ConfigMapRef.ConfigMapReference) - } - } for i := range refs { if refs[i].Namespace == "" { refs[i].Namespace = d.Namespace diff --git a/api/core/v1alpha1/groupversion_info.go b/api/core/v1alpha1/groupversion_info.go index d136848f..30159f84 100644 --- a/api/core/v1alpha1/groupversion_info.go +++ b/api/core/v1alpha1/groupversion_info.go @@ -39,6 +39,8 @@ const FinalizerName = "networking.metal.ironcore.dev/finalizer" // based on the device they are intended for. const DeviceLabel = "networking.metal.ironcore.dev/device-name" +const DeviceSerialLabel = "networking.metal.ironcore.dev/device-serial" + // DeviceKind represents the Kind of Device. const DeviceKind = "Device" @@ -77,6 +79,21 @@ const ( OperationalCondition = "Operational" ) +// ProvisioningReasonType represents the reason for the current provisioning status. +type ProvisioningReasonType string + +const ( + ProvisioningScriptExecutionStarted ProvisioningReasonType = "ScriptExecutionStarted" + ProvisioningScriptExecutionFailed ProvisioningReasonType = "ScriptExecutionFailed" + ProvisioningInstallingCertificates ProvisioningReasonType = "InstallingCertificates" + ProvisioningDownloadingImage ProvisioningReasonType = "DownloadingImage" + ProvisioningImageDownloadFailed ProvisioningReasonType = "ImageDownloadFailed" + ProvisioningUpgradeStarting ProvisioningReasonType = "UpgradeStarting" + ProvisioningUpgradeFailed ProvisioningReasonType = "UpgradeFailed" + ProvisioningRebootingDevice ProvisioningReasonType = "RebootingDevice" + ProvisioningExecutionFinishedWithoutReboot ProvisioningReasonType = "ExecutionFinishedWithoutReboot" +) + // Reasons that are used across different objects. const ( // ReadyReason indicates that the resource is ready for use. diff --git a/api/core/v1alpha1/zz_generated.deepcopy.go b/api/core/v1alpha1/zz_generated.deepcopy.go index d2660a1e..7f6277c8 100644 --- a/api/core/v1alpha1/zz_generated.deepcopy.go +++ b/api/core/v1alpha1/zz_generated.deepcopy.go @@ -714,22 +714,6 @@ func (in *BannerStatus) DeepCopy() *BannerStatus { return out } -// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. -func (in *Bootstrap) DeepCopyInto(out *Bootstrap) { - *out = *in - in.Template.DeepCopyInto(&out.Template) -} - -// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new Bootstrap. -func (in *Bootstrap) DeepCopy() *Bootstrap { - if in == nil { - return nil - } - out := new(Bootstrap) - in.DeepCopyInto(out) - return out -} - // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *Certificate) DeepCopyInto(out *Certificate) { *out = *in @@ -1090,9 +1074,9 @@ func (in *DevicePort) DeepCopy() *DevicePort { func (in *DeviceSpec) DeepCopyInto(out *DeviceSpec) { *out = *in in.Endpoint.DeepCopyInto(&out.Endpoint) - if in.Bootstrap != nil { - in, out := &in.Bootstrap, &out.Bootstrap - *out = new(Bootstrap) + if in.Provisioning != nil { + in, out := &in.Provisioning, &out.Provisioning + *out = new(Provisioning) (*in).DeepCopyInto(*out) } } @@ -1110,6 +1094,13 @@ func (in *DeviceSpec) DeepCopy() *DeviceSpec { // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *DeviceStatus) DeepCopyInto(out *DeviceStatus) { *out = *in + if in.Provisioning != nil { + in, out := &in.Provisioning, &out.Provisioning + *out = make([]ProvisioningInfo, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } if in.Ports != nil { in, out := &in.Ports, &out.Ports *out = make([]DevicePort, len(*in)) @@ -1464,6 +1455,21 @@ func (in *ISISStatus) DeepCopy() *ISISStatus { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *Image) DeepCopyInto(out *Image) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new Image. +func (in *Image) DeepCopy() *Image { + if in == nil { + return nil + } + out := new(Image) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *Interface) DeepCopyInto(out *Interface) { *out = *in @@ -2253,6 +2259,40 @@ func (in *PasswordSource) DeepCopy() *PasswordSource { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *Provisioning) DeepCopyInto(out *Provisioning) { + *out = *in + out.Image = in.Image + in.BootScript.DeepCopyInto(&out.BootScript) +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new Provisioning. +func (in *Provisioning) DeepCopy() *Provisioning { + if in == nil { + return nil + } + out := new(Provisioning) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ProvisioningInfo) DeepCopyInto(out *ProvisioningInfo) { + *out = *in + in.StartTime.DeepCopyInto(&out.StartTime) + in.EndTime.DeepCopyInto(&out.EndTime) +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ProvisioningInfo. +func (in *ProvisioningInfo) DeepCopy() *ProvisioningInfo { + if in == nil { + return nil + } + out := new(ProvisioningInfo) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *RendezvousPoint) DeepCopyInto(out *RendezvousPoint) { *out = *in diff --git a/charts/network-operator/templates/crd/networking.metal.ironcore.dev_devices.yaml b/charts/network-operator/templates/crd/networking.metal.ironcore.dev_devices.yaml index 2a9f7b7e..4025fa2f 100644 --- a/charts/network-operator/templates/crd/networking.metal.ironcore.dev_devices.yaml +++ b/charts/network-operator/templates/crd/networking.metal.ironcore.dev_devices.yaml @@ -80,84 +80,6 @@ spec: Specification of the desired state of the resource. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#spec-and-status properties: - bootstrap: - description: |- - Bootstrap is an optional configuration for the device bootstrap process. - It can be used to provide initial configuration templates or scripts that are applied during the device provisioning. - properties: - template: - description: Template defines the multiline string template that - contains the initial configuration for the device. - properties: - configMapRef: - description: Reference to a ConfigMap containing the template - properties: - key: - description: |- - Key is the of the entry in the configmap resource's `data` or `binaryData` - field to be used. - maxLength: 253 - minLength: 1 - type: string - name: - description: Name is unique within a namespace to reference - a configmap resource. - maxLength: 253 - minLength: 1 - type: string - namespace: - description: |- - Namespace defines the space within which the configmap name must be unique. - If omitted, the namespace of the object being reconciled will be used. - maxLength: 63 - minLength: 1 - type: string - required: - - key - - name - type: object - x-kubernetes-map-type: atomic - inline: - description: Inline template content - minLength: 1 - type: string - secretRef: - description: Reference to a Secret containing the template - properties: - key: - description: |- - Key is the of the entry in the secret resource's `data` or `stringData` - field to be used. - maxLength: 253 - minLength: 1 - type: string - name: - description: Name is unique within a namespace to reference - a secret resource. - maxLength: 253 - minLength: 1 - type: string - namespace: - description: |- - Namespace defines the space within which the secret name must be unique. - If omitted, the namespace of the object being reconciled will be used. - maxLength: 63 - minLength: 1 - type: string - required: - - key - - name - type: object - x-kubernetes-map-type: atomic - type: object - x-kubernetes-validations: - - message: exactly one of 'inline', 'secretRef', or 'configMapRef' - must be specified - rule: '[has(self.inline), has(self.secretRef), has(self.configMapRef)].filter(x, - x).size() == 1' - required: - - template - type: object endpoint: description: Endpoint contains the connection information for the device. @@ -260,6 +182,110 @@ spec: x-kubernetes-validations: - message: SecretRef is required once set rule: '!has(oldSelf.secretRef) || has(self.secretRef)' + provisioning: + description: |- + Bootstrap is an optional configuration for the device bootstrap process. + It can be used to provide initial configuration templates or scripts that are applied during the device provisioning. + properties: + bootScript: + description: BootScript defines the script delivered by a TFTP + server to the device during bootstrapping. + properties: + configMapRef: + description: Reference to a ConfigMap containing the template + properties: + key: + description: |- + Key is the of the entry in the configmap resource's `data` or `binaryData` + field to be used. + maxLength: 253 + minLength: 1 + type: string + name: + description: Name is unique within a namespace to reference + a configmap resource. + maxLength: 253 + minLength: 1 + type: string + namespace: + description: |- + Namespace defines the space within which the configmap name must be unique. + If omitted, the namespace of the object being reconciled will be used. + maxLength: 63 + minLength: 1 + type: string + required: + - key + - name + type: object + x-kubernetes-map-type: atomic + inline: + description: Inline template content + minLength: 1 + type: string + secretRef: + description: Reference to a Secret containing the template + properties: + key: + description: |- + Key is the of the entry in the secret resource's `data` or `stringData` + field to be used. + maxLength: 253 + minLength: 1 + type: string + name: + description: Name is unique within a namespace to reference + a secret resource. + maxLength: 253 + minLength: 1 + type: string + namespace: + description: |- + Namespace defines the space within which the secret name must be unique. + If omitted, the namespace of the object being reconciled will be used. + maxLength: 63 + minLength: 1 + type: string + required: + - key + - name + type: object + x-kubernetes-map-type: atomic + type: object + x-kubernetes-validations: + - message: exactly one of 'inline', 'secretRef', or 'configMapRef' + must be specified + rule: '[has(self.inline), has(self.secretRef), has(self.configMapRef)].filter(x, + x).size() == 1' + image: + description: Image defines the image to be used for provisioning + the device. + properties: + checksum: + description: |- + Checksum is the checksum of the image for verification. + kubebuilder:validation:MinLength=1 + type: string + checksumType: + default: MD5 + description: ChecksumType is the type of the checksum (e.g., + sha256, md5). + enum: + - SHA256 + - MD5 + type: string + url: + description: URL is the location of the image to be used for + provisioning. + type: string + required: + - checksum + - checksumType + - url + type: object + required: + - image + type: object required: - endpoint type: object @@ -348,6 +374,7 @@ spec: - Provisioning - Active - Failed + - ProvisioningCompleted type: string portSummary: description: PostSummary shows a summary of the port configured, grouped @@ -394,6 +421,29 @@ spec: - name type: object type: array + provisioning: + description: Provisioning is the list of provisioning attempts for + the Device. + items: + properties: + endTime: + format: date-time + type: string + error: + type: string + startTime: + format: date-time + type: string + token: + type: string + required: + - startTime + - token + type: object + type: array + x-kubernetes-list-map-keys: + - startTime + x-kubernetes-list-type: map serialNumber: description: SerialNumber is the serial number of the Device. type: string diff --git a/cmd/main.go b/cmd/main.go index 6691b281..389190d2 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -15,6 +15,7 @@ import ( // Import all Kubernetes client auth plugins (e.g. Azure, GCP, OIDC, etc.) // to ensure that exec-entrypoint and run can make use of them. _ "k8s.io/client-go/plugin/pkg/client/auth" + "k8s.io/klog/v2" "k8s.io/utils/ptr" // Set runtime concurrency to match CPU limit imposed by Kubernetes @@ -38,6 +39,7 @@ import ( // Import all supported provider implementations. _ "github.com/ironcore-dev/network-operator/internal/provider/cisco/nxos" _ "github.com/ironcore-dev/network-operator/internal/provider/openconfig" + "github.com/ironcore-dev/network-operator/internal/provisioning" nxv1alpha1 "github.com/ironcore-dev/network-operator/api/cisco/nx/v1alpha1" "github.com/ironcore-dev/network-operator/api/core/v1alpha1" @@ -75,6 +77,8 @@ func main() { var watchFilterValue string var providerName string var requeueInterval time.Duration + var provisioningHTTPPort int + var provisioningHTTPValidateSourceIP bool flag.StringVar(&metricsAddr, "metrics-bind-address", "0", "The address the metrics endpoint binds to. Use :8443 for HTTPS or :8080 for HTTP, or leave as 0 to disable the metrics service.") flag.StringVar(&probeAddr, "health-probe-bind-address", ":8081", "The address the probe endpoint binds to.") flag.BoolVar(&enableLeaderElection, "leader-elect", false, "Enable leader election for controller manager. Enabling this will ensure there is only one active controller manager.") @@ -89,6 +93,8 @@ func main() { flag.StringVar(&watchFilterValue, "watch-filter", "", fmt.Sprintf("Label value that the controller watches to reconcile api objects. Label key is always %q. If unspecified, the controller watches for all api objects.", v1alpha1.WatchLabel)) flag.StringVar(&providerName, "provider", "openconfig", "The provider to use for the controller. If not specified, the default provider is used. Available providers: "+strings.Join(provider.Providers(), ", ")) flag.DurationVar(&requeueInterval, "requeue-interval", 30*time.Second, "The interval after which Kubernetes resources should be reconciled again regardless of whether they have changed.") + flag.IntVar(&provisioningHTTPPort, "provisioning-http-port", 8080, "The port on which the provisioning HTTP server listens.") + flag.BoolVar(&provisioningHTTPValidateSourceIP, "provisioning-http-validate-source-ip", false, "If set, the provisioning HTTP server will validate the source IP of incoming requests against the DeviceIPLabel of Device resources.") opts := zap.Options{ Development: true, TimeEncoder: zapcore.ISO8601TimeEncoder, @@ -461,6 +467,25 @@ func main() { setupLog.Error(err, "unable to create controller", "controller", "EVPNInstance") os.Exit(1) } + provisioningProvider, ok := prov().(provider.ProvisioningProvider) + if provisioningHTTPPort != 0 && ok { + provisioningServer := provisioning.HTTPServer{ + Client: mgr.GetClient(), + Port: provisioningHTTPPort, + Logger: klog.NewKlogr().WithName("provisioning"), + Recorder: mgr.GetEventRecorderFor("provisioning"), + ValidateSourceIP: provisioningHTTPValidateSourceIP, + Provider: provisioningProvider, + } + setupLog.Info("Starting provisioning HTTP server", "port", provisioningHTTPPort, "validateSourceIP", provisioningHTTPValidateSourceIP) + go func() { + if err := provisioningServer.Start(ctx); err != nil { + setupLog.Error(err, "provisioning HTTP server failed") + os.Exit(1) + } + }() + } + // +kubebuilder:scaffold:builder if metricsCertWatcher != nil { diff --git a/config/crd/bases/networking.metal.ironcore.dev_devices.yaml b/config/crd/bases/networking.metal.ironcore.dev_devices.yaml index 7a486aa8..b982dd8b 100644 --- a/config/crd/bases/networking.metal.ironcore.dev_devices.yaml +++ b/config/crd/bases/networking.metal.ironcore.dev_devices.yaml @@ -74,84 +74,6 @@ spec: Specification of the desired state of the resource. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#spec-and-status properties: - bootstrap: - description: |- - Bootstrap is an optional configuration for the device bootstrap process. - It can be used to provide initial configuration templates or scripts that are applied during the device provisioning. - properties: - template: - description: Template defines the multiline string template that - contains the initial configuration for the device. - properties: - configMapRef: - description: Reference to a ConfigMap containing the template - properties: - key: - description: |- - Key is the of the entry in the configmap resource's `data` or `binaryData` - field to be used. - maxLength: 253 - minLength: 1 - type: string - name: - description: Name is unique within a namespace to reference - a configmap resource. - maxLength: 253 - minLength: 1 - type: string - namespace: - description: |- - Namespace defines the space within which the configmap name must be unique. - If omitted, the namespace of the object being reconciled will be used. - maxLength: 63 - minLength: 1 - type: string - required: - - key - - name - type: object - x-kubernetes-map-type: atomic - inline: - description: Inline template content - minLength: 1 - type: string - secretRef: - description: Reference to a Secret containing the template - properties: - key: - description: |- - Key is the of the entry in the secret resource's `data` or `stringData` - field to be used. - maxLength: 253 - minLength: 1 - type: string - name: - description: Name is unique within a namespace to reference - a secret resource. - maxLength: 253 - minLength: 1 - type: string - namespace: - description: |- - Namespace defines the space within which the secret name must be unique. - If omitted, the namespace of the object being reconciled will be used. - maxLength: 63 - minLength: 1 - type: string - required: - - key - - name - type: object - x-kubernetes-map-type: atomic - type: object - x-kubernetes-validations: - - message: exactly one of 'inline', 'secretRef', or 'configMapRef' - must be specified - rule: '[has(self.inline), has(self.secretRef), has(self.configMapRef)].filter(x, - x).size() == 1' - required: - - template - type: object endpoint: description: Endpoint contains the connection information for the device. @@ -254,6 +176,110 @@ spec: x-kubernetes-validations: - message: SecretRef is required once set rule: '!has(oldSelf.secretRef) || has(self.secretRef)' + provisioning: + description: |- + Bootstrap is an optional configuration for the device bootstrap process. + It can be used to provide initial configuration templates or scripts that are applied during the device provisioning. + properties: + bootScript: + description: BootScript defines the script delivered by a TFTP + server to the device during bootstrapping. + properties: + configMapRef: + description: Reference to a ConfigMap containing the template + properties: + key: + description: |- + Key is the of the entry in the configmap resource's `data` or `binaryData` + field to be used. + maxLength: 253 + minLength: 1 + type: string + name: + description: Name is unique within a namespace to reference + a configmap resource. + maxLength: 253 + minLength: 1 + type: string + namespace: + description: |- + Namespace defines the space within which the configmap name must be unique. + If omitted, the namespace of the object being reconciled will be used. + maxLength: 63 + minLength: 1 + type: string + required: + - key + - name + type: object + x-kubernetes-map-type: atomic + inline: + description: Inline template content + minLength: 1 + type: string + secretRef: + description: Reference to a Secret containing the template + properties: + key: + description: |- + Key is the of the entry in the secret resource's `data` or `stringData` + field to be used. + maxLength: 253 + minLength: 1 + type: string + name: + description: Name is unique within a namespace to reference + a secret resource. + maxLength: 253 + minLength: 1 + type: string + namespace: + description: |- + Namespace defines the space within which the secret name must be unique. + If omitted, the namespace of the object being reconciled will be used. + maxLength: 63 + minLength: 1 + type: string + required: + - key + - name + type: object + x-kubernetes-map-type: atomic + type: object + x-kubernetes-validations: + - message: exactly one of 'inline', 'secretRef', or 'configMapRef' + must be specified + rule: '[has(self.inline), has(self.secretRef), has(self.configMapRef)].filter(x, + x).size() == 1' + image: + description: Image defines the image to be used for provisioning + the device. + properties: + checksum: + description: |- + Checksum is the checksum of the image for verification. + kubebuilder:validation:MinLength=1 + type: string + checksumType: + default: MD5 + description: ChecksumType is the type of the checksum (e.g., + sha256, md5). + enum: + - SHA256 + - MD5 + type: string + url: + description: URL is the location of the image to be used for + provisioning. + type: string + required: + - checksum + - checksumType + - url + type: object + required: + - image + type: object required: - endpoint type: object @@ -342,6 +368,7 @@ spec: - Provisioning - Active - Failed + - ProvisioningCompleted type: string portSummary: description: PostSummary shows a summary of the port configured, grouped @@ -388,6 +415,29 @@ spec: - name type: object type: array + provisioning: + description: Provisioning is the list of provisioning attempts for + the Device. + items: + properties: + endTime: + format: date-time + type: string + error: + type: string + startTime: + format: date-time + type: string + token: + type: string + required: + - startTime + - token + type: object + type: array + x-kubernetes-list-map-keys: + - startTime + x-kubernetes-list-type: map serialNumber: description: SerialNumber is the serial number of the Device. type: string diff --git a/internal/controller/core/device_controller.go b/internal/controller/core/device_controller.go index 2a5f24e0..217d2c37 100644 --- a/internal/controller/core/device_controller.go +++ b/internal/controller/core/device_controller.go @@ -104,27 +104,20 @@ func (r *DeviceReconciler) Reconcile(ctx context.Context, req ctrl.Request) (_ c switch obj.Status.Phase { case v1alpha1.DevicePhasePending: - if obj.Spec.Bootstrap == nil { - // Skip provisioning if no bootstrap configuration is provided. + if obj.Spec.Provisioning == nil { + // Skip provisioning if no provisioning configuration is provided. obj.Status.Phase = v1alpha1.DevicePhaseActive return ctrl.Result{}, nil } - log.Info("Device is in pending phase, starting provisioning") - c := clientutil.NewClient(r.Client, req.Namespace) - tmpl, err := c.Template(ctx, &obj.Spec.Bootstrap.Template) - if err != nil { - log.Error(err, "Failed to get template for device provisioning") - conditions.Set(obj, metav1.Condition{ - Type: v1alpha1.ReadyCondition, - Status: metav1.ConditionFalse, - Reason: v1alpha1.NotReadyReason, - Message: fmt.Sprintf("Failed to get template for device provisioning: %v", err), - }) - obj.Status.Phase = v1alpha1.DevicePhaseFailed - r.Recorder.Event(obj, "Warning", "ProvisioningFailed", "Device provisioning failed due to template retrieval error") - return ctrl.Result{}, err + if _, ok := r.Provider().(provider.ProvisioningProvider); !ok { + // Skip provisioning if the provider does not support it. + log.Info("Provider does not support provisioning, skipping") + obj.Status.Phase = v1alpha1.DevicePhaseActive + return ctrl.Result{}, nil } + + log.Info("Device is in pending phase, starting provisioning") conditions.Set(obj, metav1.Condition{ Type: v1alpha1.ReadyCondition, Status: metav1.ConditionFalse, @@ -133,29 +126,23 @@ func (r *DeviceReconciler) Reconcile(ctx context.Context, req ctrl.Request) (_ c }) obj.Status.Phase = v1alpha1.DevicePhaseProvisioning r.Recorder.Event(obj, "Normal", "ProvisioningStarted", "Device provisioning has started") - // TODO(swagner-de): Start POAP Process. - _ = tmpl // <-- Use the template. return ctrl.Result{RequeueAfter: r.RequeueInterval}, nil case v1alpha1.DevicePhaseProvisioning: - log.Info("Device is in provisioning phase, checking completion") - // TODO(swagner-de): Check if POAP Process is complete. - ready := true // <-- This should be replaced with actual readiness check logic. - if !ready { - // If the device is not ready yet, we requeue the request to check again later. - return ctrl.Result{RequeueAfter: r.RequeueInterval}, nil + log.Info("Device is in provisioning phase, waiting for status report") + return ctrl.Result{}, nil + + case v1alpha1.DevicePhaseProvisioningCompleted: + log.Info("Device provisioning completed, running post provisioning checks") + prov, _ := r.Provider().(provider.ProvisioningProvider) + conn, err := deviceutil.GetDeviceConnection(ctx, r, obj) + if err != nil { + return ctrl.Result{}, fmt.Errorf("failed to create obtain device connection: %w", err) + } + if ok := prov.VerifyProvisioningCompleted(ctx, conn, obj); !ok { + return ctrl.Result{RequeueAfter: 30 * time.Second}, nil } - log.Info("Device provisioning is complete, updating status") - conditions.Set(obj, metav1.Condition{ - Type: v1alpha1.ReadyCondition, - Status: metav1.ConditionTrue, - Reason: v1alpha1.ReadyReason, - Message: "Device is ready for use", - }) obj.Status.Phase = v1alpha1.DevicePhaseActive - r.Recorder.Event(obj, "Normal", "ProvisioningComplete", "Device provisioning has completed successfully") - // Trigger a status update and let the controller requeue the request - return ctrl.Result{}, nil case v1alpha1.DevicePhaseActive: if err := r.reconcile(ctx, obj); err != nil { @@ -265,7 +252,7 @@ func (r *DeviceReconciler) reconcile(ctx context.Context, device *v1alpha1.Devic Name: p.ID, Type: p.Type, SupportedSpeedsGbps: p.SupportedSpeedsGbps, - Trasceiver: p.Transceiver, + Transceiver: p.Transceiver, InterfaceRef: ref, } } diff --git a/internal/controller/core/device_controller_test.go b/internal/controller/core/device_controller_test.go index c6c07f9e..6a42640b 100644 --- a/internal/controller/core/device_controller_test.go +++ b/internal/controller/core/device_controller_test.go @@ -8,7 +8,6 @@ import ( . "github.com/onsi/gomega" corev1 "k8s.io/api/core/v1" "k8s.io/apimachinery/pkg/api/errors" - "k8s.io/utils/ptr" "sigs.k8s.io/controller-runtime/pkg/client" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" @@ -54,11 +53,6 @@ var _ = Describe("Device Controller", func() { Name: name, }, }, - Bootstrap: &v1alpha1.Bootstrap{ - Template: v1alpha1.TemplateSource{ - Inline: ptr.To("device-template"), - }, - }, }, } Expect(k8sClient.Create(ctx, resource)).To(Succeed()) @@ -128,7 +122,7 @@ var _ = Describe("Device Controller", func() { g.Expect(resource.Status.Ports[0].Name).To(Equal("eth1/1")) g.Expect(resource.Status.Ports[0].Type).To(Equal("10g")) g.Expect(resource.Status.Ports[0].SupportedSpeedsGbps).To(Equal([]int32{1, 10})) - g.Expect(resource.Status.Ports[0].Trasceiver).To(Equal("QSFP-DD")) + g.Expect(resource.Status.Ports[0].Transceiver).To(Equal("QSFP-DD")) g.Expect(resource.Status.Ports[0].InterfaceRef).ToNot(BeNil()) g.Expect(resource.Status.Ports[0].InterfaceRef.Name).To(Equal(name)) g.Expect(resource.Status.PostSummary).To(Equal("1/8 (10g)")) diff --git a/internal/deviceutil/deviceutil.go b/internal/deviceutil/deviceutil.go index 3c9dc07e..4572d30f 100644 --- a/internal/deviceutil/deviceutil.go +++ b/internal/deviceutil/deviceutil.go @@ -14,6 +14,7 @@ import ( "google.golang.org/grpc/credentials" "google.golang.org/grpc/credentials/insecure" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/labels" "k8s.io/apimachinery/pkg/runtime/schema" ctrl "sigs.k8s.io/controller-runtime" "sigs.k8s.io/controller-runtime/pkg/client" @@ -62,6 +63,28 @@ func GetDeviceByName(ctx context.Context, r client.Reader, namespace, name strin return obj, nil } +func GetDeviceBySerial(ctx context.Context, r client.Reader, namespace, serial string) (*v1alpha1.Device, error) { + deviceList := &v1alpha1.DeviceList{} + listOpts := &client.ListOptions{ + LabelSelector: labels.SelectorFromSet(labels.Set{v1alpha1.DeviceSerialLabel: serial}), + } + + if namespace != "" { + listOpts.Namespace = namespace + } + + if err := r.List(ctx, deviceList, listOpts); err != nil { + return nil, fmt.Errorf("failed to list %s objects: %w", v1alpha1.GroupVersion.WithKind(v1alpha1.DeviceKind).String(), err) + } + if len(deviceList.Items) == 0 { + return nil, fmt.Errorf("no %s object found with serial %q", v1alpha1.GroupVersion.WithKind(v1alpha1.DeviceKind).String(), serial) + } + if len(deviceList.Items) > 1 { + return nil, fmt.Errorf("multiple %s objects found with serial %q", v1alpha1.GroupVersion.WithKind(v1alpha1.DeviceKind).String(), serial) + } + return &deviceList.Items[0], nil +} + // Connection holds the necessary information to connect to a device's API. // // TODO(felix-kaestner): find a better place for this struct, maybe in a 'connection' package? diff --git a/internal/provider/cisco/nxos/provider.go b/internal/provider/cisco/nxos/provider.go index d2822575..c67bc4f1 100644 --- a/internal/provider/cisco/nxos/provider.go +++ b/internal/provider/cisco/nxos/provider.go @@ -6,6 +6,7 @@ package nxos import ( "cmp" "context" + "crypto/rand" "crypto/rsa" "errors" "fmt" @@ -31,6 +32,7 @@ import ( var ( _ provider.Provider = (*Provider)(nil) _ provider.DeviceProvider = (*Provider)(nil) + _ provider.ProvisioningProvider = (*Provider)(nil) _ provider.ACLProvider = (*Provider)(nil) _ provider.BannerProvider = (*Provider)(nil) _ provider.BGPProvider = (*Provider)(nil) @@ -77,6 +79,27 @@ func (p *Provider) Disconnect(_ context.Context, _ *deviceutil.Connection) error return p.conn.Close() } +func (p *Provider) HashProvisioningPassword(password string) (string, string, error) { + salt := make([]byte, 10) + rand.Read(salt) + s := [10]byte{} + copy(s[:], salt) + e := Scrypt{Salt: s} + hashed, pwdEncryptType, err := e.Encode(password) + if err != nil { + return "", "", err + } + return hashed, string(pwdEncryptType), nil +} + +func (p *Provider) VerifyProvisioningCompleted(ctx context.Context, conn *deviceutil.Connection, device *v1alpha1.Device) bool { + if err := p.Connect(ctx, conn); err != nil { + return false + } + defer p.Disconnect(ctx, conn) + return true +} + func (p *Provider) ListPorts(ctx context.Context) ([]provider.DevicePort, error) { ports := new(Ports) if err := p.client.GetState(ctx, ports); err != nil { diff --git a/internal/provider/provider.go b/internal/provider/provider.go index d0c59822..749deddb 100644 --- a/internal/provider/provider.go +++ b/internal/provider/provider.go @@ -37,6 +37,14 @@ type DeviceProvider interface { GetDeviceInfo(context.Context) (*DeviceInfo, error) } +// ProvisioningProvider is the interface for the realization of the provisioning-related operations over different providers. +type ProvisioningProvider interface { + // HashedPassword takes a plaintext password and returns the hashed password along with the hash type. This is necessary to securely provision user accounts on devices using a potentially insecure channel. + HashProvisioningPassword(password string) (string, string, error) + // VerifyProvisioningCompleted checks if the provisioning process has been completed successfully on the device. + VerifyProvisioningCompleted(context.Context, *deviceutil.Connection, *v1alpha1.Device) bool +} + type DevicePort struct { // ID is the unique identifier of the port on the device. ID string diff --git a/internal/provisioning/http.go b/internal/provisioning/http.go new file mode 100644 index 00000000..d77b94cf --- /dev/null +++ b/internal/provisioning/http.go @@ -0,0 +1,438 @@ +// SPDX-FileCopyrightText: 2025 SAP SE or an SAP affiliate company and IronCore contributors +// SPDX-License-Identifier: Apache-2.0 + +package provisioning + +import ( + "context" + "encoding/json" + "fmt" + "net" + "net/http" + "strings" + + corev1 "k8s.io/api/core/v1" + + "github.com/ironcore-dev/network-operator/api/core/v1alpha1" + "github.com/ironcore-dev/network-operator/internal/clientutil" + "github.com/ironcore-dev/network-operator/internal/conditions" + "github.com/ironcore-dev/network-operator/internal/deviceutil" + "github.com/ironcore-dev/network-operator/internal/provider" + "k8s.io/client-go/tools/record" + "k8s.io/klog/v2" + "sigs.k8s.io/controller-runtime/pkg/client" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +func getClientIP(r *http.Request) (string, error) { + forwarded := r.Header.Get("X-Forwarded-For") + if forwarded != "" { + // Take the first IP if there are multiple + ips := strings.Split(forwarded, ",") + return strings.TrimSpace(ips[0]), nil + } + + // Check X-Real-IP header + realIP := r.Header.Get("X-Real-IP") + if realIP != "" { + return realIP, nil + } + + // Use RemoteAddr as fallback + ip, _, err := net.SplitHostPort(r.RemoteAddr) + if err != nil { + return "", fmt.Errorf("Failed to parse remote address: %w", err) + } + return ip, nil +} + +func getBearerToken(r *http.Request) (string, error) { + authHeader := r.Header.Get("Authorization") + if authHeader == "" { + return "", fmt.Errorf("Authorization header is missing") + } + + parts := strings.Split(authHeader, " ") + if len(parts) != 2 || strings.ToLower(parts[0]) != "bearer" { + return "", fmt.Errorf("Invalid authorization header format") + } + + return parts[1], nil +} + +func (s *HTTPServer) findDeviceAndValidateToken(ctx context.Context, serial, token string) (*v1alpha1.Device, *v1alpha1.ProvisioningInfo, int, error) { + device, err := deviceutil.GetDeviceBySerial(ctx, s.Client, "", serial) + if err != nil { + s.Logger.Error(err, "Failed to get device by serial", "serial", serial, "error", err) + return nil, nil, http.StatusInternalServerError, fmt.Errorf("Failed to find device by serial: %w", err) + } + + act := device.GetActiveProvisioning() + + if act == nil { + s.Logger.Error(nil, "No active provisioning found for device", "device", device.Name) + return nil, nil, http.StatusNotFound, fmt.Errorf("no active provisioning found for device: %s", device.Name) + } + if act.Token != token { + return nil, nil, http.StatusUnauthorized, fmt.Errorf("Unauthorized: Invalid token") + } + return device, act, http.StatusOK, nil +} + +type HTTPServer struct { + Client client.Client + Port int + Logger klog.Logger + Mux *http.ServeMux + Recorder record.EventRecorder + ValidateSourceIP bool + Provider provider.ProvisioningProvider +} + +func (s *HTTPServer) Start(ctx context.Context) error { + mux := http.NewServeMux() + mux.HandleFunc("/provisioning/status-report", s.HandleStatusReport) + mux.HandleFunc("/provisioning/config", s.HandleProvisioningRequest) + mux.HandleFunc("/provisioning/device-certificate", s.GetDeviceCertificate) + mux.HandleFunc("/provisioning/mtls-client-ca", s.GetMTLSClientCA) + + httpServer := &http.Server{ + Addr: fmt.Sprintf(":%d", s.Port), + Handler: mux, + } + + s.Logger.Info("Starting provisioning server", "port", s.Port) + + err := httpServer.ListenAndServe() + if err != nil && err != http.ErrServerClosed { + return err + } + return nil +} + +type StatusReport struct { + Serial string `json:"serial"` + Status v1alpha1.ProvisioningReasonType `json:"status"` + Detail string `json:"detail,omitempty"` +} + +func (r *StatusReport) ToCondition() metav1.Condition { + condition := metav1.ConditionFalse + if r.Succeeded() { + condition = metav1.ConditionTrue + } + return metav1.Condition{ + Type: v1alpha1.ReadyCondition, + Status: condition, + Reason: string(r.Status), + Message: r.Detail, + } +} + +func (r *StatusReport) Failed() bool { + switch r.Status { + case v1alpha1.ProvisioningScriptExecutionFailed, + v1alpha1.ProvisioningUpgradeFailed, + v1alpha1.ProvisioningImageDownloadFailed: + return true + default: + return false + } +} + +func (r *StatusReport) Succeeded() bool { + switch r.Status { + case v1alpha1.ProvisioningExecutionFinishedWithoutReboot, + v1alpha1.ProvisioningRebootingDevice: + return true + default: + return false + } +} + +func (s *HTTPServer) HandleStatusReport(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + if r.Method != http.MethodPut { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + token, err := getBearerToken(r) + if err != nil { + http.Error(w, "Unauthorized: "+err.Error(), http.StatusUnauthorized) + return + } + + var report StatusReport + if err := json.NewDecoder(r.Body).Decode(&report); err != nil { + clientIP, _ := getClientIP(r) + s.Logger.Error(err, "Invalid status report body", "IP", clientIP) + http.Error(w, "Invalid JSON body", http.StatusBadRequest) + return + } + defer r.Body.Close() + + serial := report.Serial + device, act, statusCode, err := s.findDeviceAndValidateToken(ctx, serial, token) + if err != nil { + http.Error(w, err.Error(), statusCode) + return + } + eventtype := "Normal" + conditions.Set(device, report.ToCondition()) + + if report.Succeeded() { + device.Status.Phase = v1alpha1.DevicePhaseProvisioningCompleted + act.EndTime = metav1.Now() + } + + if report.Failed() { + device.Status.Phase = v1alpha1.DevicePhaseFailed + act.EndTime = metav1.Now() + act.Error = report.Detail + } + + s.Recorder.Eventf(device, eventtype, "Provisioning", "%s: %s", report.Status, report.Detail) + + if err := s.Client.Status().Update(ctx, device); err != nil { + s.Logger.Error(err, "Failed to update device status", "device", device.Name) + http.Error(w, "Failed to persist device status", http.StatusInternalServerError) + return + } + w.WriteHeader(http.StatusCreated) + w.Write([]byte("OK")) +} + +type ProvisioningResponse struct { + ProvisioningToken string `json:"provisioningToken"` + Image v1alpha1.Image `json:"image"` + UserAccounts []UserAccount `json:"userAccounts"` + Hostname string `json:"hostname"` +} + +type UserAccount struct { + Username string `json:"username"` + HashedPassword string `json:"hashedPassword"` + HashAlgorithm string `json:"hashAlgorithm"` +} + +func (s *HTTPServer) HandleProvisioningRequest(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + + serial := r.URL.Query().Get("serial") + if serial == "" { + http.Error(w, "Serial parameter is required", http.StatusBadRequest) + return + } + + s.Logger.Info("provisioning request received", "serial", serial) + device, err := deviceutil.GetDeviceBySerial(ctx, s.Client, "", serial) + + if err != nil { + s.Logger.Error(err, "Failed to find device by serial", "serial", serial, "error", err) + http.Error(w, "Failed to find device by serial", http.StatusInternalServerError) + return + } + + if s.ValidateSourceIP { + clientIP, err := getClientIP(r) + if err != nil { + s.Logger.Error(err, "Failed to get client IP for validation") + http.Error(w, "Failed to determine client IP", http.StatusBadRequest) + return + } + + deviceIP := strings.Split(device.Spec.Endpoint.Address, ":")[0] + if deviceIP != clientIP { + s.Logger.Error(nil, "Source IP validation failed", "clientIP", clientIP, "deviceIP", deviceIP) + http.Error(w, "Source IP does not match device IP", http.StatusForbidden) + return + } + s.Logger.Info("Source IP validation passed", "clientIP", clientIP, "device", device.Name) + } + + w.Header().Set("Content-Type", "application/json") + act := device.GetActiveProvisioning() + if act == nil { + act, err = device.CreateProvisioningEntry() + if err != nil { + s.Logger.Error(err, "Failed to create provisioning entry", "device", device.Name) + http.Error(w, "Failed to create provisioning entry", http.StatusPreconditionRequired) + return + } + if err := s.Client.Status().Update(ctx, device); err != nil { + s.Logger.Error(err, "Failed to update device status", "device", device.Name) + http.Error(w, "Failed to update device status", http.StatusInternalServerError) + return + } + } + + conn, err := deviceutil.GetDeviceConnection(ctx, s.Client, device) + if err != nil { + s.Logger.Error(err, "Failed to get user accounts", "device", device.Name) + http.Error(w, "Failed to get user accounts", http.StatusInternalServerError) + return + } + + hashedPassword, hashAlgorithm, err := s.Provider.HashProvisioningPassword(conn.Password) + if err != nil { + s.Logger.Error(err, "Failed to hash provisioning password", "device", device.Name) + http.Error(w, "Failed to hash provisioning password", http.StatusInternalServerError) + return + } + + ua := UserAccount{ + Username: conn.Username, + HashedPassword: hashedPassword, + HashAlgorithm: hashAlgorithm, + } + + response := ProvisioningResponse{ + ProvisioningToken: act.Token, + Image: device.Spec.Provisioning.Image, + UserAccounts: []UserAccount{ua}, + Hostname: device.ObjectMeta.Name, + } + + content, err := json.Marshal(response) + if err != nil { + s.Logger.Error(err, "Failed to marshal provisioning response", "device", device.Name) + http.Error(w, "Failed to marshal provisioning response", http.StatusInternalServerError) + return + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + w.Write(content) +} + +func (s *HTTPServer) GetMTLSClientCA(w http.ResponseWriter, r *http.Request) { + + ctx := r.Context() + if r.Method != http.MethodGet { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + token, err := getBearerToken(r) + if err != nil { + http.Error(w, "Unauthorized: "+err.Error(), http.StatusUnauthorized) + return + } + + serial := r.URL.Query().Get("serial") + if serial == "" { + http.Error(w, "Serial parameter is required", http.StatusBadRequest) + return + } + + device, _, statusCode, err := s.findDeviceAndValidateToken(ctx, serial, token) + if err != nil { + http.Error(w, err.Error(), statusCode) + return + } + + c := clientutil.NewClient(s.Client, device.Namespace) + + // The CA the operator uses to connect to the device, should be trusted by the device for MTLS + if device.Spec.Endpoint.TLS == nil { + http.Error(w, "Device has no MTLS configuration", http.StatusNotFound) + return + } + + operatorCA, err := c.Secret(ctx, &device.Spec.Endpoint.TLS.CA) + if err != nil { + s.Logger.Error(err, "Failed to get CA certificate", "device", device.Name) + http.Error(w, "Failed to get CA certificate", http.StatusInternalServerError) + return + } + w.Header().Set("Content-Type", "application/x-pem-file") + w.WriteHeader(http.StatusOK) + w.Write(operatorCA) +} + +type DeviceCertificateResponse struct { + Certificate []byte `json:"certificate"` + PrivateKey []byte `json:"privateKey"` + CACertificate []byte `json:"caCertificate"` +} + +func (s *HTTPServer) GetDeviceCertificate(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + if r.Method != http.MethodGet { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + token, err := getBearerToken(r) + if err != nil { + http.Error(w, "Unauthorized: "+err.Error(), http.StatusUnauthorized) + return + } + + serial := r.URL.Query().Get("serial") + if serial == "" { + http.Error(w, "Serial parameter is required", http.StatusBadRequest) + return + } + + device, _, statusCode, err := s.findDeviceAndValidateToken(ctx, serial, token) + if err != nil { + http.Error(w, err.Error(), statusCode) + return + } + + c := clientutil.NewClient(s.Client, device.Namespace) + certList := v1alpha1.CertificateList{} + + if err = c.List(ctx, &certList, client.InNamespace(device.Namespace), client.MatchingLabels{v1alpha1.DeviceLabel: device.Name}); err != nil { + s.Logger.Error(err, "Failed to list certificates", "device", device.Name) + http.Error(w, "Failed to list certificates", http.StatusInternalServerError) + return + } + + if len(certList.Items) == 0 { + http.Error(w, "No certificate found for device", http.StatusNotFound) + return + } + + if len(certList.Items) > 1 { + s.Logger.Error(nil, "Multiple certificates found for device", "device", device.Name) + http.Error(w, "Multiple certificates found for device", http.StatusInternalServerError) + return + } + certSecret := corev1.Secret{} + certRef := client.ObjectKey{Name: certList.Items[0].Spec.SecretRef.Name, Namespace: device.Namespace} + err = c.Get(ctx, certRef, &certSecret) + if err != nil { + s.Logger.Error(err, "Failed to get certificate secret", "device", device.Name) + http.Error(w, "Failed to get certificate secret", http.StatusInternalServerError) + return + } + response := DeviceCertificateResponse{} + if certificate, ok := certSecret.Data["tls.crt"]; ok { + response.Certificate = certificate + } + if privateKey, ok := certSecret.Data["tls.key"]; ok { + response.PrivateKey = privateKey + } + if caCertificate, ok := certSecret.Data["ca.crt"]; ok { + response.CACertificate = caCertificate + } + + if len(response.Certificate) == 0 || len(response.PrivateKey) == 0 { + s.Logger.Error(nil, "Incomplete certificate data in secret", "device", device.Name) + http.Error(w, "Incomplete certificate data in secret", http.StatusInternalServerError) + return + } + + content, err := json.Marshal(response) + if err != nil { + s.Logger.Error(err, "Failed to marshal device certificate response", "device", device.Name) + http.Error(w, "Failed to marshal device certificate response", http.StatusInternalServerError) + return + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + w.Write(content) +} diff --git a/internal/provisioning/http_test.go b/internal/provisioning/http_test.go new file mode 100644 index 00000000..05821269 --- /dev/null +++ b/internal/provisioning/http_test.go @@ -0,0 +1,1105 @@ +// SPDX-FileCopyrightText: 2025 SAP SE or an SAP affiliate company and IronCore contributors +// SPDX-License-Identifier: Apache-2.0 + +package provisioning + +import ( + "bytes" + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" + + "github.com/ironcore-dev/network-operator/api/core/v1alpha1" + "github.com/ironcore-dev/network-operator/internal/deviceutil" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" + utilruntime "k8s.io/apimachinery/pkg/util/runtime" + "k8s.io/client-go/kubernetes/scheme" + "k8s.io/client-go/tools/record" + "k8s.io/klog/v2" + "sigs.k8s.io/controller-runtime/pkg/client/fake" +) + +var ( + testDevice = &v1alpha1.Device{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-device", + Namespace: "default", + Labels: map[string]string{ + v1alpha1.DeviceSerialLabel: "ABC123", + }, + }, + Spec: v1alpha1.DeviceSpec{ + Endpoint: v1alpha1.Endpoint{ + Address: "192.168.1.100:22", + SecretRef: &v1alpha1.SecretReference{ + Name: "test-device-connection", + }, + }, + Provisioning: &v1alpha1.Provisioning{ + Image: v1alpha1.Image{ + URL: "http://example.com/image.bin", + }, + }, + }, + Status: v1alpha1.DeviceStatus{ + SerialNumber: "ABC123", + Provisioning: []v1alpha1.ProvisioningInfo{ + { + Token: "validtoken", + StartTime: metav1.Now(), + }, + }, + }, + } + + testSecret = &corev1.Secret{ + Type: corev1.SecretTypeBasicAuth, + ObjectMeta: metav1.ObjectMeta{ + Name: "test-device-connection", + Namespace: "default", + }, + Data: map[string][]byte{ + "username": []byte("admin"), + "password": []byte("secret123"), + }, + } +) + +type MockProvider struct { + mock.Mock +} + +func (m *MockProvider) HashProvisioningPassword(password string) (string, string, error) { + return "hashedpass", "sha256", nil +} + +func (p *MockProvider) VerifyProvisioningCompleted(ctx context.Context, conn *deviceutil.Connection, device *v1alpha1.Device) (bool, error) { + return true, nil +} + +func TestGetClientIP(t *testing.T) { + tests := []struct { + name string + setupRequest func(*http.Request) + expectedIP string + expectedError bool + }{ + { + name: "extract IP from X-Forwarded-For header with single IP", + setupRequest: func(req *http.Request) { + req.Header.Set("X-Forwarded-For", "192.168.1.100") + }, + expectedIP: "192.168.1.100", + expectedError: false, + }, + { + name: "extract first IP from X-Forwarded-For header with multiple IPs", + setupRequest: func(req *http.Request) { + req.Header.Set("X-Forwarded-For", "192.168.1.100, 10.0.0.1, 172.16.0.1") + }, + expectedIP: "192.168.1.100", + expectedError: false, + }, + { + name: "extract IP from X-Real-IP header", + setupRequest: func(req *http.Request) { + req.Header.Set("X-Real-IP", "192.168.1.200") + }, + expectedIP: "192.168.1.200", + expectedError: false, + }, + { + name: "extract IP from RemoteAddr as fallback", + setupRequest: func(req *http.Request) { + req.RemoteAddr = "192.168.1.50:12345" + }, + expectedIP: "192.168.1.50", + expectedError: false, + }, + { + name: "prioritize X-Forwarded-For over X-Real-IP", + setupRequest: func(req *http.Request) { + req.Header.Set("X-Forwarded-For", "192.168.1.100") + req.Header.Set("X-Real-IP", "192.168.1.200") + }, + expectedIP: "192.168.1.100", + expectedError: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/test", nil) + tt.setupRequest(req) + + ip, err := getClientIP(req) + if tt.expectedError { + assert.Error(t, err) + } else { + assert.NoError(t, err) + assert.Equal(t, tt.expectedIP, ip) + } + }) + } +} + +func TestGetBearerToken(t *testing.T) { + tests := []struct { + name string + authorization string + expectedToken string + expectedError bool + errorContains string + }{ + { + name: "extract valid bearer token", + authorization: "Bearer abc123token", + expectedToken: "abc123token", + expectedError: false, + }, + { + name: "missing authorization header", + authorization: "", + expectedError: true, + errorContains: "Authorization header is missing", + }, + { + name: "invalid format without Bearer prefix", + authorization: "abc123token", + expectedError: true, + errorContains: "Invalid authorization header format", + }, + { + name: "wrong auth type", + authorization: "Basic abc123token", + expectedError: true, + errorContains: "Invalid authorization header format", + }, + { + name: "too many parts", + authorization: "Bearer abc123 extra", + expectedError: true, + errorContains: "Invalid authorization header format", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/test", nil) + if tt.authorization != "" { + req.Header.Set("Authorization", tt.authorization) + } + + token, err := getBearerToken(req) + if tt.expectedError { + assert.Error(t, err) + if tt.errorContains != "" { + assert.Contains(t, err.Error(), tt.errorContains) + } + } else { + assert.NoError(t, err) + assert.Equal(t, tt.expectedToken, token) + } + }) + } +} + +func TestHandleStatusReport(t *testing.T) { + tests := []struct { + name string + method string + authorization string + body interface{} + device *v1alpha1.Device + expectedStatus int + expectedBody string + validateDevice func(*testing.T, *v1alpha1.Device) + }{ + { + name: "reject non-PUT requests", + method: http.MethodGet, + expectedStatus: http.StatusMethodNotAllowed, + expectedBody: "Method not allowed", + }, + { + name: "reject requests without authorization header", + method: http.MethodPut, + body: StatusReport{Serial: "ABC123", Status: v1alpha1.ProvisioningScriptExecutionStarted}, + expectedStatus: http.StatusUnauthorized, + expectedBody: "Unauthorized", + }, + { + name: "reject requests with invalid JSON body", + method: http.MethodPut, + authorization: "Bearer validtoken", + body: "invalid json", + expectedStatus: http.StatusBadRequest, + expectedBody: "Invalid JSON body", + }, + { + name: "device not found", + method: http.MethodPut, + authorization: "Bearer validtoken", + body: StatusReport{Serial: "NONEXISTENT", Status: v1alpha1.ProvisioningScriptExecutionStarted}, + expectedStatus: http.StatusInternalServerError, + expectedBody: "Failed to find device", + }, + { + name: "no active provisioning found", + method: http.MethodPut, + authorization: "Bearer validtoken", + body: StatusReport{Serial: "ABC123", Status: v1alpha1.ProvisioningScriptExecutionStarted}, + device: &v1alpha1.Device{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-device", + Namespace: "default", + Labels: map[string]string{v1alpha1.DeviceSerialLabel: "ABC123"}, + }, + Status: v1alpha1.DeviceStatus{SerialNumber: "ABC123"}, + }, + expectedStatus: http.StatusNotFound, + expectedBody: "no active provisioning found", + }, + { + name: "reject invalid token", + method: http.MethodPut, + authorization: "Bearer wrongtoken", + body: StatusReport{Serial: "ABC123", Status: v1alpha1.ProvisioningDownloadingImage}, + device: &v1alpha1.Device{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-device", + Namespace: "default", + Labels: map[string]string{v1alpha1.DeviceSerialLabel: "ABC123"}, + }, + Status: v1alpha1.DeviceStatus{ + SerialNumber: "ABC123", + Provisioning: []v1alpha1.ProvisioningInfo{{Token: "correcttoken", StartTime: metav1.Now()}}, + }, + }, + expectedStatus: http.StatusUnauthorized, + expectedBody: "Unauthorized: Invalid token", + }, + { + name: "successfully update device status for successful provisioning", + method: http.MethodPut, + authorization: "Bearer validtoken", + body: StatusReport{Serial: "ABC123", Status: v1alpha1.ProvisioningRebootingDevice, Detail: "Device is rebooting"}, + device: &v1alpha1.Device{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-device", + Namespace: "default", + Labels: map[string]string{v1alpha1.DeviceSerialLabel: "ABC123"}, + }, + Status: v1alpha1.DeviceStatus{ + SerialNumber: "ABC123", + Provisioning: []v1alpha1.ProvisioningInfo{{Token: "validtoken", StartTime: metav1.Now()}}, + }, + }, + expectedStatus: http.StatusCreated, + expectedBody: "OK", + validateDevice: func(t *testing.T, device *v1alpha1.Device) { + assert.Equal(t, v1alpha1.DevicePhaseProvisioningCompleted, device.Status.Phase) + assert.False(t, device.Status.Provisioning[0].EndTime.IsZero()) + }, + }, + { + name: "successfully update device status for failed provisioning", + method: http.MethodPut, + authorization: "Bearer validtoken", + body: StatusReport{Serial: "ABC123", Status: v1alpha1.ProvisioningScriptExecutionFailed, Detail: "Script execution failed"}, + device: &v1alpha1.Device{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-device", + Namespace: "default", + Labels: map[string]string{v1alpha1.DeviceSerialLabel: "ABC123"}, + }, + Status: v1alpha1.DeviceStatus{ + SerialNumber: "ABC123", + Provisioning: []v1alpha1.ProvisioningInfo{{Token: "validtoken", StartTime: metav1.Now()}}, + }, + }, + expectedStatus: http.StatusCreated, + validateDevice: func(t *testing.T, device *v1alpha1.Device) { + assert.Equal(t, v1alpha1.DevicePhaseFailed, device.Status.Phase) + assert.False(t, device.Status.Provisioning[0].EndTime.IsZero()) + assert.Equal(t, "Script execution failed", device.Status.Provisioning[0].Error) + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var bodyReader *bytes.Buffer + if tt.body != nil { + if str, ok := tt.body.(string); ok { + bodyReader = bytes.NewBufferString(str) + } else { + bodyBytes, _ := json.Marshal(tt.body) + bodyReader = bytes.NewBuffer(bodyBytes) + } + } else { + bodyReader = bytes.NewBufferString("") + } + + req := httptest.NewRequest(tt.method, "/provisioning/status-report", bodyReader) + if tt.authorization != "" { + req.Header.Set("Authorization", tt.authorization) + } + rr := httptest.NewRecorder() + + clientBuilder := fake.NewClientBuilder().WithScheme(scheme.Scheme) + if tt.device != nil { + clientBuilder.WithObjects(tt.device).WithStatusSubresource(tt.device) + } + k8sClient := clientBuilder.Build() + + server := &HTTPServer{ + Client: k8sClient, + Logger: klog.NewKlogr(), + Recorder: record.NewFakeRecorder(10), + } + server.HandleStatusReport(rr, req) + + assert.Equal(t, tt.expectedStatus, rr.Code) + if tt.expectedBody != "" { + assert.Contains(t, rr.Body.String(), tt.expectedBody) + } + + if tt.validateDevice != nil && tt.device != nil { + var updatedDevice v1alpha1.Device + err := k8sClient.Get(context.Background(), types.NamespacedName{Name: tt.device.Name, Namespace: tt.device.Namespace}, &updatedDevice) + require.NoError(t, err) + tt.validateDevice(t, &updatedDevice) + } + }) + } +} + +func TestHandleProvisioningRequest(t *testing.T) { + tests := []struct { + name string + querySerial string + remoteAddr string + device *v1alpha1.Device + secret *corev1.Secret + validateSourceIP bool + mockProvider *MockProvider + expectedStatus int + expectedBody string + validateResponse func(*testing.T, *ProvisioningResponse) + }{ + { + name: "reject requests without serial parameter", + expectedStatus: http.StatusBadRequest, + expectedBody: "Serial parameter is required", + }, + { + name: "device not found", + querySerial: "NONEXISTENT", + expectedStatus: http.StatusInternalServerError, + expectedBody: "Failed to find device", + }, + { + name: "reject request when source IP validation fails", + querySerial: "ABC123", + remoteAddr: "192.168.1.100:12345", + device: &v1alpha1.Device{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-device", + Namespace: "default", + Labels: map[string]string{v1alpha1.DeviceSerialLabel: "ABC123"}, + }, + Spec: v1alpha1.DeviceSpec{ + Endpoint: v1alpha1.Endpoint{Address: "192.168.1.200:22"}, + }, + Status: v1alpha1.DeviceStatus{SerialNumber: "ABC123"}, + }, + validateSourceIP: true, + expectedStatus: http.StatusForbidden, + expectedBody: "Source IP does not match device IP", + }, + { + name: "return error when no active provisioning and the device is active", + querySerial: "ABC123", + remoteAddr: "192.168.1.100:12345", + device: &v1alpha1.Device{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-device", + Namespace: "default", + Labels: map[string]string{v1alpha1.DeviceSerialLabel: "ABC123"}, + }, + Spec: v1alpha1.DeviceSpec{ + Endpoint: v1alpha1.Endpoint{ + Address: "192.168.1.100:22", + SecretRef: &v1alpha1.SecretReference{ + Name: "test-secret", + }, + }, + }, + Status: v1alpha1.DeviceStatus{SerialNumber: "ABC123", Phase: v1alpha1.DevicePhaseActive}, + }, + secret: &corev1.Secret{ + Type: corev1.SecretTypeBasicAuth, + ObjectMeta: metav1.ObjectMeta{ + Name: "test-secret", + Namespace: "default", + }, + Data: map[string][]byte{ + "username": []byte("admin"), + "password": []byte("secret"), + }, + }, + mockProvider: new(MockProvider), + validateSourceIP: true, + expectedStatus: http.StatusPreconditionRequired, + expectedBody: "Failed to create provisioning entry", + }, + { + name: "successfully return provisioning configuration", + querySerial: "ABC123", + remoteAddr: "192.168.1.100:12345", + device: testDevice.DeepCopy(), + secret: testSecret.DeepCopy(), + validateSourceIP: true, + mockProvider: new(MockProvider), + expectedStatus: http.StatusOK, + validateResponse: func(t *testing.T, response *ProvisioningResponse) { + assert.Equal(t, "validtoken", response.ProvisioningToken) + assert.Equal(t, "http://example.com/image.bin", response.Image.URL) + assert.Equal(t, "test-device", response.Hostname) + assert.Len(t, response.UserAccounts, 1) + assert.Equal(t, "admin", response.UserAccounts[0].Username) + assert.Equal(t, "hashedpass", response.UserAccounts[0].HashedPassword) + assert.Equal(t, "sha256", response.UserAccounts[0].HashAlgorithm) + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + url := "/provisioning/config" + if tt.querySerial != "" { + url += "?serial=" + tt.querySerial + } + req := httptest.NewRequest(http.MethodGet, url, nil) + if tt.remoteAddr != "" { + req.RemoteAddr = tt.remoteAddr + } + rr := httptest.NewRecorder() + + clientBuilder := fake.NewClientBuilder().WithScheme(scheme.Scheme) + if tt.device != nil { + clientBuilder.WithObjects(tt.device) + } + if tt.secret != nil { + clientBuilder.WithObjects(tt.secret) + } + k8sClient := clientBuilder.Build() + + server := &HTTPServer{ + Client: k8sClient, + Logger: klog.NewKlogr(), + ValidateSourceIP: tt.validateSourceIP, + Provider: tt.mockProvider, + } + server.HandleProvisioningRequest(rr, req) + + assert.Equal(t, tt.expectedStatus, rr.Code) + if tt.expectedBody != "" { + assert.Contains(t, rr.Body.String(), tt.expectedBody) + } + + if tt.validateResponse != nil { + assert.Equal(t, "application/json", rr.Header().Get("Content-Type")) + var response ProvisioningResponse + err := json.Unmarshal(rr.Body.Bytes(), &response) + require.NoError(t, err) + tt.validateResponse(t, &response) + } + }) + } +} + +func TestGetDeviceCertificate(t *testing.T) { + tests := []struct { + name string + method string + querySerial string + authorization string + device *v1alpha1.Device + certificates []*v1alpha1.Certificate + certSecrets []*corev1.Secret + expectedStatus int + expectedBody string + validateResponse func(*testing.T, *DeviceCertificateResponse) + }{ + { + name: "reject non-GET requests", + method: http.MethodPost, + querySerial: "ABC123", + authorization: "Bearer validtoken", + expectedStatus: http.StatusMethodNotAllowed, + expectedBody: "Method not allowed", + }, + { + name: "reject requests without authorization header", + method: http.MethodGet, + querySerial: "ABC123", + expectedStatus: http.StatusUnauthorized, + expectedBody: "Unauthorized", + }, + { + name: "reject requests without serial parameter", + method: http.MethodGet, + authorization: "Bearer validtoken", + expectedStatus: http.StatusBadRequest, + expectedBody: "Serial parameter is required", + }, + { + name: "device not found", + method: http.MethodGet, + querySerial: "NONEXISTENT", + authorization: "Bearer validtoken", + expectedStatus: http.StatusInternalServerError, + expectedBody: "Failed to find device", + }, + { + name: "no active provisioning found", + method: http.MethodGet, + querySerial: "ABC123", + authorization: "Bearer validtoken", + device: &v1alpha1.Device{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-device", + Namespace: "default", + Labels: map[string]string{v1alpha1.DeviceSerialLabel: "ABC123"}, + }, + Status: v1alpha1.DeviceStatus{SerialNumber: "ABC123"}, + }, + expectedStatus: http.StatusNotFound, + expectedBody: "no active provisioning found", + }, + { + name: "invalid token", + method: http.MethodGet, + querySerial: "ABC123", + authorization: "Bearer wrongtoken", + device: &v1alpha1.Device{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-device", + Namespace: "default", + Labels: map[string]string{v1alpha1.DeviceSerialLabel: "ABC123"}, + }, + Status: v1alpha1.DeviceStatus{ + SerialNumber: "ABC123", + Provisioning: []v1alpha1.ProvisioningInfo{{Token: "validtoken", StartTime: metav1.Now()}}, + }, + }, + expectedStatus: http.StatusUnauthorized, + expectedBody: "Unauthorized: Invalid token", + }, + { + name: "no certificate found for device", + method: http.MethodGet, + querySerial: "ABC123", + authorization: "Bearer validtoken", + device: &v1alpha1.Device{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-device", + Namespace: "default", + Labels: map[string]string{v1alpha1.DeviceSerialLabel: "ABC123"}, + }, + Status: v1alpha1.DeviceStatus{ + SerialNumber: "ABC123", + Provisioning: []v1alpha1.ProvisioningInfo{{Token: "validtoken", StartTime: metav1.Now()}}, + }, + }, + expectedStatus: http.StatusNotFound, + expectedBody: "No certificate found for device", + }, + { + name: "multiple certificates found for device", + method: http.MethodGet, + querySerial: "ABC123", + authorization: "Bearer validtoken", + device: &v1alpha1.Device{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-device", + Namespace: "default", + Labels: map[string]string{v1alpha1.DeviceSerialLabel: "ABC123"}, + }, + Status: v1alpha1.DeviceStatus{ + SerialNumber: "ABC123", + Provisioning: []v1alpha1.ProvisioningInfo{{Token: "validtoken", StartTime: metav1.Now()}}, + }, + }, + certificates: []*v1alpha1.Certificate{ + { + ObjectMeta: metav1.ObjectMeta{ + Name: "test-device-cert-1", + Namespace: "default", + Labels: map[string]string{v1alpha1.DeviceLabel: "test-device"}, + }, + Spec: v1alpha1.CertificateSpec{ + SecretRef: v1alpha1.SecretReference{Name: "test-device-cert-secret-1"}, + }, + }, + { + ObjectMeta: metav1.ObjectMeta{ + Name: "test-device-cert-2", + Namespace: "default", + Labels: map[string]string{v1alpha1.DeviceLabel: "test-device"}, + }, + Spec: v1alpha1.CertificateSpec{ + SecretRef: v1alpha1.SecretReference{Name: "test-device-cert-secret-2"}, + }, + }, + }, + expectedStatus: http.StatusInternalServerError, + expectedBody: "Multiple certificates found for device", + }, + { + name: "certificate secret not found", + method: http.MethodGet, + querySerial: "ABC123", + authorization: "Bearer validtoken", + device: &v1alpha1.Device{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-device", + Namespace: "default", + Labels: map[string]string{v1alpha1.DeviceSerialLabel: "ABC123"}, + }, + Status: v1alpha1.DeviceStatus{ + SerialNumber: "ABC123", + Provisioning: []v1alpha1.ProvisioningInfo{{Token: "validtoken", StartTime: metav1.Now()}}, + }, + }, + certificates: []*v1alpha1.Certificate{ + { + ObjectMeta: metav1.ObjectMeta{ + Name: "test-device-cert", + Namespace: "default", + Labels: map[string]string{v1alpha1.DeviceLabel: "test-device"}, + }, + Spec: v1alpha1.CertificateSpec{ + SecretRef: v1alpha1.SecretReference{Name: "nonexistent-secret"}, + }, + }, + }, + expectedStatus: http.StatusInternalServerError, + expectedBody: "Failed to get certificate secret", + }, + { + name: "incomplete certificate data - missing private key", + method: http.MethodGet, + querySerial: "ABC123", + authorization: "Bearer validtoken", + device: &v1alpha1.Device{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-device", + Namespace: "default", + Labels: map[string]string{v1alpha1.DeviceSerialLabel: "ABC123"}, + }, + Status: v1alpha1.DeviceStatus{ + SerialNumber: "ABC123", + Provisioning: []v1alpha1.ProvisioningInfo{{Token: "validtoken", StartTime: metav1.Now()}}, + }, + }, + certificates: []*v1alpha1.Certificate{ + { + ObjectMeta: metav1.ObjectMeta{ + Name: "test-device-cert", + Namespace: "default", + Labels: map[string]string{v1alpha1.DeviceLabel: "test-device"}, + }, + Spec: v1alpha1.CertificateSpec{ + SecretRef: v1alpha1.SecretReference{Name: "test-device-cert-secret"}, + }, + }, + }, + certSecrets: []*corev1.Secret{ + { + ObjectMeta: metav1.ObjectMeta{ + Name: "test-device-cert-secret", + Namespace: "default", + }, + Data: map[string][]byte{ + "tls.crt": []byte("-----BEGIN CERTIFICATE-----\ntest-cert\n-----END CERTIFICATE-----"), + }, + }, + }, + expectedStatus: http.StatusInternalServerError, + expectedBody: "Incomplete certificate data in secret", + }, + { + name: "successfully return device certificate with all fields", + method: http.MethodGet, + querySerial: "ABC123", + authorization: "Bearer validtoken", + device: &v1alpha1.Device{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-device", + Namespace: "default", + Labels: map[string]string{v1alpha1.DeviceSerialLabel: "ABC123"}, + }, + Status: v1alpha1.DeviceStatus{ + SerialNumber: "ABC123", + Provisioning: []v1alpha1.ProvisioningInfo{{Token: "validtoken", StartTime: metav1.Now()}}, + }, + }, + certificates: []*v1alpha1.Certificate{ + { + ObjectMeta: metav1.ObjectMeta{ + Name: "test-device-cert", + Namespace: "default", + Labels: map[string]string{v1alpha1.DeviceLabel: "test-device"}, + }, + Spec: v1alpha1.CertificateSpec{ + SecretRef: v1alpha1.SecretReference{Name: "test-device-cert-secret"}, + }, + }, + }, + certSecrets: []*corev1.Secret{ + { + ObjectMeta: metav1.ObjectMeta{ + Name: "test-device-cert-secret", + Namespace: "default", + }, + Data: map[string][]byte{ + "tls.crt": []byte("-----BEGIN CERTIFICATE-----\ntest-cert\n-----END CERTIFICATE-----"), + "tls.key": []byte("-----BEGIN PRIVATE KEY-----\ntest-key\n-----END PRIVATE KEY-----"), + "ca.crt": []byte("-----BEGIN CERTIFICATE-----\ntest-ca\n-----END CERTIFICATE-----"), + }, + }, + }, + expectedStatus: http.StatusOK, + validateResponse: func(t *testing.T, response *DeviceCertificateResponse) { + assert.Equal(t, []byte("-----BEGIN CERTIFICATE-----\ntest-cert\n-----END CERTIFICATE-----"), response.Certificate) + assert.Equal(t, []byte("-----BEGIN PRIVATE KEY-----\ntest-key\n-----END PRIVATE KEY-----"), response.PrivateKey) + assert.Equal(t, []byte("-----BEGIN CERTIFICATE-----\ntest-ca\n-----END CERTIFICATE-----"), response.CACertificate) + }, + }, + { + name: "successfully return device certificate without CA certificate", + method: http.MethodGet, + querySerial: "ABC123", + authorization: "Bearer validtoken", + device: &v1alpha1.Device{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-device", + Namespace: "default", + Labels: map[string]string{v1alpha1.DeviceSerialLabel: "ABC123"}, + }, + Status: v1alpha1.DeviceStatus{ + SerialNumber: "ABC123", + Provisioning: []v1alpha1.ProvisioningInfo{{Token: "validtoken", StartTime: metav1.Now()}}, + }, + }, + certificates: []*v1alpha1.Certificate{ + { + ObjectMeta: metav1.ObjectMeta{ + Name: "test-device-cert", + Namespace: "default", + Labels: map[string]string{v1alpha1.DeviceLabel: "test-device"}, + }, + Spec: v1alpha1.CertificateSpec{ + SecretRef: v1alpha1.SecretReference{Name: "test-device-cert-secret"}, + }, + }, + }, + certSecrets: []*corev1.Secret{ + { + ObjectMeta: metav1.ObjectMeta{ + Name: "test-device-cert-secret", + Namespace: "default", + }, + Data: map[string][]byte{ + "tls.crt": []byte("-----BEGIN CERTIFICATE-----\ntest-cert\n-----END CERTIFICATE-----"), + "tls.key": []byte("-----BEGIN PRIVATE KEY-----\ntest-key\n-----END PRIVATE KEY-----"), + }, + }, + }, + expectedStatus: http.StatusOK, + validateResponse: func(t *testing.T, response *DeviceCertificateResponse) { + assert.Equal(t, []byte("-----BEGIN CERTIFICATE-----\ntest-cert\n-----END CERTIFICATE-----"), response.Certificate) + assert.Equal(t, []byte("-----BEGIN PRIVATE KEY-----\ntest-key\n-----END PRIVATE KEY-----"), response.PrivateKey) + assert.Empty(t, response.CACertificate) + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + url := "/provisioning/device-certificate" + if tt.querySerial != "" { + url += "?serial=" + tt.querySerial + } + req := httptest.NewRequest(tt.method, url, nil) + if tt.authorization != "" { + req.Header.Set("Authorization", tt.authorization) + } + rr := httptest.NewRecorder() + + clientBuilder := fake.NewClientBuilder().WithScheme(scheme.Scheme) + if tt.device != nil { + clientBuilder.WithObjects(tt.device).WithStatusSubresource(tt.device) + } + for _, cert := range tt.certificates { + clientBuilder.WithObjects(cert) + } + for _, secret := range tt.certSecrets { + clientBuilder.WithObjects(secret) + } + k8sClient := clientBuilder.Build() + + server := &HTTPServer{ + Client: k8sClient, + Logger: klog.NewKlogr(), + } + server.GetDeviceCertificate(rr, req) + + assert.Equal(t, tt.expectedStatus, rr.Code) + if tt.expectedBody != "" { + assert.Contains(t, rr.Body.String(), tt.expectedBody) + } + + if tt.validateResponse != nil { + assert.Equal(t, "application/json", rr.Header().Get("Content-Type")) + var response DeviceCertificateResponse + err := json.Unmarshal(rr.Body.Bytes(), &response) + require.NoError(t, err) + tt.validateResponse(t, &response) + } + }) + } +} + +func TestGetMTLSClientCA(t *testing.T) { + tests := []struct { + name string + method string + querySerial string + authorization string + device *v1alpha1.Device + caSecret *corev1.Secret + expectedStatus int + expectedBody string + validateCA func(*testing.T, []byte) + }{ + { + name: "reject non-GET requests", + method: http.MethodPost, + querySerial: "ABC123", + authorization: "Bearer validtoken", + expectedStatus: http.StatusMethodNotAllowed, + expectedBody: "Method not allowed", + }, + { + name: "reject requests without authorization header", + method: http.MethodGet, + querySerial: "ABC123", + expectedStatus: http.StatusUnauthorized, + expectedBody: "Unauthorized", + }, + { + name: "reject requests without serial parameter", + method: http.MethodGet, + authorization: "Bearer validtoken", + expectedStatus: http.StatusBadRequest, + expectedBody: "Serial parameter is required", + }, + { + name: "device not found", + method: http.MethodGet, + querySerial: "NONEXISTENT", + authorization: "Bearer validtoken", + expectedStatus: http.StatusInternalServerError, + expectedBody: "Failed to find device", + }, + { + name: "no active provisioning found", + method: http.MethodGet, + querySerial: "ABC123", + authorization: "Bearer validtoken", + device: &v1alpha1.Device{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-device", + Namespace: "default", + Labels: map[string]string{v1alpha1.DeviceSerialLabel: "ABC123"}, + }, + Status: v1alpha1.DeviceStatus{SerialNumber: "ABC123"}, + }, + expectedStatus: http.StatusNotFound, + expectedBody: "no active provisioning found", + }, + { + name: "invalid token", + method: http.MethodGet, + querySerial: "ABC123", + authorization: "Bearer wrongtoken", + device: &v1alpha1.Device{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-device", + Namespace: "default", + Labels: map[string]string{v1alpha1.DeviceSerialLabel: "ABC123"}, + }, + Status: v1alpha1.DeviceStatus{ + SerialNumber: "ABC123", + Provisioning: []v1alpha1.ProvisioningInfo{{Token: "validtoken", StartTime: metav1.Now()}}, + }, + }, + expectedStatus: http.StatusUnauthorized, + expectedBody: "Unauthorized: Invalid token", + }, + { + name: "device has no MTLS configuration", + method: http.MethodGet, + querySerial: "ABC123", + authorization: "Bearer validtoken", + device: &v1alpha1.Device{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-device", + Namespace: "default", + Labels: map[string]string{v1alpha1.DeviceSerialLabel: "ABC123"}, + }, + Spec: v1alpha1.DeviceSpec{ + Endpoint: v1alpha1.Endpoint{ + Address: "192.168.1.100:22", + }, + }, + Status: v1alpha1.DeviceStatus{ + SerialNumber: "ABC123", + Provisioning: []v1alpha1.ProvisioningInfo{{Token: "validtoken", StartTime: metav1.Now()}}, + }, + }, + expectedStatus: http.StatusNotFound, + expectedBody: "Device has no MTLS configuration", + }, + { + name: "CA secret not found", + method: http.MethodGet, + querySerial: "ABC123", + authorization: "Bearer validtoken", + device: &v1alpha1.Device{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-device", + Namespace: "default", + Labels: map[string]string{v1alpha1.DeviceSerialLabel: "ABC123"}, + }, + Spec: v1alpha1.DeviceSpec{ + Endpoint: v1alpha1.Endpoint{ + Address: "192.168.1.100:22", + TLS: &v1alpha1.TLS{ + CA: v1alpha1.SecretKeySelector{ + SecretReference: v1alpha1.SecretReference{ + Name: "nonexistent-ca-secret", + }, + Key: "ca.crt", + }, + }, + }, + }, + Status: v1alpha1.DeviceStatus{ + SerialNumber: "ABC123", + Provisioning: []v1alpha1.ProvisioningInfo{{Token: "validtoken", StartTime: metav1.Now()}}, + }, + }, + expectedStatus: http.StatusInternalServerError, + expectedBody: "Failed to get CA certificate", + }, + { + name: "successfully return MTLS client CA certificate", + method: http.MethodGet, + querySerial: "ABC123", + authorization: "Bearer validtoken", + device: &v1alpha1.Device{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-device", + Namespace: "default", + Labels: map[string]string{v1alpha1.DeviceSerialLabel: "ABC123"}, + }, + Spec: v1alpha1.DeviceSpec{ + Endpoint: v1alpha1.Endpoint{ + Address: "192.168.1.100:22", + TLS: &v1alpha1.TLS{ + CA: v1alpha1.SecretKeySelector{ + SecretReference: v1alpha1.SecretReference{ + Name: "operator-ca-secret", + }, + Key: "ca.crt", + }, + }, + }, + }, + Status: v1alpha1.DeviceStatus{ + SerialNumber: "ABC123", + Provisioning: []v1alpha1.ProvisioningInfo{{Token: "validtoken", StartTime: metav1.Now()}}, + }, + }, + caSecret: &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: "operator-ca-secret", + Namespace: "default", + }, + Data: map[string][]byte{ + "ca.crt": []byte("-----BEGIN CERTIFICATE-----\noperator-ca-cert\n-----END CERTIFICATE-----"), + }, + }, + expectedStatus: http.StatusOK, + validateCA: func(t *testing.T, ca []byte) { + assert.Equal(t, []byte("-----BEGIN CERTIFICATE-----\noperator-ca-cert\n-----END CERTIFICATE-----"), ca) + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + url := "/provisioning/mtls-client-ca" + if tt.querySerial != "" { + url += "?serial=" + tt.querySerial + } + req := httptest.NewRequest(tt.method, url, nil) + if tt.authorization != "" { + req.Header.Set("Authorization", tt.authorization) + } + rr := httptest.NewRecorder() + + clientBuilder := fake.NewClientBuilder().WithScheme(scheme.Scheme) + if tt.device != nil { + clientBuilder.WithObjects(tt.device).WithStatusSubresource(tt.device) + } + if tt.caSecret != nil { + clientBuilder.WithObjects(tt.caSecret) + } + k8sClient := clientBuilder.Build() + + server := &HTTPServer{ + Client: k8sClient, + Logger: klog.NewKlogr(), + } + server.GetMTLSClientCA(rr, req) + + assert.Equal(t, tt.expectedStatus, rr.Code) + if tt.expectedBody != "" { + assert.Contains(t, rr.Body.String(), tt.expectedBody) + } + + if tt.validateCA != nil { + assert.Equal(t, "application/x-pem-file", rr.Header().Get("Content-Type")) + tt.validateCA(t, rr.Body.Bytes()) + } + }) + } +} + +func init() { + utilruntime.Must(v1alpha1.AddToScheme(scheme.Scheme)) +}