diff --git a/api/machina/v1alpha3/machine_types.go b/api/machina/v1alpha3/machine_types.go index 37ac2a77..6af9185c 100644 --- a/api/machina/v1alpha3/machine_types.go +++ b/api/machina/v1alpha3/machine_types.go @@ -300,6 +300,18 @@ type PXESpec struct { // +optional BootProtocol string `json:"bootProtocol,omitempty"` + // InsecureDisableSecureBoot disables UEFI Secure Boot when using HTTP + // boot. By default, metalman enables Secure Boot for HTTP boot repaves. + // +optional + InsecureDisableSecureBoot bool `json:"insecureDisableSecureBoot,omitempty"` + + // TrustedSecureBootKeys configures public certificates that metalman + // enrolls into UEFI Secure Boot databases through Redfish. The referenced + // data must contain a PEM-encoded X.509 certificate. Metalman only enrolls + // missing certificates and does not remove keys already present on the BMC. + // +optional + TrustedSecureBootKeys []TrustedSecureBootKeyRef `json:"trustedSecureBootKeys,omitempty"` + // DHCPLeases defines static DHCP leases for PXE booting. // +optional DHCPLeases []DHCPLease `json:"dhcpLeases,omitempty"` @@ -327,8 +339,66 @@ const ( DefaultPXEArchitecture = PXEArchitectureAMD64 // DefaultPXEBootProtocol is used when spec.pxe.bootProtocol is omitted. DefaultPXEBootProtocol = PXEBootProtocolPXE + // SecureBootDatabasePK is the UEFI Platform Key database. + SecureBootDatabasePK = "PK" + // SecureBootDatabaseKEK is the UEFI Key Exchange Key database. + SecureBootDatabaseKEK = "KEK" + // SecureBootDatabaseDB is the UEFI allowed signatures database. + SecureBootDatabaseDB = "db" + // SecureBootDatabaseDBX is the UEFI forbidden signatures database. + SecureBootDatabaseDBX = "dbx" + // DefaultSecureBootDatabase is used when a trusted Secure Boot key does + // not specify a database. + DefaultSecureBootDatabase = SecureBootDatabaseDB ) +// TrustedSecureBootKeyRef references a public Secure Boot certificate to enroll. +type TrustedSecureBootKeyRef struct { + // Database is the UEFI Secure Boot database to enroll into. Defaults to db, + // the allowed signatures database used to trust bootloader signing keys. + // +kubebuilder:validation:Enum=PK;KEK;db;dbx + // +kubebuilder:default=db + // +optional + Database string `json:"database,omitempty"` + + // SecretKeyRef references a Secret key containing a PEM-encoded X.509 + // certificate. + // +optional + SecretKeyRef *SecureBootObjectKeySelector `json:"secretKeyRef,omitempty"` + + // ConfigMapKeyRef references a ConfigMap key containing a PEM-encoded X.509 + // certificate. + // +optional + ConfigMapKeyRef *SecureBootObjectKeySelector `json:"configMapKeyRef,omitempty"` +} + +// TargetDatabase returns the effective UEFI Secure Boot database. +func (r *TrustedSecureBootKeyRef) TargetDatabase() string { + if r == nil || r.Database == "" { + return DefaultSecureBootDatabase + } + + return r.Database +} + +// SecureBootObjectKeySelector selects a key from a Secret or ConfigMap. +type SecureBootObjectKeySelector struct { + // Name of the object. + // +kubebuilder:validation:Required + // +kubebuilder:validation:MinLength=1 + Name string `json:"name"` + + // Namespace of the object. + // +kubebuilder:validation:Required + // +kubebuilder:validation:MinLength=1 + Namespace string `json:"namespace"` + + // Key within the object. + // +kubebuilder:validation:Required + // +kubebuilder:validation:MinLength=1 + Key string `json:"key"` +} + // TargetArchitecture returns the effective PXE target architecture. func (p *PXESpec) TargetArchitecture() string { if p == nil || p.Architecture == "" { diff --git a/api/machina/v1alpha3/zz_generated.deepcopy.go b/api/machina/v1alpha3/zz_generated.deepcopy.go index 5e14036a..b4c811d5 100644 --- a/api/machina/v1alpha3/zz_generated.deepcopy.go +++ b/api/machina/v1alpha3/zz_generated.deepcopy.go @@ -1017,6 +1017,13 @@ func (in *OperationsStatus) DeepCopy() *OperationsStatus { // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *PXESpec) DeepCopyInto(out *PXESpec) { *out = *in + if in.TrustedSecureBootKeys != nil { + in, out := &in.TrustedSecureBootKeys, &out.TrustedSecureBootKeys + *out = make([]TrustedSecureBootKeyRef, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } if in.DHCPLeases != nil { in, out := &in.DHCPLeases, &out.DHCPLeases *out = make([]DHCPLease, len(*in)) @@ -1128,6 +1135,21 @@ func (in *SecretKeySelector) DeepCopy() *SecretKeySelector { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *SecureBootObjectKeySelector) DeepCopyInto(out *SecureBootObjectKeySelector) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new SecureBootObjectKeySelector. +func (in *SecureBootObjectKeySelector) DeepCopy() *SecureBootObjectKeySelector { + if in == nil { + return nil + } + out := new(SecureBootObjectKeySelector) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *TPMStatus) DeepCopyInto(out *TPMStatus) { *out = *in @@ -1142,3 +1164,28 @@ func (in *TPMStatus) DeepCopy() *TPMStatus { in.DeepCopyInto(out) return out } + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *TrustedSecureBootKeyRef) DeepCopyInto(out *TrustedSecureBootKeyRef) { + *out = *in + if in.SecretKeyRef != nil { + in, out := &in.SecretKeyRef, &out.SecretKeyRef + *out = new(SecureBootObjectKeySelector) + **out = **in + } + if in.ConfigMapKeyRef != nil { + in, out := &in.ConfigMapKeyRef, &out.ConfigMapKeyRef + *out = new(SecureBootObjectKeySelector) + **out = **in + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new TrustedSecureBootKeyRef. +func (in *TrustedSecureBootKeyRef) DeepCopy() *TrustedSecureBootKeyRef { + if in == nil { + return nil + } + out := new(TrustedSecureBootKeyRef) + in.DeepCopyInto(out) + return out +} diff --git a/cmd/metalman/README.md b/cmd/metalman/README.md index 8da02db8..bb5f3db0 100644 --- a/cmd/metalman/README.md +++ b/cmd/metalman/README.md @@ -189,6 +189,9 @@ spec: image: ghcr.io/azure/host-ubuntu2404:v1 # Defaults to PXE. Set to HTTP to use Redfish UEFI HTTP boot. bootProtocol: PXE + # HTTP boot enables UEFI Secure Boot by default. Set this only when + # the target image or firmware cannot boot with Secure Boot enabled. + insecureDisableSecureBoot: false dhcpLeases: - mac: "aa:bb:cc:dd:ee:01" ipv4: "10.0.0.11" @@ -201,6 +204,13 @@ boot artifacts from the default netboot image. Set `spec.pxe.netbootImage` only when a Machine needs a non-default PXE boot environment. The node must be manually PXE-booted (or have PXE as its default boot option). +When `spec.pxe.bootProtocol` is `HTTP`, Metalman uses Redfish UEFI HTTP boot and +reconciles the BMC SecureBoot resource before the repave. Secure Boot is enabled +by default. Set `spec.pxe.insecureDisableSecureBoot: true` to actively reconcile +`SecureBootEnable` to `false`. The default netboot image serves signed shim +artifacts via `metadata.yaml:httpBootPath`; custom netboot images should set +`httpBootPath` to their signed HTTP bootloader path. + #### BMC Adding a `redfish` block enables remote power management. The controller will diff --git a/deploy/machina/crd/unbounded-cloud.io_machines.yaml b/deploy/machina/crd/unbounded-cloud.io_machines.yaml index 262ea798..1c12710e 100644 --- a/deploy/machina/crd/unbounded-cloud.io_machines.yaml +++ b/deploy/machina/crd/unbounded-cloud.io_machines.yaml @@ -400,6 +400,11 @@ spec: The image must contain /disk/disk.img.gz. Example: "ghcr.io/azure/host-ubuntu2404:v1" type: string + insecureDisableSecureBoot: + description: |- + InsecureDisableSecureBoot disables UEFI Secure Boot when using HTTP + boot. By default, metalman enables Secure Boot for HTTP boot repaves. + type: boolean netbootImage: description: |- NetbootImage is an OCI image reference containing the PXE boot @@ -443,6 +448,73 @@ spec: - url - username type: object + trustedSecureBootKeys: + description: |- + TrustedSecureBootKeys configures public certificates that metalman + enrolls into UEFI Secure Boot databases through Redfish. The referenced + data must contain a PEM-encoded X.509 certificate. Metalman only enrolls + missing certificates and does not remove keys already present on the BMC. + items: + description: TrustedSecureBootKeyRef references a public Secure + Boot certificate to enroll. + properties: + configMapKeyRef: + description: |- + ConfigMapKeyRef references a ConfigMap key containing a PEM-encoded X.509 + certificate. + properties: + key: + description: Key within the object. + minLength: 1 + type: string + name: + description: Name of the object. + minLength: 1 + type: string + namespace: + description: Namespace of the object. + minLength: 1 + type: string + required: + - key + - name + - namespace + type: object + database: + default: db + description: |- + Database is the UEFI Secure Boot database to enroll into. Defaults to db, + the allowed signatures database used to trust bootloader signing keys. + enum: + - PK + - KEK + - db + - dbx + type: string + secretKeyRef: + description: |- + SecretKeyRef references a Secret key containing a PEM-encoded X.509 + certificate. + properties: + key: + description: Key within the object. + minLength: 1 + type: string + name: + description: Name of the object. + minLength: 1 + type: string + namespace: + description: Namespace of the object. + minLength: 1 + type: string + required: + - key + - name + - namespace + type: object + type: object + type: array required: - image type: object diff --git a/docs/content/guides/pxe.md b/docs/content/guides/pxe.md index 25ef2a7d..9323cfd0 100644 --- a/docs/content/guides/pxe.md +++ b/docs/content/guides/pxe.md @@ -118,6 +118,53 @@ spec: Store BMC passwords in a Secret referenced by `passwordRef`. See the [CRD Reference]({{< relref "/reference/machina-crd" >}}) for all fields. +## Secure Boot Trusted Keys + +When a Machine has `spec.pxe.redfish`, metalman can enroll trusted UEFI Secure Boot certificates through Redfish before enabling Secure Boot or changing boot order. Each trusted key must be a PEM-encoded X.509 certificate stored in a Secret or ConfigMap. By default certificates are enrolled into `db`, the UEFI allowed signatures database used to trust bootloader signing keys. + +```yaml +apiVersion: v1 +kind: Secret +metadata: + name: secure-boot-certs + namespace: unbounded-kube +type: Opaque +stringData: + db.pem: | + -----BEGIN CERTIFICATE----- + ... + -----END CERTIFICATE----- +``` + +Reference the certificate from the Machine: + +```yaml +apiVersion: unbounded-cloud.io/v1alpha3 +kind: Machine +metadata: + name: server-01 +spec: + pxe: + image: ghcr.io/azure/host-ubuntu2404:v1 + redfish: + url: "https://bmc-01.example.com" + username: admin + passwordRef: + name: bmc-passwords + namespace: unbounded-kube + key: bmc-01 + trustedSecureBootKeys: + - database: db + secretKeyRef: + name: secure-boot-certs + namespace: unbounded-kube + key: db.pem +``` + +`database` can be `PK`, `KEK`, `db`, or `dbx`; omit it to use `db`. ConfigMap-backed certificates use `configMapKeyRef` with the same `name`, `namespace`, and `key` fields. + +Metalman compares the certificate's SHA-256 DER fingerprint with certificates already reported by the BMC and only POSTs missing certificates. It does not delete keys that are present on the BMC but absent from the Machine spec. If a Machine configures trusted keys and the BMC does not support Redfish certificate enrollment, reconciliation fails closed and sets `SecureBootKeyEnrollmentSupported=False`. + ## Cloud-Init Customization Cloud-init on PXE-booted machines uses two data sources that are merged at boot: diff --git a/docs/content/reference/machina-crd.md b/docs/content/reference/machina-crd.md index f6e909c4..5c925ef7 100644 --- a/docs/content/reference/machina-crd.md +++ b/docs/content/reference/machina-crd.md @@ -56,6 +56,17 @@ PXE boot configuration consumed by the metalman controller. | `pxe.architecture` | string | No | `amd64` | Target CPU architecture for PXE boot artifacts and machine images. Allowed values: `amd64`, `arm64`. | | `pxe.netbootImage` | string | No | Metalman default | OCI netboot image reference containing PXE boot artifacts. | | `pxe.bootProtocol` | string | No | `PXE` | Network boot trigger protocol for repaves. `PXE` uses DHCP/TFTP bootfile options. `HTTP` uses Redfish UEFI HTTP boot with a URL derived from the netboot image metadata. Allowed values: `PXE`, `HTTP`. | +| `pxe.insecureDisableSecureBoot` | bool | No | `false` | Disables UEFI Secure Boot configuration through Redfish. By default, metalman enables Secure Boot for HTTP boot repaves. | +| `pxe.trustedSecureBootKeys` | []TrustedSecureBootKeyRef | No | - | PEM-encoded X.509 certificates to enroll into UEFI Secure Boot databases through Redfish. Metalman enrolls missing certificates and does not remove existing keys. | +| `pxe.trustedSecureBootKeys[].database` | string | No | `db` | UEFI Secure Boot database to enroll into. Allowed values: `PK`, `KEK`, `db`, `dbx`. | +| `pxe.trustedSecureBootKeys[].secretKeyRef` | SecureBootObjectKeySelector | No | - | Secret key containing a PEM-encoded X.509 certificate. Exactly one of `secretKeyRef` or `configMapKeyRef` should be set. | +| `pxe.trustedSecureBootKeys[].configMapKeyRef` | SecureBootObjectKeySelector | No | - | ConfigMap key containing a PEM-encoded X.509 certificate. Exactly one of `secretKeyRef` or `configMapKeyRef` should be set. | +| `pxe.trustedSecureBootKeys[].secretKeyRef.name` | string | Yes | - | Secret name. | +| `pxe.trustedSecureBootKeys[].secretKeyRef.namespace` | string | Yes | - | Secret namespace. | +| `pxe.trustedSecureBootKeys[].secretKeyRef.key` | string | Yes | - | Secret data key. | +| `pxe.trustedSecureBootKeys[].configMapKeyRef.name` | string | Yes | - | ConfigMap name. | +| `pxe.trustedSecureBootKeys[].configMapKeyRef.namespace` | string | Yes | - | ConfigMap namespace. | +| `pxe.trustedSecureBootKeys[].configMapKeyRef.key` | string | Yes | - | ConfigMap data or binaryData key. | | `pxe.dhcpLeases` | []DHCPLease | No | - | Static DHCP leases served during PXE boot. | | `pxe.dhcpLeases[].ipv4` | string | Yes | - | Static IPv4 address to assign. | | `pxe.dhcpLeases[].mac` | string | Yes | - | NIC MAC address (matched case-insensitively). | diff --git a/internal/metalman/redfish/client.go b/internal/metalman/redfish/client.go index 091a65de..c5367503 100644 --- a/internal/metalman/redfish/client.go +++ b/internal/metalman/redfish/client.go @@ -69,6 +69,11 @@ type BootConfig struct { UefiHTTPSource string } +// SecureBootConfig holds the current UEFI Secure Boot configuration. +type SecureBootConfig struct { + Enabled bool +} + // Client provides Redfish operations against a single BMC. // Created via Pool.Get or Dial. Must be closed when no longer needed. type Client struct { @@ -241,6 +246,56 @@ func (c *Client) SetHTTPBootOverride(ctx context.Context, bootURL string) error return nil } +// GetSecureBootConfig returns the current UEFI Secure Boot configuration. +// Returns ErrUnsupported if the BMC does not expose the SecureBoot resource. +func (c *Client) GetSecureBootConfig(ctx context.Context) (SecureBootConfig, error) { + path := fmt.Sprintf("/redfish/v1/Systems/%s/SecureBoot", c.deviceID) + + data, status, err := c.session.do(ctx, http.MethodGet, path, nil) + if err != nil { + return SecureBootConfig{}, err + } + + if isUnsupportedStatus(status) { + return SecureBootConfig{}, fmt.Errorf("SecureBoot GET returned %d: %w", status, ErrUnsupported) + } + + if status != http.StatusOK { + return SecureBootConfig{}, fmt.Errorf("unexpected status %d from %s: %s", status, path, data) + } + + var result struct { + SecureBootEnable bool `json:"SecureBootEnable"` + } + if err := json.Unmarshal(data, &result); err != nil { + return SecureBootConfig{}, fmt.Errorf("parsing SecureBoot config: %w", err) + } + + return SecureBootConfig{Enabled: result.SecureBootEnable}, nil +} + +// SetSecureBootEnabled sets UEFI Secure Boot to the requested state. +// Returns ErrUnsupported if the BMC does not support the PATCH. +func (c *Client) SetSecureBootEnabled(ctx context.Context, enabled bool) error { + path := fmt.Sprintf("/redfish/v1/Systems/%s/SecureBoot", c.deviceID) + body := map[string]bool{"SecureBootEnable": enabled} + + _, status, err := c.session.do(ctx, http.MethodPatch, path, body) + if err != nil { + return err + } + + if isUnsupportedStatus(status) { + return fmt.Errorf("SecureBoot PATCH returned %d: %w", status, ErrUnsupported) + } + + if !isSuccessStatus(status) { + return fmt.Errorf("unexpected status %d from SecureBoot PATCH", status) + } + + return nil +} + // DisableBootOverride disables the boot source override. If the BMC does // not support disabling, it falls back to setting Hdd/Continuous. // Returns ErrUnsupported if neither approach works. @@ -370,7 +425,7 @@ func resolveDeviceID(ctx context.Context, s *bmcSession, deviceID string) (strin // isSuccessStatus returns true for HTTP status codes indicating success. func isSuccessStatus(code int) bool { - return code == http.StatusOK || code == http.StatusNoContent || code == http.StatusAccepted + return code == http.StatusOK || code == http.StatusCreated || code == http.StatusNoContent || code == http.StatusAccepted } // isUnsupportedStatus returns true for HTTP status codes that indicate the BMC diff --git a/internal/metalman/redfish/reconciler.go b/internal/metalman/redfish/reconciler.go index 423130e9..6ea999ce 100644 --- a/internal/metalman/redfish/reconciler.go +++ b/internal/metalman/redfish/reconciler.go @@ -26,9 +26,11 @@ import ( const ( // Condition types set by this controller. - condPoweredOff = "PoweredOff" - condBootSupported = "BootOrderConfigSupported" - condRepaved = "Repaved" + condPoweredOff = "PoweredOff" + condBootSupported = "BootOrderConfigSupported" + condSecureBootSupported = "SecureBootConfigSupported" + condSecureBootKeysSupported = "SecureBootKeyEnrollmentSupported" + condRepaved = "Repaved" // Condition reasons. reasonPoweringOff = "PoweringOff" @@ -122,7 +124,12 @@ func (r *Reconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Resu return ctrl.Result{}, fmt.Errorf("getting Redfish password secret: %w", err) } - password := string(secret.Data[rf.PasswordRef.Key]) + passwordBytes, ok := secret.Data[rf.PasswordRef.Key] + if !ok { + return ctrl.Result{}, fmt.Errorf("key %q not found in Redfish password secret %s/%s", rf.PasswordRef.Key, rf.PasswordRef.Namespace, rf.PasswordRef.Name) + } + + password := string(passwordBytes) // Acquire Redfish client. c, err := r.Pool.Get(ctx, rf.URL, fingerprint, rf.Username, password, rf.DeviceID) @@ -130,6 +137,79 @@ func (r *Reconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Resu return ctrl.Result{}, fmt.Errorf("getting Redfish client: %w", err) } + desiredSecureBoot := !machine.Spec.PXE.InsecureDisableSecureBoot + secureBootCond := meta.FindStatusCondition(machine.Status.Conditions, condSecureBootSupported) + secureBootKeysCond := meta.FindStatusCondition(machine.Status.Conditions, condSecureBootKeysSupported) + secureBootStatusChanged := false + secureBootKeysStatusChanged := false + + if len(machine.Spec.PXE.TrustedSecureBootKeys) > 0 { + if secureBootKeysCond != nil && secureBootKeysCond.Status == metav1.ConditionFalse { + return ctrl.Result{}, fmt.Errorf("secure boot key enrollment is not supported but trusted keys are configured: %w", ErrUnsupported) + } + + if err := r.reconcileSecureBootKeys(ctx, log, &machine, c); err != nil { + if errors.Is(err, ErrUnsupported) { + log.Info("Secure Boot key enrollment not supported", "err", err) + meta.SetStatusCondition(&machine.Status.Conditions, metav1.Condition{ + Type: condSecureBootKeysSupported, + Status: metav1.ConditionFalse, + Reason: reasonNotSupported, + ObservedGeneration: machine.Generation, + }) + + if err := r.Client.Status().Update(ctx, &machine); err != nil { + return ctrl.Result{}, fmt.Errorf("updating Secure Boot key enrollment support status: %w", err) + } + + return ctrl.Result{}, fmt.Errorf("secure boot key enrollment is not supported but trusted keys are configured: %w", err) + } + + return ctrl.Result{}, fmt.Errorf("reconciling Secure Boot trusted keys: %w", err) + } + + if secureBootKeysCond == nil || secureBootKeysCond.Status != metav1.ConditionTrue || secureBootKeysCond.ObservedGeneration != machine.Generation { + meta.SetStatusCondition(&machine.Status.Conditions, metav1.Condition{ + Type: condSecureBootKeysSupported, + Status: metav1.ConditionTrue, + Reason: "Reconciled", + ObservedGeneration: machine.Generation, + }) + + secureBootKeysStatusChanged = true + } + } + + if secureBootCond != nil && secureBootCond.Status == metav1.ConditionFalse && desiredSecureBoot { + return ctrl.Result{}, fmt.Errorf("secure boot config is not supported but secure boot is enabled: %w", ErrUnsupported) + } + + if secureBootCond == nil || secureBootCond.Status != metav1.ConditionFalse { + if err := reconcileSecureBoot(ctx, log, c, desiredSecureBoot); err != nil { + if errors.Is(err, ErrUnsupported) { + log.Info("Secure Boot config not supported", "err", err) + meta.SetStatusCondition(&machine.Status.Conditions, metav1.Condition{ + Type: condSecureBootSupported, + Status: metav1.ConditionFalse, + Reason: reasonNotSupported, + ObservedGeneration: machine.Generation, + }) + + secureBootStatusChanged = true + + if desiredSecureBoot { + if err := r.Client.Status().Update(ctx, &machine); err != nil { + return ctrl.Result{}, fmt.Errorf("updating Secure Boot support status: %w", err) + } + + return ctrl.Result{}, fmt.Errorf("secure boot config is not supported but secure boot is enabled: %w", err) + } + } else { + return ctrl.Result{}, fmt.Errorf("configuring Secure Boot: %w", err) + } + } + } + // Boot order configuration (skip if known unsupported). pendingRepave := machine.Spec.Operations.RepaveCounter > machine.Status.Operations.RepaveCounter @@ -171,6 +251,10 @@ func (r *Reconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Resu // No reboot pending - done. if machine.Spec.Operations.RebootCounter <= machine.Status.Operations.RebootCounter { + if secureBootStatusChanged || secureBootKeysStatusChanged { + return ctrl.Result{}, r.Client.Status().Update(ctx, &machine) + } + return ctrl.Result{}, nil } @@ -182,6 +266,87 @@ func (r *Reconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Resu return r.reconcilePowerOn(ctx, log, &machine, c, pendingRepave) } +func (r *Reconciler) reconcileSecureBootKeys(ctx context.Context, log *slog.Logger, machine *v1alpha3.Machine, c *Client) error { + for i := range machine.Spec.PXE.TrustedSecureBootKeys { + keyRef := &machine.Spec.PXE.TrustedSecureBootKeys[i] + database := keyRef.TargetDatabase() + + certPEM, err := r.resolveSecureBootCertificate(ctx, keyRef) + if err != nil { + return err + } + + cert, err := ParseSecureBootCertificate(certPEM) + if err != nil { + return err + } + + existing, err := c.ListSecureBootCertificates(ctx, database) + if err != nil { + return err + } + + if secureBootCertificateInstalled(existing, cert) { + continue + } + + log.Info("enrolling Secure Boot trusted certificate", "database", database, "fingerprint", cert.SHA256Fingerprint) + + if err := c.InstallSecureBootCertificate(ctx, database, cert); err != nil { + return err + } + } + + return nil +} + +func (r *Reconciler) resolveSecureBootCertificate(ctx context.Context, keyRef *v1alpha3.TrustedSecureBootKeyRef) (string, error) { + if keyRef.SecretKeyRef == nil && keyRef.ConfigMapKeyRef == nil { + return "", fmt.Errorf("trusted Secure Boot key must set secretKeyRef or configMapKeyRef") + } + + if keyRef.SecretKeyRef != nil && keyRef.ConfigMapKeyRef != nil { + return "", fmt.Errorf("trusted Secure Boot key must not set both secretKeyRef and configMapKeyRef") + } + + if keyRef.SecretKeyRef != nil { + return r.resolveSecureBootCertificateFromSecret(ctx, keyRef.SecretKeyRef) + } + + return r.resolveSecureBootCertificateFromConfigMap(ctx, keyRef.ConfigMapKeyRef) +} + +func (r *Reconciler) resolveSecureBootCertificateFromSecret(ctx context.Context, ref *v1alpha3.SecureBootObjectKeySelector) (string, error) { + var secret corev1.Secret + if err := r.Client.Get(ctx, types.NamespacedName{Name: ref.Name, Namespace: ref.Namespace}, &secret); err != nil { + return "", fmt.Errorf("getting Secure Boot certificate secret %s/%s: %w", ref.Namespace, ref.Name, err) + } + + data, ok := secret.Data[ref.Key] + if !ok { + return "", fmt.Errorf("key %q not found in Secure Boot certificate secret %s/%s", ref.Key, ref.Namespace, ref.Name) + } + + return string(data), nil +} + +func (r *Reconciler) resolveSecureBootCertificateFromConfigMap(ctx context.Context, ref *v1alpha3.SecureBootObjectKeySelector) (string, error) { + var cm corev1.ConfigMap + if err := r.Client.Get(ctx, types.NamespacedName{Name: ref.Name, Namespace: ref.Namespace}, &cm); err != nil { + return "", fmt.Errorf("getting Secure Boot certificate ConfigMap %s/%s: %w", ref.Namespace, ref.Name, err) + } + + if data, ok := cm.Data[ref.Key]; ok { + return data, nil + } + + if data, ok := cm.BinaryData[ref.Key]; ok { + return string(data), nil + } + + return "", fmt.Errorf("key %q not found in Secure Boot certificate ConfigMap %s/%s", ref.Key, ref.Namespace, ref.Name) +} + // reconcileBootOrder ensures the boot source override matches the desired state. // Returns ErrUnsupported if the BMC does not support boot order configuration. func (r *Reconciler) reconcileBootOrder(ctx context.Context, log *slog.Logger, machine *v1alpha3.Machine, c *Client, pendingRepave bool) error { @@ -225,6 +390,21 @@ func (r *Reconciler) reconcileBootOrder(ctx context.Context, log *slog.Logger, m return c.DisableBootOverride(ctx) } +func reconcileSecureBoot(ctx context.Context, log *slog.Logger, c *Client, desired bool) error { + config, err := c.GetSecureBootConfig(ctx) + if err != nil { + return err + } + + if config.Enabled == desired { + return nil + } + + log.Info("setting Secure Boot state", "currentEnabled", config.Enabled, "desiredEnabled", desired) + + return c.SetSecureBootEnabled(ctx, desired) +} + func (r *Reconciler) httpBootURL(machine *v1alpha3.Machine) (string, error) { if r.FileResolver == nil { return "", fmt.Errorf("netboot file resolver is not configured") diff --git a/internal/metalman/redfish/redfish_test.go b/internal/metalman/redfish/redfish_test.go index 57c316bf..b097fbd3 100644 --- a/internal/metalman/redfish/redfish_test.go +++ b/internal/metalman/redfish/redfish_test.go @@ -5,11 +5,19 @@ package redfish import ( "context" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" "crypto/sha256" + "crypto/x509" + "crypto/x509/pkix" "encoding/json" + "encoding/pem" + "errors" "fmt" "io" "log/slog" + "math/big" "net" "net/http" "net/http/httptest" @@ -147,8 +155,9 @@ func TestRedfishRebootCycle(t *testing.T) { ObjectMeta: metav1.ObjectMeta{Name: "node-01", Namespace: "default"}, Spec: v1alpha3.MachineSpec{ PXE: &v1alpha3.PXESpec{ - Image: "ghcr.io/test/test-image:v1", - DHCPLeases: []v1alpha3.DHCPLease{{MAC: "aa:bb:cc:dd:ee:f0", IPv4: "10.0.0.1", SubnetMask: "255.255.255.0"}}, + Image: "ghcr.io/test/test-image:v1", + InsecureDisableSecureBoot: true, + DHCPLeases: []v1alpha3.DHCPLease{{MAC: "aa:bb:cc:dd:ee:f0", IPv4: "10.0.0.1", SubnetMask: "255.255.255.0"}}, Redfish: &v1alpha3.RedfishSpec{ URL: srv.URL, Username: "admin", @@ -385,8 +394,9 @@ func TestRedfishPowerOnTimeoutRetry(t *testing.T) { ObjectMeta: metav1.ObjectMeta{Name: "node-stuck", Namespace: "default"}, Spec: v1alpha3.MachineSpec{ PXE: &v1alpha3.PXESpec{ - Image: "ghcr.io/test/test-image:v1", - DHCPLeases: []v1alpha3.DHCPLease{{MAC: "aa:bb:cc:dd:ee:f5", IPv4: "10.0.0.6", SubnetMask: "255.255.255.0"}}, + Image: "ghcr.io/test/test-image:v1", + InsecureDisableSecureBoot: true, + DHCPLeases: []v1alpha3.DHCPLease{{MAC: "aa:bb:cc:dd:ee:f5", IPv4: "10.0.0.6", SubnetMask: "255.255.255.0"}}, Redfish: &v1alpha3.RedfishSpec{ URL: srv.URL, Username: "admin", @@ -515,8 +525,9 @@ func TestRedfishForceOffTimeoutRetry(t *testing.T) { ObjectMeta: metav1.ObjectMeta{Name: "node-stuck-off", Namespace: "default"}, Spec: v1alpha3.MachineSpec{ PXE: &v1alpha3.PXESpec{ - Image: "ghcr.io/test/test-image:v1", - DHCPLeases: []v1alpha3.DHCPLease{{MAC: "aa:bb:cc:dd:ee:f6", IPv4: "10.0.0.7", SubnetMask: "255.255.255.0"}}, + Image: "ghcr.io/test/test-image:v1", + InsecureDisableSecureBoot: true, + DHCPLeases: []v1alpha3.DHCPLease{{MAC: "aa:bb:cc:dd:ee:f6", IPv4: "10.0.0.7", SubnetMask: "255.255.255.0"}}, Redfish: &v1alpha3.RedfishSpec{ URL: srv.URL, Username: "admin", @@ -702,8 +713,9 @@ func TestRedfishExactlyOnceSemantics(t *testing.T) { ObjectMeta: metav1.ObjectMeta{Name: "node-once", Namespace: "default"}, Spec: v1alpha3.MachineSpec{ PXE: &v1alpha3.PXESpec{ - Image: "ghcr.io/test/test-image:v1", - DHCPLeases: []v1alpha3.DHCPLease{{MAC: "aa:bb:cc:dd:ee:f4", IPv4: "10.0.0.5", SubnetMask: "255.255.255.0"}}, + Image: "ghcr.io/test/test-image:v1", + InsecureDisableSecureBoot: true, + DHCPLeases: []v1alpha3.DHCPLease{{MAC: "aa:bb:cc:dd:ee:f4", IPv4: "10.0.0.5", SubnetMask: "255.255.255.0"}}, Redfish: &v1alpha3.RedfishSpec{ URL: srv.URL, Username: "admin", @@ -812,8 +824,9 @@ func TestBootOrderConfigPxeOn(t *testing.T) { ObjectMeta: metav1.ObjectMeta{Name: "node-boot-pxe", Namespace: "default"}, Spec: v1alpha3.MachineSpec{ PXE: &v1alpha3.PXESpec{ - Image: "ghcr.io/test/test-image:v1", - DHCPLeases: []v1alpha3.DHCPLease{{MAC: "aa:bb:cc:dd:ee:b0", IPv4: "10.0.0.30", SubnetMask: "255.255.255.0"}}, + Image: "ghcr.io/test/test-image:v1", + InsecureDisableSecureBoot: true, + DHCPLeases: []v1alpha3.DHCPLease{{MAC: "aa:bb:cc:dd:ee:b0", IPv4: "10.0.0.30", SubnetMask: "255.255.255.0"}}, Redfish: &v1alpha3.RedfishSpec{ URL: srv.URL, Username: "admin", @@ -914,8 +927,9 @@ func TestBootOrderConfigPxeOff(t *testing.T) { ObjectMeta: metav1.ObjectMeta{Name: "node-boot-hdd", Namespace: "default"}, Spec: v1alpha3.MachineSpec{ PXE: &v1alpha3.PXESpec{ - Image: "ghcr.io/test/test-image:v1", - DHCPLeases: []v1alpha3.DHCPLease{{MAC: "aa:bb:cc:dd:ee:b1", IPv4: "10.0.0.31", SubnetMask: "255.255.255.0"}}, + Image: "ghcr.io/test/test-image:v1", + InsecureDisableSecureBoot: true, + DHCPLeases: []v1alpha3.DHCPLease{{MAC: "aa:bb:cc:dd:ee:b1", IPv4: "10.0.0.31", SubnetMask: "255.255.255.0"}}, Redfish: &v1alpha3.RedfishSpec{ URL: srv.URL, Username: "admin", @@ -964,8 +978,10 @@ func TestBootOrderConfigPxeOff(t *testing.T) { func TestBootOrderConfigUEFIHTTPOn(t *testing.T) { var ( - patchCalls atomic.Int64 - patchBody map[string]any + bootPatchCalls atomic.Int64 + bootPatchBody map[string]any + secureBootPatchCalls atomic.Int64 + secureBootPatchBody map[string]any ) srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { @@ -989,8 +1005,16 @@ func TestBootOrderConfigUEFIHTTPOn(t *testing.T) { }) case r.Method == http.MethodPatch && strings.HasSuffix(r.URL.Path, "/Systems/System.Embedded.1"): - patchCalls.Add(1) - json.NewDecoder(r.Body).Decode(&patchBody) + bootPatchCalls.Add(1) + json.NewDecoder(r.Body).Decode(&bootPatchBody) + w.WriteHeader(http.StatusOK) + + case r.Method == http.MethodGet && strings.HasSuffix(r.URL.Path, "/Systems/System.Embedded.1/SecureBoot"): + json.NewEncoder(w).Encode(map[string]any{"SecureBootEnable": false}) + + case r.Method == http.MethodPatch && strings.HasSuffix(r.URL.Path, "/Systems/System.Embedded.1/SecureBoot"): + secureBootPatchCalls.Add(1) + json.NewDecoder(r.Body).Decode(&secureBootPatchBody) w.WriteHeader(http.StatusOK) case strings.Contains(r.URL.Path, "/TrustedComponents"): @@ -1051,11 +1075,19 @@ func TestBootOrderConfigUEFIHTTPOn(t *testing.T) { t.Fatalf("reconcile: %v", err) } - if patchCalls.Load() != 1 { - t.Fatalf("expected 1 PATCH call, got %d", patchCalls.Load()) + if secureBootPatchCalls.Load() != 1 { + t.Fatalf("expected 1 SecureBoot PATCH call, got %d", secureBootPatchCalls.Load()) } - boot, ok := patchBody["Boot"].(map[string]any) + if secureBootPatchBody["SecureBootEnable"] != true { + t.Fatalf("expected SecureBootEnable=true, got %v", secureBootPatchBody["SecureBootEnable"]) + } + + if bootPatchCalls.Load() != 1 { + t.Fatalf("expected 1 boot PATCH call, got %d", bootPatchCalls.Load()) + } + + boot, ok := bootPatchBody["Boot"].(map[string]any) if !ok { t.Fatal("expected Boot in PATCH body") } @@ -1102,6 +1134,13 @@ func TestBootOrderConfigUEFIHTTPNoOp(t *testing.T) { patchCalls.Add(1) w.WriteHeader(http.StatusOK) + case r.Method == http.MethodGet && strings.HasSuffix(r.URL.Path, "/Systems/System.Embedded.1/SecureBoot"): + json.NewEncoder(w).Encode(map[string]any{"SecureBootEnable": true}) + + case r.Method == http.MethodPatch && strings.HasSuffix(r.URL.Path, "/Systems/System.Embedded.1/SecureBoot"): + patchCalls.Add(1) + w.WriteHeader(http.StatusOK) + case strings.Contains(r.URL.Path, "/TrustedComponents"): http.NotFound(w, r) @@ -1152,10 +1191,14 @@ func TestBootOrderConfigUEFIHTTPNoOp(t *testing.T) { } } -func TestUEFIHTTPBootEndToEnd(t *testing.T) { - var patchBody map[string]any +func TestBootOrderConfigUEFIHTTPDisablesSecureBoot(t *testing.T) { + var ( + bootPatchCalls atomic.Int64 + secureBootPatchCalls atomic.Int64 + secureBootPatchBody map[string]any + ) - bmc := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if !testSessionAuth(w, r) { return } @@ -1165,45 +1208,42 @@ func TestUEFIHTTPBootEndToEnd(t *testing.T) { json.NewEncoder(w).Encode(map[string]any{ "PowerState": "On", "Boot": map[string]string{ - "BootSourceOverrideTarget": "Hdd", - "BootSourceOverrideEnabled": "Disabled", - }, - "Links": map[string]any{ - "Chassis": []map[string]any{{"@odata.id": "/redfish/v1/Chassis/1"}}, + "BootSourceOverrideTarget": "UefiHttp", + "BootSourceOverrideEnabled": "Once", + "BootSourceOverrideMode": "UEFI", + "HttpBootUri": "http://10.0.0.10:8880/shimx64.efi", }, }) case r.Method == http.MethodPatch && strings.HasSuffix(r.URL.Path, "/Systems/System.Embedded.1"): - json.NewDecoder(r.Body).Decode(&patchBody) + bootPatchCalls.Add(1) w.WriteHeader(http.StatusOK) - case strings.Contains(r.URL.Path, "/TrustedComponents"): - http.NotFound(w, r) + case r.Method == http.MethodGet && strings.HasSuffix(r.URL.Path, "/Systems/System.Embedded.1/SecureBoot"): + json.NewEncoder(w).Encode(map[string]any{"SecureBootEnable": true}) + + case r.Method == http.MethodPatch && strings.HasSuffix(r.URL.Path, "/Systems/System.Embedded.1/SecureBoot"): + secureBootPatchCalls.Add(1) + json.NewDecoder(r.Body).Decode(&secureBootPatchBody) + w.WriteHeader(http.StatusOK) default: http.NotFound(w, r) } })) - defer bmc.Close() - - port := freeTCPPort(t) - serveURL := fmt.Sprintf("http://127.0.0.1:%d", port) - imageRef := "ghcr.io/test/test-image:v1" - cache := setupRedfishOCICacheFiles(t, imageRef, "httpe2e123", map[string][]byte{ - "metadata.yaml": []byte("dhcpBootImageName: shimx64.efi\nhttpBootPath: shimx64.efi\n"), - "shimx64.efi": []byte("shim efi binary"), - }) + defer srv.Close() + fp := tlsServerFingerprint(srv) secret := &corev1.Secret{ObjectMeta: metav1.ObjectMeta{Name: "bmc-pass", Namespace: "default"}, Data: map[string][]byte{"password": []byte("secret")}} node := &v1alpha3.Machine{ - ObjectMeta: metav1.ObjectMeta{Name: "node-http-e2e", Namespace: "default"}, + ObjectMeta: metav1.ObjectMeta{Name: "node-boot-http-insecure", Namespace: "default"}, Spec: v1alpha3.MachineSpec{ PXE: &v1alpha3.PXESpec{ - Image: imageRef, - BootProtocol: v1alpha3.PXEBootProtocolHTTP, - DHCPLeases: []v1alpha3.DHCPLease{{MAC: "aa:bb:cc:dd:ee:e2", IPv4: "10.0.0.42", SubnetMask: "255.255.255.0"}}, + Image: "ghcr.io/test/test-image:v1", + BootProtocol: v1alpha3.PXEBootProtocolHTTP, + InsecureDisableSecureBoot: true, Redfish: &v1alpha3.RedfishSpec{ - URL: bmc.URL, + URL: srv.URL, Username: "admin", DeviceID: "System.Embedded.1", PasswordRef: v1alpha3.SecretKeySelector{Name: "bmc-pass", Namespace: "default", Key: "password"}, @@ -1211,91 +1251,44 @@ func TestUEFIHTTPBootEndToEnd(t *testing.T) { }, Operations: &v1alpha3.OperationsSpec{RepaveCounter: 1}, }, - Status: v1alpha3.MachineStatus{Redfish: &v1alpha3.RedfishStatus{CertFingerprint: tlsServerFingerprint(bmc)}}, + Status: v1alpha3.MachineStatus{Redfish: &v1alpha3.RedfishStatus{CertFingerprint: fp}}, } scheme := testScheme(t) - fc := fake.NewClientBuilder().WithScheme(scheme). - WithObjects(node, secret). - WithStatusSubresource(node). - WithIndex(&v1alpha3.Machine{}, indexing.IndexNodeByIP, indexing.IndexNodeByIPFunc). - Build() - - resolver := netboot.FileResolver{Cache: cache, Reader: fc, ServeURL: serveURL} - statusRecorder := &recordingNetbootStatusRecorder{} - httpServer := &netboot.HTTPServer{ - BindAddr: "127.0.0.1", - Port: port, - FileResolver: resolver, - StatusRecorder: statusRecorder, + fc := fake.NewClientBuilder().WithScheme(scheme).WithObjects(node, secret).WithStatusSubresource(node).Build() + reconciler := &Reconciler{ + Client: fc, + Pool: NewPool(), + FileResolver: &netboot.FileResolver{ + Cache: setupRedfishOCICache(t, "ghcr.io/test/test-image:v1", "httpredfishinsecure123", "httpBootPath: shimx64.efi\n"), + ServeURL: "http://10.0.0.10:8880", + }, } - ctx, cancel := context.WithCancel(t.Context()) - defer cancel() - - serverErr := make(chan error, 1) - - go func() { serverErr <- httpServer.Start(ctx) }() - - waitForHTTPHealth(t, serveURL+"/healthz") - - reconciler := &Reconciler{Client: fc, Pool: NewPool(), FileResolver: &resolver} - - _, err := reconciler.Reconcile(t.Context(), ctrl.Request{NamespacedName: types.NamespacedName{Name: "node-http-e2e", Namespace: "default"}}) + _, err := reconciler.Reconcile(t.Context(), ctrl.Request{NamespacedName: types.NamespacedName{Name: "node-boot-http-insecure", Namespace: "default"}}) if err != nil { t.Fatalf("reconcile: %v", err) } - boot, ok := patchBody["Boot"].(map[string]any) - if !ok { - t.Fatal("expected Boot in Redfish PATCH body") - } - - bootURL, ok := boot["HttpBootUri"].(string) - if !ok || bootURL != serveURL+"/shimx64.efi" { - t.Fatalf("expected UEFI HTTP URL %s/shimx64.efi, got %v", serveURL, boot["HttpBootUri"]) - } - - req, err := http.NewRequestWithContext(t.Context(), http.MethodGet, bootURL, nil) - if err != nil { - t.Fatal(err) - } - - req.Header.Set("X-Forwarded-For", "10.0.0.42") - - resp, err := http.DefaultClient.Do(req) - if err != nil { - t.Fatalf("GET UEFI HTTP boot URL: %v", err) - } - - body, err := io.ReadAll(resp.Body) - resp.Body.Close() - - if err != nil { - t.Fatal(err) - } - - if resp.StatusCode != http.StatusOK { - t.Fatalf("GET UEFI HTTP boot URL status: got %d, want 200", resp.StatusCode) - } - - if string(body) != "shim efi binary" { - t.Fatalf("GET UEFI HTTP boot URL body: got %q", body) + if secureBootPatchCalls.Load() != 1 { + t.Fatalf("expected 1 SecureBoot PATCH call, got %d", secureBootPatchCalls.Load()) } - if got := statusRecorder.bootLoaderEvents(); len(got) != 1 || got[0] != "node-http-e2e:shimx64.efi" { - t.Fatalf("bootloader events: got %v, want [node-http-e2e:shimx64.efi]", got) + if secureBootPatchBody["SecureBootEnable"] != false { + t.Fatalf("expected SecureBootEnable=false, got %v", secureBootPatchBody["SecureBootEnable"]) } - cancel() - - if err := <-serverErr; err != nil { - t.Fatalf("HTTP server returned error: %v", err) + if bootPatchCalls.Load() != 0 { + t.Fatalf("expected no boot PATCH call, got %d", bootPatchCalls.Load()) } } -func TestBootOrderConfigNoOp(t *testing.T) { - var patchCalls atomic.Int64 +func TestBootOrderConfigPXEDisablesSecureBoot(t *testing.T) { + var ( + bootPatchCalls atomic.Int64 + secureBootPatchCalls atomic.Int64 + secureBootPatchBody map[string]any + ) srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if !testSessionAuth(w, r) { @@ -1310,19 +1303,19 @@ func TestBootOrderConfigNoOp(t *testing.T) { "BootSourceOverrideTarget": "Pxe", "BootSourceOverrideEnabled": "Continuous", }, - "Links": map[string]any{ - "Chassis": []map[string]any{ - {"@odata.id": "/redfish/v1/Chassis/1"}, - }, - }, }) case r.Method == http.MethodPatch && strings.HasSuffix(r.URL.Path, "/Systems/System.Embedded.1"): - patchCalls.Add(1) + bootPatchCalls.Add(1) w.WriteHeader(http.StatusOK) - case strings.Contains(r.URL.Path, "/TrustedComponents"): - http.NotFound(w, r) + case r.Method == http.MethodGet && strings.HasSuffix(r.URL.Path, "/Systems/System.Embedded.1/SecureBoot"): + json.NewEncoder(w).Encode(map[string]any{"SecureBootEnable": true}) + + case r.Method == http.MethodPatch && strings.HasSuffix(r.URL.Path, "/Systems/System.Embedded.1/SecureBoot"): + secureBootPatchCalls.Add(1) + json.NewDecoder(r.Body).Decode(&secureBootPatchBody) + w.WriteHeader(http.StatusOK) default: http.NotFound(w, r) @@ -1331,18 +1324,14 @@ func TestBootOrderConfigNoOp(t *testing.T) { defer srv.Close() fp := tlsServerFingerprint(srv) - - secret := &corev1.Secret{ - ObjectMeta: metav1.ObjectMeta{Name: "bmc-pass", Namespace: "default"}, - Data: map[string][]byte{"password": []byte("secret")}, - } - + secret := &corev1.Secret{ObjectMeta: metav1.ObjectMeta{Name: "bmc-pass", Namespace: "default"}, Data: map[string][]byte{"password": []byte("secret")}} node := &v1alpha3.Machine{ - ObjectMeta: metav1.ObjectMeta{Name: "node-boot-noop", Namespace: "default"}, + ObjectMeta: metav1.ObjectMeta{Name: "node-boot-pxe-insecure", Namespace: "default"}, Spec: v1alpha3.MachineSpec{ PXE: &v1alpha3.PXESpec{ - Image: "ghcr.io/test/test-image:v1", - DHCPLeases: []v1alpha3.DHCPLease{{MAC: "aa:bb:cc:dd:ee:b2", IPv4: "10.0.0.32", SubnetMask: "255.255.255.0"}}, + Image: "ghcr.io/test/test-image:v1", + BootProtocol: v1alpha3.PXEBootProtocolPXE, + InsecureDisableSecureBoot: true, Redfish: &v1alpha3.RedfishSpec{ URL: srv.URL, Username: "admin", @@ -1350,37 +1339,35 @@ func TestBootOrderConfigNoOp(t *testing.T) { PasswordRef: v1alpha3.SecretKeySelector{Name: "bmc-pass", Namespace: "default", Key: "password"}, }, }, - Operations: &v1alpha3.OperationsSpec{ - RepaveCounter: 1, - }, - }, - Status: v1alpha3.MachineStatus{ - Redfish: &v1alpha3.RedfishStatus{CertFingerprint: fp}, + Operations: &v1alpha3.OperationsSpec{RepaveCounter: 1}, }, + Status: v1alpha3.MachineStatus{Redfish: &v1alpha3.RedfishStatus{CertFingerprint: fp}}, } scheme := testScheme(t) - fc := fake.NewClientBuilder().WithScheme(scheme). - WithObjects(node, secret). - WithStatusSubresource(node). - Build() - + fc := fake.NewClientBuilder().WithScheme(scheme).WithObjects(node, secret).WithStatusSubresource(node).Build() reconciler := &Reconciler{Client: fc, Pool: NewPool()} - ctx := t.Context() - req := ctrl.Request{NamespacedName: types.NamespacedName{Name: "node-boot-noop", Namespace: "default"}} - _, err := reconciler.Reconcile(ctx, req) + _, err := reconciler.Reconcile(t.Context(), ctrl.Request{NamespacedName: types.NamespacedName{Name: "node-boot-pxe-insecure", Namespace: "default"}}) if err != nil { t.Fatalf("reconcile: %v", err) } - if patchCalls.Load() != 0 { - t.Fatalf("expected 0 PATCH calls for no-op, got %d", patchCalls.Load()) + if secureBootPatchCalls.Load() != 1 { + t.Fatalf("expected 1 SecureBoot PATCH call, got %d", secureBootPatchCalls.Load()) + } + + if secureBootPatchBody["SecureBootEnable"] != false { + t.Fatalf("expected SecureBootEnable=false, got %v", secureBootPatchBody["SecureBootEnable"]) + } + + if bootPatchCalls.Load() != 0 { + t.Fatalf("expected no boot PATCH call, got %d", bootPatchCalls.Load()) } } -func TestBootOrderConfigNoOpPxeOff(t *testing.T) { - var patchCalls atomic.Int64 +func TestSecureBootUnsupportedWhenDisabledSetsCondition(t *testing.T) { + var secureBootGetCalls atomic.Int64 srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if !testSessionAuth(w, r) { @@ -1392,23 +1379,18 @@ func TestBootOrderConfigNoOpPxeOff(t *testing.T) { json.NewEncoder(w).Encode(map[string]any{ "PowerState": "On", "Boot": map[string]string{ - "BootSourceOverrideTarget": "None", - "BootSourceOverrideEnabled": "Disabled", - }, - "Links": map[string]any{ - "Chassis": []map[string]any{ - {"@odata.id": "/redfish/v1/Chassis/1"}, - }, + "BootSourceOverrideTarget": "Pxe", + "BootSourceOverrideEnabled": "Continuous", }, }) - case r.Method == http.MethodPatch && strings.HasSuffix(r.URL.Path, "/Systems/System.Embedded.1"): - patchCalls.Add(1) - w.WriteHeader(http.StatusOK) - - case strings.Contains(r.URL.Path, "/TrustedComponents"): + case r.Method == http.MethodGet && strings.HasSuffix(r.URL.Path, "/Systems/System.Embedded.1/SecureBoot"): + secureBootGetCalls.Add(1) http.NotFound(w, r) + case r.Method == http.MethodPatch && strings.HasSuffix(r.URL.Path, "/Systems/System.Embedded.1/SecureBoot"): + t.Fatal("unexpected SecureBoot PATCH") + default: http.NotFound(w, r) } @@ -1416,18 +1398,13 @@ func TestBootOrderConfigNoOpPxeOff(t *testing.T) { defer srv.Close() fp := tlsServerFingerprint(srv) - - secret := &corev1.Secret{ - ObjectMeta: metav1.ObjectMeta{Name: "bmc-pass", Namespace: "default"}, - Data: map[string][]byte{"password": []byte("secret")}, - } - + secret := &corev1.Secret{ObjectMeta: metav1.ObjectMeta{Name: "bmc-pass", Namespace: "default"}, Data: map[string][]byte{"password": []byte("secret")}} node := &v1alpha3.Machine{ - ObjectMeta: metav1.ObjectMeta{Name: "node-boot-noop-off", Namespace: "default"}, + ObjectMeta: metav1.ObjectMeta{Name: "node-secure-boot-unsupported", Namespace: "default"}, Spec: v1alpha3.MachineSpec{ PXE: &v1alpha3.PXESpec{ - Image: "ghcr.io/test/test-image:v1", - DHCPLeases: []v1alpha3.DHCPLease{{MAC: "aa:bb:cc:dd:ee:b5", IPv4: "10.0.0.35", SubnetMask: "255.255.255.0"}}, + Image: "ghcr.io/test/test-image:v1", + InsecureDisableSecureBoot: true, Redfish: &v1alpha3.RedfishSpec{ URL: srv.URL, Username: "admin", @@ -1435,37 +1412,49 @@ func TestBootOrderConfigNoOpPxeOff(t *testing.T) { PasswordRef: v1alpha3.SecretKeySelector{Name: "bmc-pass", Namespace: "default", Key: "password"}, }, }, + Operations: &v1alpha3.OperationsSpec{RepaveCounter: 1}, }, - Status: v1alpha3.MachineStatus{ - Redfish: &v1alpha3.RedfishStatus{CertFingerprint: fp}, - }, + Status: v1alpha3.MachineStatus{Redfish: &v1alpha3.RedfishStatus{CertFingerprint: fp}}, } scheme := testScheme(t) - fc := fake.NewClientBuilder().WithScheme(scheme). - WithObjects(node, secret). - WithStatusSubresource(node). - Build() - + fc := fake.NewClientBuilder().WithScheme(scheme).WithObjects(node, secret).WithStatusSubresource(node).Build() reconciler := &Reconciler{Client: fc, Pool: NewPool()} - ctx := t.Context() - req := ctrl.Request{NamespacedName: types.NamespacedName{Name: "node-boot-noop-off", Namespace: "default"}} + req := ctrl.Request{NamespacedName: types.NamespacedName{Name: "node-secure-boot-unsupported", Namespace: "default"}} - _, err := reconciler.Reconcile(ctx, req) + _, err := reconciler.Reconcile(t.Context(), req) if err != nil { t.Fatalf("reconcile: %v", err) } - if patchCalls.Load() != 0 { - t.Fatalf("expected 0 PATCH calls for no-op, got %d", patchCalls.Load()) + var updated v1alpha3.Machine + if err := fc.Get(t.Context(), req.NamespacedName, &updated); err != nil { + t.Fatal(err) + } + + cond := meta.FindStatusCondition(updated.Status.Conditions, condSecureBootSupported) + if cond == nil || cond.Status != metav1.ConditionFalse || cond.Reason != reasonNotSupported { + t.Fatalf("expected SecureBootConfigSupported=False/NotSupported, got %+v", cond) + } + + if secureBootGetCalls.Load() != 1 { + t.Fatalf("expected 1 SecureBoot GET call, got %d", secureBootGetCalls.Load()) + } + + // The second reconcile skips Secure Boot and continues through the rest of + // the Redfish flow. + _, err = reconciler.Reconcile(t.Context(), req) + if err != nil { + t.Fatalf("reconcile 2: %v", err) + } + + if secureBootGetCalls.Load() != 1 { + t.Fatalf("expected SecureBoot GET to be skipped after unsupported condition, got %d calls", secureBootGetCalls.Load()) } } -func TestBootOrderConfigPxeOffDisableFallbackToHdd(t *testing.T) { - var ( - patchCalls atomic.Int64 - lastPatchBody map[string]any - ) +func TestSecureBootUnsupportedWhenEnabledFailsClosed(t *testing.T) { + var secureBootGetCalls atomic.Int64 srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if !testSessionAuth(w, r) { @@ -1480,25 +1469,768 @@ func TestBootOrderConfigPxeOffDisableFallbackToHdd(t *testing.T) { "BootSourceOverrideTarget": "Pxe", "BootSourceOverrideEnabled": "Continuous", }, - "Links": map[string]any{ - "Chassis": []map[string]any{ - {"@odata.id": "/redfish/v1/Chassis/1"}, - }, - }, }) - case r.Method == http.MethodPatch && strings.HasSuffix(r.URL.Path, "/Systems/System.Embedded.1"): - patchCalls.Add(1) + case r.Method == http.MethodGet && strings.HasSuffix(r.URL.Path, "/Systems/System.Embedded.1/SecureBoot"): + secureBootGetCalls.Add(1) + http.NotFound(w, r) - var body map[string]any - json.NewDecoder(r.Body).Decode(&body) - lastPatchBody = body + case r.Method == http.MethodPatch && strings.HasSuffix(r.URL.Path, "/Systems/System.Embedded.1/SecureBoot"): + t.Fatal("unexpected SecureBoot PATCH") - boot, _ := body["Boot"].(map[string]any) - if boot["BootSourceOverrideEnabled"] == "Disabled" { - w.WriteHeader(http.StatusBadRequest) - return - } + default: + http.NotFound(w, r) + } + })) + defer srv.Close() + + fp := tlsServerFingerprint(srv) + secret := &corev1.Secret{ObjectMeta: metav1.ObjectMeta{Name: "bmc-pass", Namespace: "default"}, Data: map[string][]byte{"password": []byte("secret")}} + node := &v1alpha3.Machine{ + ObjectMeta: metav1.ObjectMeta{Name: "node-secure-boot-fail-closed", Namespace: "default"}, + Spec: v1alpha3.MachineSpec{ + PXE: &v1alpha3.PXESpec{ + Image: "ghcr.io/test/test-image:v1", + Redfish: &v1alpha3.RedfishSpec{ + URL: srv.URL, + Username: "admin", + DeviceID: "System.Embedded.1", + PasswordRef: v1alpha3.SecretKeySelector{Name: "bmc-pass", Namespace: "default", Key: "password"}, + }, + }, + Operations: &v1alpha3.OperationsSpec{RepaveCounter: 1}, + }, + Status: v1alpha3.MachineStatus{Redfish: &v1alpha3.RedfishStatus{CertFingerprint: fp}}, + } + + scheme := testScheme(t) + fc := fake.NewClientBuilder().WithScheme(scheme).WithObjects(node, secret).WithStatusSubresource(node).Build() + reconciler := &Reconciler{Client: fc, Pool: NewPool()} + req := ctrl.Request{NamespacedName: types.NamespacedName{Name: "node-secure-boot-fail-closed", Namespace: "default"}} + + _, err := reconciler.Reconcile(t.Context(), req) + if err == nil || !errors.Is(err, ErrUnsupported) { + t.Fatalf("expected ErrUnsupported, got %v", err) + } + + var updated v1alpha3.Machine + if err := fc.Get(t.Context(), req.NamespacedName, &updated); err != nil { + t.Fatal(err) + } + + cond := meta.FindStatusCondition(updated.Status.Conditions, condSecureBootSupported) + if cond == nil || cond.Status != metav1.ConditionFalse || cond.Reason != reasonNotSupported { + t.Fatalf("expected SecureBootConfigSupported=False/NotSupported, got %+v", cond) + } + + if secureBootGetCalls.Load() != 1 { + t.Fatalf("expected 1 SecureBoot GET call, got %d", secureBootGetCalls.Load()) + } + + _, err = reconciler.Reconcile(t.Context(), req) + if err == nil || !errors.Is(err, ErrUnsupported) { + t.Fatalf("expected cached ErrUnsupported, got %v", err) + } + + if secureBootGetCalls.Load() != 1 { + t.Fatalf("expected SecureBoot GET to be skipped after unsupported condition, got %d calls", secureBootGetCalls.Load()) + } +} + +func TestSecureBootTrustedKeyInstallsMissingCertificate(t *testing.T) { + var ( + postCalls atomic.Int64 + postBody map[string]any + ) + + srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if !testSessionAuth(w, r) { + return + } + + switch { + case r.Method == http.MethodGet && strings.HasSuffix(r.URL.Path, "/Systems/System.Embedded.1/SecureBoot/SecureBootDatabases/db/Certificates"): + json.NewEncoder(w).Encode(map[string]any{"Members": []any{}}) + + case r.Method == http.MethodPost && strings.HasSuffix(r.URL.Path, "/Systems/System.Embedded.1/SecureBoot/SecureBootDatabases/db/Certificates"): + postCalls.Add(1) + json.NewDecoder(r.Body).Decode(&postBody) + w.WriteHeader(http.StatusCreated) + + case r.Method == http.MethodGet && strings.HasSuffix(r.URL.Path, "/Systems/System.Embedded.1/SecureBoot"): + json.NewEncoder(w).Encode(map[string]any{"SecureBootEnable": true}) + + case r.Method == http.MethodPatch && strings.HasSuffix(r.URL.Path, "/Systems/System.Embedded.1/SecureBoot"): + t.Fatal("unexpected SecureBoot PATCH") + + case r.Method == http.MethodGet && strings.HasSuffix(r.URL.Path, "/Systems/System.Embedded.1"): + json.NewEncoder(w).Encode(map[string]any{ + "PowerState": "On", + "Boot": map[string]string{ + "BootSourceOverrideTarget": "Hdd", + "BootSourceOverrideEnabled": "Disabled", + }, + }) + + case r.Method == http.MethodPatch && strings.HasSuffix(r.URL.Path, "/Systems/System.Embedded.1"): + t.Fatal("unexpected boot PATCH") + + default: + http.NotFound(w, r) + } + })) + defer srv.Close() + + certPEM := testSecureBootCertificatePEM(t) + secret := &corev1.Secret{ObjectMeta: metav1.ObjectMeta{Name: "bmc-pass", Namespace: "default"}, Data: map[string][]byte{"password": []byte("secret")}} + certSecret := &corev1.Secret{ObjectMeta: metav1.ObjectMeta{Name: "secure-boot", Namespace: "default"}, Data: map[string][]byte{"db.pem": []byte(certPEM)}} + node := testSecureBootKeyMachine(srv.URL, tlsServerFingerprint(srv), &v1alpha3.TrustedSecureBootKeyRef{ + SecretKeyRef: &v1alpha3.SecureBootObjectKeySelector{Name: "secure-boot", Namespace: "default", Key: "db.pem"}, + }) + + scheme := testScheme(t) + fc := fake.NewClientBuilder().WithScheme(scheme).WithObjects(node, secret, certSecret).WithStatusSubresource(node).Build() + reconciler := &Reconciler{Client: fc, Pool: NewPool()} + + _, err := reconciler.Reconcile(t.Context(), ctrl.Request{NamespacedName: types.NamespacedName{Name: node.Name, Namespace: node.Namespace}}) + if err != nil { + t.Fatalf("reconcile: %v", err) + } + + if postCalls.Load() != 1 { + t.Fatalf("expected 1 certificate POST call, got %d", postCalls.Load()) + } + + if postBody["CertificateType"] != certificateTypePEM { + t.Fatalf("expected CertificateType=%s, got %v", certificateTypePEM, postBody["CertificateType"]) + } + + if postBody["CertificateString"] != certPEM { + t.Fatalf("expected posted certificate to match ref data") + } + + var updated v1alpha3.Machine + if err := fc.Get(t.Context(), types.NamespacedName{Name: node.Name, Namespace: node.Namespace}, &updated); err != nil { + t.Fatal(err) + } + + cond := meta.FindStatusCondition(updated.Status.Conditions, condSecureBootKeysSupported) + if cond == nil || cond.Status != metav1.ConditionTrue || cond.Reason != "Reconciled" { + t.Fatalf("expected SecureBootKeyEnrollmentSupported=True/Reconciled, got %+v", cond) + } +} + +func TestSecureBootTrustedKeyAlreadyInstalledNoOp(t *testing.T) { + var postCalls atomic.Int64 + + certPEM := testSecureBootCertificatePEM(t) + + material, err := ParseSecureBootCertificate(certPEM) + if err != nil { + t.Fatal(err) + } + + srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if !testSessionAuth(w, r) { + return + } + + switch { + case r.Method == http.MethodGet && strings.HasSuffix(r.URL.Path, "/Systems/System.Embedded.1/SecureBoot/SecureBootDatabases/db/Certificates"): + json.NewEncoder(w).Encode(map[string]any{"Members": []map[string]any{{ + "Fingerprint": material.SHA256Fingerprint, + "FingerprintHashAlgorithm": "SHA256", + }}}) + + case r.Method == http.MethodPost && strings.HasSuffix(r.URL.Path, "/Systems/System.Embedded.1/SecureBoot/SecureBootDatabases/db/Certificates"): + postCalls.Add(1) + w.WriteHeader(http.StatusCreated) + + case r.Method == http.MethodGet && strings.HasSuffix(r.URL.Path, "/Systems/System.Embedded.1/SecureBoot"): + json.NewEncoder(w).Encode(map[string]any{"SecureBootEnable": true}) + + case r.Method == http.MethodGet && strings.HasSuffix(r.URL.Path, "/Systems/System.Embedded.1"): + json.NewEncoder(w).Encode(map[string]any{ + "PowerState": "On", + "Boot": map[string]string{ + "BootSourceOverrideTarget": "Hdd", + "BootSourceOverrideEnabled": "Disabled", + }, + }) + + case r.Method == http.MethodPatch: + t.Fatal("unexpected PATCH") + + default: + http.NotFound(w, r) + } + })) + defer srv.Close() + + secret := &corev1.Secret{ObjectMeta: metav1.ObjectMeta{Name: "bmc-pass", Namespace: "default"}, Data: map[string][]byte{"password": []byte("secret")}} + certSecret := &corev1.Secret{ObjectMeta: metav1.ObjectMeta{Name: "secure-boot", Namespace: "default"}, Data: map[string][]byte{"db.pem": []byte(certPEM)}} + node := testSecureBootKeyMachine(srv.URL, tlsServerFingerprint(srv), &v1alpha3.TrustedSecureBootKeyRef{ + SecretKeyRef: &v1alpha3.SecureBootObjectKeySelector{Name: "secure-boot", Namespace: "default", Key: "db.pem"}, + }) + + scheme := testScheme(t) + fc := fake.NewClientBuilder().WithScheme(scheme).WithObjects(node, secret, certSecret).WithStatusSubresource(node).Build() + reconciler := &Reconciler{Client: fc, Pool: NewPool()} + + _, err = reconciler.Reconcile(t.Context(), ctrl.Request{NamespacedName: types.NamespacedName{Name: node.Name, Namespace: node.Namespace}}) + if err != nil { + t.Fatalf("reconcile: %v", err) + } + + if postCalls.Load() != 0 { + t.Fatalf("expected no certificate POST calls, got %d", postCalls.Load()) + } +} + +func TestSecureBootTrustedKeyFromConfigMapWithDatabase(t *testing.T) { + var postPath string + + certPEM := testSecureBootCertificatePEM(t) + + srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if !testSessionAuth(w, r) { + return + } + + switch { + case r.Method == http.MethodGet && strings.HasSuffix(r.URL.Path, "/Systems/System.Embedded.1/SecureBoot/SecureBootDatabases/KEK/Certificates"): + json.NewEncoder(w).Encode(map[string]any{"Members": []any{}}) + + case r.Method == http.MethodPost && strings.HasSuffix(r.URL.Path, "/Systems/System.Embedded.1/SecureBoot/SecureBootDatabases/KEK/Certificates"): + postPath = r.URL.Path + + w.WriteHeader(http.StatusCreated) + + case r.Method == http.MethodGet && strings.HasSuffix(r.URL.Path, "/Systems/System.Embedded.1/SecureBoot"): + json.NewEncoder(w).Encode(map[string]any{"SecureBootEnable": true}) + + case r.Method == http.MethodGet && strings.HasSuffix(r.URL.Path, "/Systems/System.Embedded.1"): + json.NewEncoder(w).Encode(map[string]any{ + "PowerState": "On", + "Boot": map[string]string{ + "BootSourceOverrideTarget": "Hdd", + "BootSourceOverrideEnabled": "Disabled", + }, + }) + + case r.Method == http.MethodPatch: + t.Fatal("unexpected PATCH") + + default: + http.NotFound(w, r) + } + })) + defer srv.Close() + + secret := &corev1.Secret{ObjectMeta: metav1.ObjectMeta{Name: "bmc-pass", Namespace: "default"}, Data: map[string][]byte{"password": []byte("secret")}} + cm := &corev1.ConfigMap{ObjectMeta: metav1.ObjectMeta{Name: "secure-boot", Namespace: "default"}, Data: map[string]string{"kek.pem": certPEM}} + node := testSecureBootKeyMachine(srv.URL, tlsServerFingerprint(srv), &v1alpha3.TrustedSecureBootKeyRef{ + Database: v1alpha3.SecureBootDatabaseKEK, + ConfigMapKeyRef: &v1alpha3.SecureBootObjectKeySelector{Name: "secure-boot", Namespace: "default", Key: "kek.pem"}, + }) + + scheme := testScheme(t) + fc := fake.NewClientBuilder().WithScheme(scheme).WithObjects(node, secret, cm).WithStatusSubresource(node).Build() + reconciler := &Reconciler{Client: fc, Pool: NewPool()} + + _, err := reconciler.Reconcile(t.Context(), ctrl.Request{NamespacedName: types.NamespacedName{Name: node.Name, Namespace: node.Namespace}}) + if err != nil { + t.Fatalf("reconcile: %v", err) + } + + if !strings.HasSuffix(postPath, "/Systems/System.Embedded.1/SecureBoot/SecureBootDatabases/KEK/Certificates") { + t.Fatalf("expected KEK certificate collection POST, got %q", postPath) + } +} + +func TestSecureBootTrustedKeyInvalidPEMFailsBeforePost(t *testing.T) { + var postCalls atomic.Int64 + + srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if !testSessionAuth(w, r) { + return + } + + switch { + case r.Method == http.MethodPost && strings.Contains(r.URL.Path, "/SecureBootDatabases/"): + postCalls.Add(1) + w.WriteHeader(http.StatusCreated) + + default: + http.NotFound(w, r) + } + })) + defer srv.Close() + + secret := &corev1.Secret{ObjectMeta: metav1.ObjectMeta{Name: "bmc-pass", Namespace: "default"}, Data: map[string][]byte{"password": []byte("secret")}} + certSecret := &corev1.Secret{ObjectMeta: metav1.ObjectMeta{Name: "secure-boot", Namespace: "default"}, Data: map[string][]byte{"db.pem": []byte("not a certificate")}} + node := testSecureBootKeyMachine(srv.URL, tlsServerFingerprint(srv), &v1alpha3.TrustedSecureBootKeyRef{ + SecretKeyRef: &v1alpha3.SecureBootObjectKeySelector{Name: "secure-boot", Namespace: "default", Key: "db.pem"}, + }) + + scheme := testScheme(t) + fc := fake.NewClientBuilder().WithScheme(scheme).WithObjects(node, secret, certSecret).WithStatusSubresource(node).Build() + reconciler := &Reconciler{Client: fc, Pool: NewPool()} + + _, err := reconciler.Reconcile(t.Context(), ctrl.Request{NamespacedName: types.NamespacedName{Name: node.Name, Namespace: node.Namespace}}) + if err == nil || !strings.Contains(err.Error(), "no PEM block") { + t.Fatalf("expected invalid PEM error, got %v", err) + } + + if postCalls.Load() != 0 { + t.Fatalf("expected no certificate POST calls, got %d", postCalls.Load()) + } +} + +func TestSecureBootTrustedKeyUnsupportedFailsClosed(t *testing.T) { + var getCalls atomic.Int64 + + srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if !testSessionAuth(w, r) { + return + } + + switch { + case r.Method == http.MethodGet && strings.HasSuffix(r.URL.Path, "/Systems/System.Embedded.1/SecureBoot/SecureBootDatabases/db/Certificates"): + getCalls.Add(1) + http.NotFound(w, r) + + case r.Method == http.MethodPost && strings.Contains(r.URL.Path, "/SecureBootDatabases/"): + t.Fatal("unexpected certificate POST") + + default: + http.NotFound(w, r) + } + })) + defer srv.Close() + + certPEM := testSecureBootCertificatePEM(t) + secret := &corev1.Secret{ObjectMeta: metav1.ObjectMeta{Name: "bmc-pass", Namespace: "default"}, Data: map[string][]byte{"password": []byte("secret")}} + certSecret := &corev1.Secret{ObjectMeta: metav1.ObjectMeta{Name: "secure-boot", Namespace: "default"}, Data: map[string][]byte{"db.pem": []byte(certPEM)}} + node := testSecureBootKeyMachine(srv.URL, tlsServerFingerprint(srv), &v1alpha3.TrustedSecureBootKeyRef{ + SecretKeyRef: &v1alpha3.SecureBootObjectKeySelector{Name: "secure-boot", Namespace: "default", Key: "db.pem"}, + }) + + scheme := testScheme(t) + fc := fake.NewClientBuilder().WithScheme(scheme).WithObjects(node, secret, certSecret).WithStatusSubresource(node).Build() + reconciler := &Reconciler{Client: fc, Pool: NewPool()} + req := ctrl.Request{NamespacedName: types.NamespacedName{Name: node.Name, Namespace: node.Namespace}} + + _, err := reconciler.Reconcile(t.Context(), req) + if !errors.Is(err, ErrUnsupported) { + t.Fatalf("expected ErrUnsupported, got %v", err) + } + + var updated v1alpha3.Machine + if err := fc.Get(t.Context(), types.NamespacedName{Name: node.Name, Namespace: node.Namespace}, &updated); err != nil { + t.Fatal(err) + } + + cond := meta.FindStatusCondition(updated.Status.Conditions, condSecureBootKeysSupported) + if cond == nil || cond.Status != metav1.ConditionFalse || cond.Reason != reasonNotSupported { + t.Fatalf("expected SecureBootKeyEnrollmentSupported=False/NotSupported, got %+v", cond) + } + + if getCalls.Load() != 1 { + t.Fatalf("expected 1 certificate GET call, got %d", getCalls.Load()) + } + + _, err = reconciler.Reconcile(t.Context(), req) + if !errors.Is(err, ErrUnsupported) { + t.Fatalf("expected cached ErrUnsupported, got %v", err) + } + + if getCalls.Load() != 1 { + t.Fatalf("expected certificate GET to be skipped after unsupported condition, got %d calls", getCalls.Load()) + } +} + +func TestSecureBootTrustedKeyMissingSecretKeyFails(t *testing.T) { + srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if !testSessionAuth(w, r) { + return + } + + http.NotFound(w, r) + })) + defer srv.Close() + + secret := &corev1.Secret{ObjectMeta: metav1.ObjectMeta{Name: "bmc-pass", Namespace: "default"}, Data: map[string][]byte{"password": []byte("secret")}} + certSecret := &corev1.Secret{ObjectMeta: metav1.ObjectMeta{Name: "secure-boot", Namespace: "default"}, Data: map[string][]byte{"other.pem": []byte(testSecureBootCertificatePEM(t))}} + node := testSecureBootKeyMachine(srv.URL, tlsServerFingerprint(srv), &v1alpha3.TrustedSecureBootKeyRef{ + SecretKeyRef: &v1alpha3.SecureBootObjectKeySelector{Name: "secure-boot", Namespace: "default", Key: "db.pem"}, + }) + + scheme := testScheme(t) + fc := fake.NewClientBuilder().WithScheme(scheme).WithObjects(node, secret, certSecret).WithStatusSubresource(node).Build() + reconciler := &Reconciler{Client: fc, Pool: NewPool()} + + _, err := reconciler.Reconcile(t.Context(), ctrl.Request{NamespacedName: types.NamespacedName{Name: node.Name, Namespace: node.Namespace}}) + if err == nil || !strings.Contains(err.Error(), "key \"db.pem\" not found") { + t.Fatalf("expected missing key error, got %v", err) + } +} + +func TestUEFIHTTPBootEndToEnd(t *testing.T) { + var bootPatchBody map[string]any + + bmc := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if !testSessionAuth(w, r) { + return + } + + switch { + case r.Method == http.MethodGet && strings.HasSuffix(r.URL.Path, "/Systems/System.Embedded.1"): + json.NewEncoder(w).Encode(map[string]any{ + "PowerState": "On", + "Boot": map[string]string{ + "BootSourceOverrideTarget": "Hdd", + "BootSourceOverrideEnabled": "Disabled", + }, + "Links": map[string]any{ + "Chassis": []map[string]any{{"@odata.id": "/redfish/v1/Chassis/1"}}, + }, + }) + + case r.Method == http.MethodPatch && strings.HasSuffix(r.URL.Path, "/Systems/System.Embedded.1"): + json.NewDecoder(r.Body).Decode(&bootPatchBody) + w.WriteHeader(http.StatusOK) + + case r.Method == http.MethodGet && strings.HasSuffix(r.URL.Path, "/Systems/System.Embedded.1/SecureBoot"): + json.NewEncoder(w).Encode(map[string]any{"SecureBootEnable": true}) + + case r.Method == http.MethodPatch && strings.HasSuffix(r.URL.Path, "/Systems/System.Embedded.1/SecureBoot"): + w.WriteHeader(http.StatusOK) + + case strings.Contains(r.URL.Path, "/TrustedComponents"): + http.NotFound(w, r) + + default: + http.NotFound(w, r) + } + })) + defer bmc.Close() + + port := freeTCPPort(t) + serveURL := fmt.Sprintf("http://127.0.0.1:%d", port) + imageRef := "ghcr.io/test/test-image:v1" + cache := setupRedfishOCICacheFiles(t, imageRef, "httpe2e123", map[string][]byte{ + "metadata.yaml": []byte("dhcpBootImageName: shimx64.efi\nhttpBootPath: shimx64.efi\n"), + "shimx64.efi": []byte("shim efi binary"), + }) + + secret := &corev1.Secret{ObjectMeta: metav1.ObjectMeta{Name: "bmc-pass", Namespace: "default"}, Data: map[string][]byte{"password": []byte("secret")}} + node := &v1alpha3.Machine{ + ObjectMeta: metav1.ObjectMeta{Name: "node-http-e2e", Namespace: "default"}, + Spec: v1alpha3.MachineSpec{ + PXE: &v1alpha3.PXESpec{ + Image: imageRef, + BootProtocol: v1alpha3.PXEBootProtocolHTTP, + DHCPLeases: []v1alpha3.DHCPLease{{MAC: "aa:bb:cc:dd:ee:e2", IPv4: "10.0.0.42", SubnetMask: "255.255.255.0"}}, + Redfish: &v1alpha3.RedfishSpec{ + URL: bmc.URL, + Username: "admin", + DeviceID: "System.Embedded.1", + PasswordRef: v1alpha3.SecretKeySelector{Name: "bmc-pass", Namespace: "default", Key: "password"}, + }, + }, + Operations: &v1alpha3.OperationsSpec{RepaveCounter: 1}, + }, + Status: v1alpha3.MachineStatus{Redfish: &v1alpha3.RedfishStatus{CertFingerprint: tlsServerFingerprint(bmc)}}, + } + + scheme := testScheme(t) + fc := fake.NewClientBuilder().WithScheme(scheme). + WithObjects(node, secret). + WithStatusSubresource(node). + WithIndex(&v1alpha3.Machine{}, indexing.IndexNodeByIP, indexing.IndexNodeByIPFunc). + Build() + + resolver := netboot.FileResolver{Cache: cache, Reader: fc, ServeURL: serveURL} + statusRecorder := &recordingNetbootStatusRecorder{} + httpServer := &netboot.HTTPServer{ + BindAddr: "127.0.0.1", + Port: port, + FileResolver: resolver, + StatusRecorder: statusRecorder, + } + + ctx, cancel := context.WithCancel(t.Context()) + defer cancel() + + serverErr := make(chan error, 1) + + go func() { serverErr <- httpServer.Start(ctx) }() + + waitForHTTPHealth(t, serveURL+"/healthz") + + reconciler := &Reconciler{Client: fc, Pool: NewPool(), FileResolver: &resolver} + + _, err := reconciler.Reconcile(t.Context(), ctrl.Request{NamespacedName: types.NamespacedName{Name: "node-http-e2e", Namespace: "default"}}) + if err != nil { + t.Fatalf("reconcile: %v", err) + } + + boot, ok := bootPatchBody["Boot"].(map[string]any) + if !ok { + t.Fatal("expected Boot in Redfish PATCH body") + } + + bootURL, ok := boot["HttpBootUri"].(string) + if !ok || bootURL != serveURL+"/shimx64.efi" { + t.Fatalf("expected UEFI HTTP URL %s/shimx64.efi, got %v", serveURL, boot["HttpBootUri"]) + } + + req, err := http.NewRequestWithContext(t.Context(), http.MethodGet, bootURL, nil) + if err != nil { + t.Fatal(err) + } + + req.Header.Set("X-Forwarded-For", "10.0.0.42") + + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatalf("GET UEFI HTTP boot URL: %v", err) + } + + body, err := io.ReadAll(resp.Body) + resp.Body.Close() + + if err != nil { + t.Fatal(err) + } + + if resp.StatusCode != http.StatusOK { + t.Fatalf("GET UEFI HTTP boot URL status: got %d, want 200", resp.StatusCode) + } + + if string(body) != "shim efi binary" { + t.Fatalf("GET UEFI HTTP boot URL body: got %q", body) + } + + if got := statusRecorder.bootLoaderEvents(); len(got) != 1 || got[0] != "node-http-e2e:shimx64.efi" { + t.Fatalf("bootloader events: got %v, want [node-http-e2e:shimx64.efi]", got) + } + + cancel() + + if err := <-serverErr; err != nil { + t.Fatalf("HTTP server returned error: %v", err) + } +} + +func TestBootOrderConfigNoOp(t *testing.T) { + var patchCalls atomic.Int64 + + srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if !testSessionAuth(w, r) { + return + } + + switch { + case r.Method == http.MethodGet && strings.HasSuffix(r.URL.Path, "/Systems/System.Embedded.1"): + json.NewEncoder(w).Encode(map[string]any{ + "PowerState": "On", + "Boot": map[string]string{ + "BootSourceOverrideTarget": "Pxe", + "BootSourceOverrideEnabled": "Continuous", + }, + "Links": map[string]any{ + "Chassis": []map[string]any{ + {"@odata.id": "/redfish/v1/Chassis/1"}, + }, + }, + }) + + case r.Method == http.MethodPatch && strings.HasSuffix(r.URL.Path, "/Systems/System.Embedded.1"): + patchCalls.Add(1) + w.WriteHeader(http.StatusOK) + + case strings.Contains(r.URL.Path, "/TrustedComponents"): + http.NotFound(w, r) + + default: + http.NotFound(w, r) + } + })) + defer srv.Close() + + fp := tlsServerFingerprint(srv) + + secret := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{Name: "bmc-pass", Namespace: "default"}, + Data: map[string][]byte{"password": []byte("secret")}, + } + + node := &v1alpha3.Machine{ + ObjectMeta: metav1.ObjectMeta{Name: "node-boot-noop", Namespace: "default"}, + Spec: v1alpha3.MachineSpec{ + PXE: &v1alpha3.PXESpec{ + Image: "ghcr.io/test/test-image:v1", + InsecureDisableSecureBoot: true, + DHCPLeases: []v1alpha3.DHCPLease{{MAC: "aa:bb:cc:dd:ee:b2", IPv4: "10.0.0.32", SubnetMask: "255.255.255.0"}}, + Redfish: &v1alpha3.RedfishSpec{ + URL: srv.URL, + Username: "admin", + DeviceID: "System.Embedded.1", + PasswordRef: v1alpha3.SecretKeySelector{Name: "bmc-pass", Namespace: "default", Key: "password"}, + }, + }, + Operations: &v1alpha3.OperationsSpec{ + RepaveCounter: 1, + }, + }, + Status: v1alpha3.MachineStatus{ + Redfish: &v1alpha3.RedfishStatus{CertFingerprint: fp}, + }, + } + + scheme := testScheme(t) + fc := fake.NewClientBuilder().WithScheme(scheme). + WithObjects(node, secret). + WithStatusSubresource(node). + Build() + + reconciler := &Reconciler{Client: fc, Pool: NewPool()} + ctx := t.Context() + req := ctrl.Request{NamespacedName: types.NamespacedName{Name: "node-boot-noop", Namespace: "default"}} + + _, err := reconciler.Reconcile(ctx, req) + if err != nil { + t.Fatalf("reconcile: %v", err) + } + + if patchCalls.Load() != 0 { + t.Fatalf("expected 0 PATCH calls for no-op, got %d", patchCalls.Load()) + } +} + +func TestBootOrderConfigNoOpPxeOff(t *testing.T) { + var patchCalls atomic.Int64 + + srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if !testSessionAuth(w, r) { + return + } + + switch { + case r.Method == http.MethodGet && strings.HasSuffix(r.URL.Path, "/Systems/System.Embedded.1"): + json.NewEncoder(w).Encode(map[string]any{ + "PowerState": "On", + "Boot": map[string]string{ + "BootSourceOverrideTarget": "None", + "BootSourceOverrideEnabled": "Disabled", + }, + "Links": map[string]any{ + "Chassis": []map[string]any{ + {"@odata.id": "/redfish/v1/Chassis/1"}, + }, + }, + }) + + case r.Method == http.MethodPatch && strings.HasSuffix(r.URL.Path, "/Systems/System.Embedded.1"): + patchCalls.Add(1) + w.WriteHeader(http.StatusOK) + + case strings.Contains(r.URL.Path, "/TrustedComponents"): + http.NotFound(w, r) + + default: + http.NotFound(w, r) + } + })) + defer srv.Close() + + fp := tlsServerFingerprint(srv) + + secret := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{Name: "bmc-pass", Namespace: "default"}, + Data: map[string][]byte{"password": []byte("secret")}, + } + + node := &v1alpha3.Machine{ + ObjectMeta: metav1.ObjectMeta{Name: "node-boot-noop-off", Namespace: "default"}, + Spec: v1alpha3.MachineSpec{ + PXE: &v1alpha3.PXESpec{ + Image: "ghcr.io/test/test-image:v1", + InsecureDisableSecureBoot: true, + DHCPLeases: []v1alpha3.DHCPLease{{MAC: "aa:bb:cc:dd:ee:b5", IPv4: "10.0.0.35", SubnetMask: "255.255.255.0"}}, + Redfish: &v1alpha3.RedfishSpec{ + URL: srv.URL, + Username: "admin", + DeviceID: "System.Embedded.1", + PasswordRef: v1alpha3.SecretKeySelector{Name: "bmc-pass", Namespace: "default", Key: "password"}, + }, + }, + }, + Status: v1alpha3.MachineStatus{ + Redfish: &v1alpha3.RedfishStatus{CertFingerprint: fp}, + }, + } + + scheme := testScheme(t) + fc := fake.NewClientBuilder().WithScheme(scheme). + WithObjects(node, secret). + WithStatusSubresource(node). + Build() + + reconciler := &Reconciler{Client: fc, Pool: NewPool()} + ctx := t.Context() + req := ctrl.Request{NamespacedName: types.NamespacedName{Name: "node-boot-noop-off", Namespace: "default"}} + + _, err := reconciler.Reconcile(ctx, req) + if err != nil { + t.Fatalf("reconcile: %v", err) + } + + if patchCalls.Load() != 0 { + t.Fatalf("expected 0 PATCH calls for no-op, got %d", patchCalls.Load()) + } +} + +func TestBootOrderConfigPxeOffDisableFallbackToHdd(t *testing.T) { + var ( + patchCalls atomic.Int64 + lastPatchBody map[string]any + ) + + srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if !testSessionAuth(w, r) { + return + } + + switch { + case r.Method == http.MethodGet && strings.HasSuffix(r.URL.Path, "/Systems/System.Embedded.1"): + json.NewEncoder(w).Encode(map[string]any{ + "PowerState": "On", + "Boot": map[string]string{ + "BootSourceOverrideTarget": "Pxe", + "BootSourceOverrideEnabled": "Continuous", + }, + "Links": map[string]any{ + "Chassis": []map[string]any{ + {"@odata.id": "/redfish/v1/Chassis/1"}, + }, + }, + }) + + case r.Method == http.MethodPatch && strings.HasSuffix(r.URL.Path, "/Systems/System.Embedded.1"): + patchCalls.Add(1) + + var body map[string]any + json.NewDecoder(r.Body).Decode(&body) + lastPatchBody = body + + boot, _ := body["Boot"].(map[string]any) + if boot["BootSourceOverrideEnabled"] == "Disabled" { + w.WriteHeader(http.StatusBadRequest) + return + } w.WriteHeader(http.StatusOK) @@ -1522,8 +2254,9 @@ func TestBootOrderConfigPxeOffDisableFallbackToHdd(t *testing.T) { ObjectMeta: metav1.ObjectMeta{Name: "node-boot-fallback", Namespace: "default"}, Spec: v1alpha3.MachineSpec{ PXE: &v1alpha3.PXESpec{ - Image: "ghcr.io/test/test-image:v1", - DHCPLeases: []v1alpha3.DHCPLease{{MAC: "aa:bb:cc:dd:ee:b6", IPv4: "10.0.0.36", SubnetMask: "255.255.255.0"}}, + Image: "ghcr.io/test/test-image:v1", + InsecureDisableSecureBoot: true, + DHCPLeases: []v1alpha3.DHCPLease{{MAC: "aa:bb:cc:dd:ee:b6", IPv4: "10.0.0.36", SubnetMask: "255.255.255.0"}}, Redfish: &v1alpha3.RedfishSpec{ URL: srv.URL, Username: "admin", @@ -1617,8 +2350,9 @@ func TestBootOrderConfigNoOpPxeOffHdd(t *testing.T) { ObjectMeta: metav1.ObjectMeta{Name: "node-boot-noop-hdd", Namespace: "default"}, Spec: v1alpha3.MachineSpec{ PXE: &v1alpha3.PXESpec{ - Image: "ghcr.io/test/test-image:v1", - DHCPLeases: []v1alpha3.DHCPLease{{MAC: "aa:bb:cc:dd:ee:b7", IPv4: "10.0.0.37", SubnetMask: "255.255.255.0"}}, + Image: "ghcr.io/test/test-image:v1", + InsecureDisableSecureBoot: true, + DHCPLeases: []v1alpha3.DHCPLease{{MAC: "aa:bb:cc:dd:ee:b7", IPv4: "10.0.0.37", SubnetMask: "255.255.255.0"}}, Redfish: &v1alpha3.RedfishSpec{ URL: srv.URL, Username: "admin", @@ -1696,8 +2430,9 @@ func TestBootOrderConfigUnsupported(t *testing.T) { ObjectMeta: metav1.ObjectMeta{Name: "node-boot-unsup", Namespace: "default"}, Spec: v1alpha3.MachineSpec{ PXE: &v1alpha3.PXESpec{ - Image: "ghcr.io/test/test-image:v1", - DHCPLeases: []v1alpha3.DHCPLease{{MAC: "aa:bb:cc:dd:ee:b3", IPv4: "10.0.0.33", SubnetMask: "255.255.255.0"}}, + Image: "ghcr.io/test/test-image:v1", + InsecureDisableSecureBoot: true, + DHCPLeases: []v1alpha3.DHCPLease{{MAC: "aa:bb:cc:dd:ee:b3", IPv4: "10.0.0.33", SubnetMask: "255.255.255.0"}}, Redfish: &v1alpha3.RedfishSpec{ URL: srv.URL, Username: "admin", @@ -1795,8 +2530,9 @@ func TestBootOrderConfigUnsupportedDuringPOST(t *testing.T) { ObjectMeta: metav1.ObjectMeta{Name: "node-boot-post", Namespace: "default"}, Spec: v1alpha3.MachineSpec{ PXE: &v1alpha3.PXESpec{ - Image: "ghcr.io/test/test-image:v1", - DHCPLeases: []v1alpha3.DHCPLease{{MAC: "aa:bb:cc:dd:ee:b8", IPv4: "10.0.0.38", SubnetMask: "255.255.255.0"}}, + Image: "ghcr.io/test/test-image:v1", + InsecureDisableSecureBoot: true, + DHCPLeases: []v1alpha3.DHCPLease{{MAC: "aa:bb:cc:dd:ee:b8", IPv4: "10.0.0.38", SubnetMask: "255.255.255.0"}}, Redfish: &v1alpha3.RedfishSpec{ URL: srv.URL, Username: "admin", @@ -1916,8 +2652,9 @@ func TestBootOrderConfigTransientError(t *testing.T) { ObjectMeta: metav1.ObjectMeta{Name: "node-boot-503", Namespace: "default"}, Spec: v1alpha3.MachineSpec{ PXE: &v1alpha3.PXESpec{ - Image: "ghcr.io/test/test-image:v1", - DHCPLeases: []v1alpha3.DHCPLease{{MAC: "aa:bb:cc:dd:ee:b4", IPv4: "10.0.0.34", SubnetMask: "255.255.255.0"}}, + Image: "ghcr.io/test/test-image:v1", + InsecureDisableSecureBoot: true, + DHCPLeases: []v1alpha3.DHCPLease{{MAC: "aa:bb:cc:dd:ee:b4", IPv4: "10.0.0.34", SubnetMask: "255.255.255.0"}}, Redfish: &v1alpha3.RedfishSpec{ URL: srv.URL, Username: "admin", @@ -2165,6 +2902,53 @@ func testScheme(t *testing.T) *runtime.Scheme { return s } +func testSecureBootKeyMachine(redfishURL, fingerprint string, keyRef *v1alpha3.TrustedSecureBootKeyRef) *v1alpha3.Machine { + return &v1alpha3.Machine{ + ObjectMeta: metav1.ObjectMeta{Name: "node-secure-boot-key", Namespace: "default"}, + Spec: v1alpha3.MachineSpec{ + PXE: &v1alpha3.PXESpec{ + Image: "ghcr.io/test/test-image:v1", + TrustedSecureBootKeys: []v1alpha3.TrustedSecureBootKeyRef{*keyRef}, + Redfish: &v1alpha3.RedfishSpec{ + URL: redfishURL, + Username: "admin", + DeviceID: "System.Embedded.1", + PasswordRef: v1alpha3.SecretKeySelector{Name: "bmc-pass", Namespace: "default", Key: "password"}, + }, + }, + Operations: &v1alpha3.OperationsSpec{}, + }, + Status: v1alpha3.MachineStatus{Redfish: &v1alpha3.RedfishStatus{CertFingerprint: fingerprint}}, + } +} + +func testSecureBootCertificatePEM(t testing.TB) string { + t.Helper() + + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + t.Fatal(err) + } + + tmpl := x509.Certificate{ + SerialNumber: big.NewInt(1), + Subject: pkix.Name{ + CommonName: "unbounded-db", + }, + NotBefore: time.Now().Add(-time.Hour), + NotAfter: time.Now().Add(time.Hour), + KeyUsage: x509.KeyUsageDigitalSignature, + BasicConstraintsValid: true, + } + + der, err := x509.CreateCertificate(rand.Reader, &tmpl, &tmpl, &key.PublicKey, key) + if err != nil { + t.Fatal(err) + } + + return string(pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der})) +} + func tlsServerFingerprint(srv *httptest.Server) string { raw := srv.TLS.Certificates[0].Certificate[0] h := sha256.Sum256(raw) diff --git a/internal/metalman/redfish/secureboot.go b/internal/metalman/redfish/secureboot.go new file mode 100644 index 00000000..34f5d642 --- /dev/null +++ b/internal/metalman/redfish/secureboot.go @@ -0,0 +1,222 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +package redfish + +import ( + "context" + "crypto/sha256" + "crypto/x509" + "encoding/json" + "encoding/pem" + "fmt" + "net/http" + "net/url" + "strings" +) + +const certificateTypePEM = "PEM" + +// SecureBootCertificate holds certificate metadata reported by Redfish. +type SecureBootCertificate struct { + CertificateString string + Fingerprint string + FingerprintHashAlgorithm string +} + +// SecureBootCertificateMaterial is validated certificate material ready for enrollment. +type SecureBootCertificateMaterial struct { + PEM string + SHA256Fingerprint string +} + +// ParseSecureBootCertificate validates a PEM-encoded X.509 certificate and +// returns the Redfish-compatible SHA-256 fingerprint over its DER bytes. +func ParseSecureBootCertificate(certPEM string) (SecureBootCertificateMaterial, error) { + block, _ := pem.Decode([]byte(certPEM)) + if block == nil { + return SecureBootCertificateMaterial{}, fmt.Errorf("decoding PEM certificate: no PEM block found") + } + + if block.Type != "CERTIFICATE" { + return SecureBootCertificateMaterial{}, fmt.Errorf("decoding PEM certificate: got PEM block type %q", block.Type) + } + + if _, err := x509.ParseCertificate(block.Bytes); err != nil { + return SecureBootCertificateMaterial{}, fmt.Errorf("parsing X.509 certificate: %w", err) + } + + h := sha256.Sum256(block.Bytes) + + return SecureBootCertificateMaterial{ + PEM: certPEM, + SHA256Fingerprint: formatFingerprint(h[:]), + }, nil +} + +// ListSecureBootCertificates returns certificates in a UEFI Secure Boot database. +// Returns ErrUnsupported if the BMC does not expose certificate enrollment. +func (c *Client) ListSecureBootCertificates(ctx context.Context, database string) ([]SecureBootCertificate, error) { + path := c.secureBootCertificateCollectionPath(database) + + data, status, err := c.session.do(ctx, http.MethodGet, path, nil) + if err != nil { + return nil, err + } + + if isUnsupportedStatus(status) { + return nil, fmt.Errorf("SecureBoot certificate collection GET returned %d: %w", status, ErrUnsupported) + } + + if status != http.StatusOK { + return nil, fmt.Errorf("unexpected status %d from %s: %s", status, path, data) + } + + var collection struct { + Members []struct { + ODataID string `json:"@odata.id"` + CertificateString string `json:"CertificateString"` + Fingerprint string `json:"Fingerprint"` + FingerprintHashAlgorithm string `json:"FingerprintHashAlgorithm"` + } `json:"Members"` + } + if err := json.Unmarshal(data, &collection); err != nil { + return nil, fmt.Errorf("parsing SecureBoot certificate collection: %w", err) + } + + certs := make([]SecureBootCertificate, 0, len(collection.Members)) + for _, member := range collection.Members { + cert := SecureBootCertificate{ + CertificateString: member.CertificateString, + Fingerprint: member.Fingerprint, + FingerprintHashAlgorithm: member.FingerprintHashAlgorithm, + } + + if cert.CertificateString == "" && cert.Fingerprint == "" && member.ODataID != "" { + fetched, err := c.getSecureBootCertificate(ctx, member.ODataID) + if err != nil { + return nil, err + } + + cert = fetched + } + + certs = append(certs, cert) + } + + return certs, nil +} + +// InstallSecureBootCertificate enrolls a PEM certificate into a UEFI Secure Boot database. +// Returns ErrUnsupported if the BMC does not support certificate enrollment. +func (c *Client) InstallSecureBootCertificate(ctx context.Context, database string, cert SecureBootCertificateMaterial) error { + path := c.secureBootCertificateCollectionPath(database) + body := map[string]string{ + "CertificateString": cert.PEM, + "CertificateType": certificateTypePEM, + } + + data, status, err := c.session.do(ctx, http.MethodPost, path, body) + if err != nil { + return err + } + + if isUnsupportedStatus(status) { + return fmt.Errorf("SecureBoot certificate collection POST returned %d: %w", status, ErrUnsupported) + } + + if !isSuccessStatus(status) { + return fmt.Errorf("unexpected status %d from SecureBoot certificate collection POST: %s", status, data) + } + + return nil +} + +func (c *Client) getSecureBootCertificate(ctx context.Context, path string) (SecureBootCertificate, error) { + path, err := redfishResourcePath(path) + if err != nil { + return SecureBootCertificate{}, err + } + + data, status, err := c.session.do(ctx, http.MethodGet, path, nil) + if err != nil { + return SecureBootCertificate{}, err + } + + if isUnsupportedStatus(status) { + return SecureBootCertificate{}, fmt.Errorf("SecureBoot certificate GET returned %d: %w", status, ErrUnsupported) + } + + if status != http.StatusOK { + return SecureBootCertificate{}, fmt.Errorf("unexpected status %d from %s: %s", status, path, data) + } + + var cert struct { + CertificateString string `json:"CertificateString"` + Fingerprint string `json:"Fingerprint"` + FingerprintHashAlgorithm string `json:"FingerprintHashAlgorithm"` + } + if err := json.Unmarshal(data, &cert); err != nil { + return SecureBootCertificate{}, fmt.Errorf("parsing SecureBoot certificate: %w", err) + } + + return SecureBootCertificate{ + CertificateString: cert.CertificateString, + Fingerprint: cert.Fingerprint, + FingerprintHashAlgorithm: cert.FingerprintHashAlgorithm, + }, nil +} + +func (c *Client) secureBootCertificateCollectionPath(database string) string { + return fmt.Sprintf("/redfish/v1/Systems/%s/SecureBoot/SecureBootDatabases/%s/Certificates", c.deviceID, database) +} + +func secureBootCertificateInstalled(existing []SecureBootCertificate, desired SecureBootCertificateMaterial) bool { + desiredFingerprint := normalizeFingerprint(desired.SHA256Fingerprint) + + for _, cert := range existing { + if isSHA256FingerprintAlgorithm(cert.FingerprintHashAlgorithm) && normalizeFingerprint(cert.Fingerprint) == desiredFingerprint { + return true + } + + if cert.CertificateString == "" { + continue + } + + parsed, err := ParseSecureBootCertificate(cert.CertificateString) + if err == nil && normalizeFingerprint(parsed.SHA256Fingerprint) == desiredFingerprint { + return true + } + } + + return false +} + +func isSHA256FingerprintAlgorithm(algorithm string) bool { + normalized := strings.ToUpper(strings.ReplaceAll(strings.ReplaceAll(algorithm, "-", ""), "_", "")) + + return normalized == "SHA256" +} + +func normalizeFingerprint(fingerprint string) string { + replacer := strings.NewReplacer(":", "", " ", "", "-", "") + + return strings.ToLower(replacer.Replace(fingerprint)) +} + +func redfishResourcePath(resourceID string) (string, error) { + if !strings.HasPrefix(resourceID, "http://") && !strings.HasPrefix(resourceID, "https://") { + return resourceID, nil + } + + u, err := url.Parse(resourceID) + if err != nil { + return "", fmt.Errorf("parsing Redfish resource URI %q: %w", resourceID, err) + } + + if u.Path == "" { + return "", fmt.Errorf("redfish resource URI %q has no path", resourceID) + } + + return u.Path, nil +}