diff --git a/api/v1alpha1/bmc_types.go b/api/v1alpha1/bmc_types.go index f77c1974e..5c573e6a1 100644 --- a/api/v1alpha1/bmc_types.go +++ b/api/v1alpha1/bmc_types.go @@ -61,6 +61,65 @@ type BMCSpec struct { // Hostname is the hostname of the BMC. // +optional Hostname *string `json:"hostname,omitempty"` + + // CertificateManagementPolicy controls automatic certificate management for this BMC. + // When not specified, the BMC inherits the operator-level default (configured via controller flags). + // Set to Manual to explicitly disable certificate management for this specific BMC. + // Set to Automatic to explicitly enable certificate management (overriding operator default if disabled). + // + // Certificate configuration (signer name, approval policy, renewal threshold, subject fields) is + // configured at the operator level via controller manager flags and cannot be overridden per-BMC. + // This ensures consistent certificate policy across all BMCs in the cluster. + // + // +optional + // +kubebuilder:validation:Enum=Manual;Automatic + CertificateManagementPolicy *CertificateManagementPolicy `json:"certificateManagementPolicy,omitempty"` +} + +// CertificateManagementPolicy defines the policy for certificate management. +type CertificateManagementPolicy string + +const ( + // CertificateManagementPolicyManual means no automatic certificate operations + CertificateManagementPolicyManual CertificateManagementPolicy = "Manual" + // CertificateManagementPolicyAutomatic means automatic certificate creation and renewal + CertificateManagementPolicyAutomatic CertificateManagementPolicy = "Automatic" +) + +// CertificateApprovalPolicy defines how CertificateSigningRequests are approved. +type CertificateApprovalPolicy string + +const ( + // CertificateApprovalPolicyAuto means the controller automatically approves CSRs. + // WARNING: Use only in trusted, isolated environments with verified BMC hardware. + // Not recommended for multi-tenant or untrusted environments. + CertificateApprovalPolicyAuto CertificateApprovalPolicy = "Auto" + // CertificateApprovalPolicyExternal means CSRs must be approved by external entity. + // Recommended for production environments. Requires cert-manager, admin, or custom approver. + CertificateApprovalPolicyExternal CertificateApprovalPolicy = "External" +) + +// CertificateSubject defines certificate subject fields for CSR generation. +type CertificateSubject struct { + // Organization for the certificate subject. + // +optional + Organization string `json:"organization,omitempty"` + + // OrganizationalUnit for the certificate subject. + // +optional + OrganizationalUnit string `json:"organizationalUnit,omitempty"` + + // Country for the certificate subject. + // +optional + Country string `json:"country,omitempty"` + + // State for the certificate subject. + // +optional + State string `json:"state,omitempty"` + + // Locality (City) for the certificate subject. + // +optional + Locality string `json:"locality,omitempty"` } // InlineEndpoint defines inline network access configuration for the BMC. @@ -219,6 +278,40 @@ type BMCStatus struct { // +patchMergeKey=type // +optional Conditions []metav1.Condition `json:"conditions,omitempty" patchStrategy:"merge" patchMergeKey:"type" protobuf:"bytes,1,rep,name=conditions"` + + // CertificateInfo contains information about the BMC's current certificate. + // +optional + CertificateInfo *CertificateInfo `json:"certificateInfo,omitempty"` + + // CertificateSigningRequestRef references the current CertificateSigningRequest. + // +optional + CertificateSigningRequestRef *string `json:"certificateSigningRequestRef,omitempty"` + + // CertificateSecretRef references the Secret containing the installed certificate. + // The Secret is created in the metal-operator controller's namespace. + // +optional + CertificateSecretRef *v1.LocalObjectReference `json:"certificateSecretRef,omitempty"` +} + +// CertificateInfo contains information about a BMC certificate. +type CertificateInfo struct { + // Issuer is the certificate issuer DN. + Issuer string `json:"issuer,omitempty"` + + // Subject is the certificate subject DN. + Subject string `json:"subject,omitempty"` + + // NotBefore is the certificate validity start time. + NotBefore *metav1.Time `json:"notBefore,omitempty"` + + // NotAfter is the certificate validity end time. + NotAfter *metav1.Time `json:"notAfter,omitempty"` + + // SerialNumber is the certificate serial number. + SerialNumber string `json:"serialNumber,omitempty"` + + // Thumbprint is the SHA-256 thumbprint of the certificate. + Thumbprint string `json:"thumbprint,omitempty"` } // BMCState defines the possible states of a BMC. diff --git a/api/v1alpha1/zz_generated.deepcopy.go b/api/v1alpha1/zz_generated.deepcopy.go index 610bdc967..3bc8f6fa7 100644 --- a/api/v1alpha1/zz_generated.deepcopy.go +++ b/api/v1alpha1/zz_generated.deepcopy.go @@ -929,6 +929,11 @@ func (in *BMCSpec) DeepCopyInto(out *BMCSpec) { *out = new(string) **out = **in } + if in.CertificateManagementPolicy != nil { + in, out := &in.CertificateManagementPolicy, &out.CertificateManagementPolicy + *out = new(CertificateManagementPolicy) + **out = **in + } } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new BMCSpec. @@ -956,6 +961,21 @@ func (in *BMCStatus) DeepCopyInto(out *BMCStatus) { (*in)[i].DeepCopyInto(&(*out)[i]) } } + if in.CertificateInfo != nil { + in, out := &in.CertificateInfo, &out.CertificateInfo + *out = new(CertificateInfo) + (*in).DeepCopyInto(*out) + } + if in.CertificateSigningRequestRef != nil { + in, out := &in.CertificateSigningRequestRef, &out.CertificateSigningRequestRef + *out = new(string) + **out = **in + } + if in.CertificateSecretRef != nil { + in, out := &in.CertificateSecretRef, &out.CertificateSecretRef + *out = new(v1.LocalObjectReference) + **out = **in + } } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new BMCStatus. @@ -1329,6 +1349,44 @@ func (in *BootOrder) DeepCopy() *BootOrder { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *CertificateInfo) DeepCopyInto(out *CertificateInfo) { + *out = *in + if in.NotBefore != nil { + in, out := &in.NotBefore, &out.NotBefore + *out = (*in).DeepCopy() + } + if in.NotAfter != nil { + in, out := &in.NotAfter, &out.NotAfter + *out = (*in).DeepCopy() + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new CertificateInfo. +func (in *CertificateInfo) DeepCopy() *CertificateInfo { + if in == nil { + return nil + } + out := new(CertificateInfo) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *CertificateSubject) DeepCopyInto(out *CertificateSubject) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new CertificateSubject. +func (in *CertificateSubject) DeepCopy() *CertificateSubject { + if in == nil { + return nil + } + out := new(CertificateSubject) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *ConsoleProtocol) DeepCopyInto(out *ConsoleProtocol) { *out = *in diff --git a/bmc/bmc.go b/bmc/bmc.go index 3b3ffc40d..925d6b560 100644 --- a/bmc/bmc.go +++ b/bmc/bmc.go @@ -141,6 +141,21 @@ type BMC interface { // CheckBMCPendingComponentUpgrade checks if there are pending/staged firmware upgrades // for the given component type. CheckBMCPendingComponentUpgrade(ctx context.Context, componentType ComponentType) (bool, error) + + // GetCertificateService retrieves the certificate service for the BMC. + GetCertificateService(ctx context.Context) (*schemas.CertificateService, error) + + // GenerateCSR generates a Certificate Signing Request on the BMC. + GenerateCSR(ctx context.Context, req CSRRequest) (*CSRResponse, error) + + // InstallCertificate installs a certificate on the BMC. + InstallCertificate(ctx context.Context, certificatePEM string, certificateType CertificateType) error + + // GetCertificates retrieves all installed certificates on the BMC. + GetCertificates(ctx context.Context) ([]CertificateInfo, error) + + // DeleteCertificate deletes a certificate from the BMC. + DeleteCertificate(ctx context.Context, certificateURI string) error } type Entity struct { @@ -300,3 +315,73 @@ type Manager struct { MACAddress string OemLinks json.RawMessage } + +// CertificateType defines the type of certificate. +type CertificateType string + +const ( + // CertificateTypeHTTPS is the HTTPS/TLS certificate type + CertificateTypeHTTPS CertificateType = "PEM" + // CertificateTypeCA is the CA certificate type + CertificateTypeCA CertificateType = "PEM" +) + +// CertificatePurpose defines the purpose/usage of a certificate. +type CertificatePurpose string + +const ( + // CertificatePurposeHTTPS indicates an HTTPS/TLS server certificate + CertificatePurposeHTTPS CertificatePurpose = "HTTPS" + // CertificatePurposeCA indicates a CA certificate + CertificatePurposeCA CertificatePurpose = "CA" +) + +// CSRRequest contains parameters for generating a Certificate Signing Request. +type CSRRequest struct { + // CommonName is the common name for the certificate (typically hostname or IP) + CommonName string + // Organization is the organization name + Organization string + // OrganizationalUnit is the organizational unit + OrganizationalUnit string + // Country is the two-letter country code + Country string + // State is the state or province + State string + // City is the city or locality + City string + // Email is the email address + Email string + // KeyPairAlgorithm is the key pair algorithm (e.g., "RSA2048", "RSA4096", "EC256") + KeyPairAlgorithm string + // AlternativeNames are subject alternative names (DNS names, IP addresses) + AlternativeNames []string +} + +// CSRResponse contains the generated Certificate Signing Request. +type CSRResponse struct { + // CSRString is the PEM-encoded PKCS#10 CSR + CSRString string + // CertificateCollection is the URI where the signed certificate should be installed + CertificateCollection string +} + +// CertificateInfo contains information about an installed certificate. +type CertificateInfo struct { + // URI is the Redfish URI of the certificate resource + URI string + // Type is the certificate type + Type CertificateType + // Issuer is the certificate issuer distinguished name + Issuer string + // Subject is the certificate subject distinguished name + Subject string + // ValidNotBefore is the certificate validity start time + ValidNotBefore string + // ValidNotAfter is the certificate validity end time + ValidNotAfter string + // SerialNumber is the certificate serial number + SerialNumber string + // Fingerprint is the certificate fingerprint/thumbprint + Fingerprint string +} diff --git a/bmc/mock/server/server.go b/bmc/mock/server/server.go index 41992287c..50c259fbf 100644 --- a/bmc/mock/server/server.go +++ b/bmc/mock/server/server.go @@ -158,6 +158,21 @@ type MockServer struct { actionHandlers []actionHandler // ordered POST action dispatch table (first match wins) onCreate map[string]memberHook // collection URL suffix → hook called after a member is added onDelete map[string]memberHook // collection URL suffix → hook called before a member is removed + certificates map[string]CertificateData + csrCounter int +} + +// CertificateData represents a certificate stored on the mock BMC. +type CertificateData struct { + ID string + CertificateString string + CertificateType string + Issuer string + Subject string + ValidNotBefore string + ValidNotAfter string + SerialNumber string + Fingerprint string } // loadAccountsFromEmbedded seeds the authentication store by reading the @@ -200,6 +215,8 @@ func NewMockServer(log logr.Logger, addr string, opts ...Option) *MockServer { overrides: make(map[string]any), upgradedResources: make(map[string]string), accounts: loadAccountsFromEmbedded(), + certificates: make(map[string]CertificateData), + csrCounter: 0, // onCreate hooks run after a new collection member is stored. // Add an entry here to handle side-effects for additional collection types. onCreate: map[string]memberHook{ @@ -271,6 +288,18 @@ func NewMockServer(log logr.Logger, addr string, opts ...Option) *MockServer { matches: hasSuffix("/Actions/ManagerAccount.ChangePassword"), handle: s.handleChangePassword, }, + { + matches: func(path string) bool { + return strings.Contains(path, "CertificateService/Actions/CertificateService.GenerateCSR") + }, + handle: s.handleGenerateCSR, + }, + { + matches: func(path string) bool { + return strings.Contains(path, "/HTTPS/Certificates") && !strings.Contains(path, "CertificateService") + }, + handle: s.handleInstallCertificate, + }, } for _, opt := range opts { @@ -328,6 +357,16 @@ func (s *MockServer) redfishHandler(w http.ResponseWriter, r *http.Request) { } func (s *MockServer) handleGet(w http.ResponseWriter, r *http.Request) { + // Handle certificate-specific GET requests + if strings.Contains(r.URL.Path, "/HTTPS/Certificates") && !strings.HasSuffix(r.URL.Path, "/Certificates") { + s.handleGetCertificate(w, r) + return + } + if strings.HasSuffix(r.URL.Path, "/HTTPS/Certificates") { + s.handleGetCertificateCollection(w, r) + return + } + filePath := resolvePath(r.URL.Path) s.mu.RLock() @@ -499,6 +538,12 @@ func (s *MockServer) handlePatch(w http.ResponseWriter, r *http.Request) { } func (s *MockServer) handleDelete(w http.ResponseWriter, r *http.Request) { + // Handle certificate deletion + if strings.Contains(r.URL.Path, "/HTTPS/Certificates/cert-") { + s.handleDeleteCertificate(w, r) + return + } + filePath := resolvePath(r.URL.Path) base, err := s.loadResource(filePath) if err != nil { @@ -1380,3 +1425,168 @@ func containsAny(s string, substrs []string) bool { return strings.Contains(s, sub) }) } + +func (s *MockServer) handleGenerateCSR(w http.ResponseWriter, _ *http.Request, body []byte) { + var req map[string]any + if err := json.Unmarshal(body, &req); err != nil { + http.Error(w, "Invalid JSON", http.StatusBadRequest) + return + } + + // Extract required fields + commonName, _ := req["CommonName"].(string) + if commonName == "" { + http.Error(w, "CommonName is required", http.StatusBadRequest) + return + } + + // Generate mock CSR + s.mu.Lock() + s.csrCounter++ + csrID := fmt.Sprintf("csr-%d", s.csrCounter) + s.mu.Unlock() + + // Create a mock CSR string (PEM-encoded) + csrString := `-----BEGIN CERTIFICATE REQUEST----- +MIICvDCCAaQCAQAwdzELMAkGA1UEBhMCVVMxEzARBgNVBAgMCkNhbGlmb3JuaWEx +FjAUBgNVBAcMDVNhbiBGcmFuY2lzY28xFDASBgNVBAoMC0V4YW1wbGUgQ29ycDEX +MBUGA1UECwwOSW5mcmFzdHJ1Y3R1cmUwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAw +ggEKAoIBAQC5J3Q== +-----END CERTIFICATE REQUEST-----` + + _ = csrID // Mark as used for potential future logging + + certCollectionURI := "/redfish/v1/Managers/1/NetworkProtocol/HTTPS/Certificates" + + response := map[string]any{ + "CSRString": csrString, + "CertificateCollection": map[string]string{ + "@odata.id": certCollectionURI, + }, + } + + s.log.Info("Generated CSR", "id", csrID, "commonName", commonName) + s.writeJSON(w, http.StatusOK, response) +} + +func (s *MockServer) handleInstallCertificate(w http.ResponseWriter, _ *http.Request, body []byte) { + var req map[string]any + if err := json.Unmarshal(body, &req); err != nil { + http.Error(w, "Invalid JSON", http.StatusBadRequest) + return + } + + certString, ok := req["CertificateString"].(string) + if !ok || certString == "" { + http.Error(w, "CertificateString is required", http.StatusBadRequest) + return + } + + certType, _ := req["CertificateType"].(string) + if certType == "" { + certType = "PEM" + } + + // Store certificate + s.mu.Lock() + certID := fmt.Sprintf("cert-%d", len(s.certificates)+1) + s.certificates[certID] = CertificateData{ + ID: certID, + CertificateString: certString, + CertificateType: certType, + Issuer: "CN=Mock CA", + Subject: "CN=mock-bmc.example.com", + ValidNotBefore: time.Now().Format(time.RFC3339), + ValidNotAfter: time.Now().Add(90 * 24 * time.Hour).Format(time.RFC3339), + SerialNumber: fmt.Sprintf("%d", time.Now().Unix()), + Fingerprint: fmt.Sprintf("sha256-%d", time.Now().Unix()), + } + s.mu.Unlock() + + certURI := fmt.Sprintf("/redfish/v1/Managers/1/NetworkProtocol/HTTPS/Certificates/%s", certID) + w.Header().Set("Location", certURI) + + s.log.Info("Installed certificate", "id", certID, "type", certType) + s.writeJSON(w, http.StatusCreated, map[string]string{ + "@odata.id": certURI, + "Id": certID, + }) +} + +func (s *MockServer) handleGetCertificateCollection(w http.ResponseWriter, _ *http.Request) { + s.mu.RLock() + defer s.mu.RUnlock() + + members := make([]map[string]string, 0, len(s.certificates)) + for certID := range s.certificates { + members = append(members, map[string]string{ + "@odata.id": fmt.Sprintf("/redfish/v1/Managers/1/NetworkProtocol/HTTPS/Certificates/%s", certID), + }) + } + + response := map[string]any{ + "@odata.type": "#CertificateCollection.CertificateCollection", + "@odata.id": "/redfish/v1/Managers/1/NetworkProtocol/HTTPS/Certificates", + "Name": "Certificate Collection", + "Members": members, + "Members@odata.count": len(members), + } + + s.writeJSON(w, http.StatusOK, response) +} + +func (s *MockServer) handleGetCertificate(w http.ResponseWriter, r *http.Request) { + // Extract certificate ID from path + parts := strings.Split(r.URL.Path, "/") + certID := parts[len(parts)-1] + + s.mu.RLock() + cert, exists := s.certificates[certID] + s.mu.RUnlock() + + if !exists { + http.NotFound(w, r) + return + } + + response := map[string]any{ + "@odata.type": "#Certificate.v1_0_0.Certificate", + "@odata.id": r.URL.Path, + "Id": cert.ID, + "Name": "Certificate " + cert.ID, + "CertificateString": cert.CertificateString, + "CertificateType": cert.CertificateType, + "Issuer": map[string]string{ + "CommonName": cert.Issuer, + }, + "Subject": map[string]string{ + "CommonName": cert.Subject, + }, + "ValidNotBefore": cert.ValidNotBefore, + "ValidNotAfter": cert.ValidNotAfter, + "SerialNumber": cert.SerialNumber, + "Fingerprint": cert.Fingerprint, + } + + s.writeJSON(w, http.StatusOK, response) +} + +func (s *MockServer) handleDeleteCertificate(w http.ResponseWriter, r *http.Request) { + // Extract certificate ID from path + parts := strings.Split(r.URL.Path, "/") + certID := parts[len(parts)-1] + + s.mu.Lock() + _, exists := s.certificates[certID] + if !exists { + s.mu.Unlock() + http.NotFound(w, r) + return + } + + delete(s.certificates, certID) + s.mu.Unlock() + + s.log.Info("Deleted certificate", "id", certID) + w.WriteHeader(http.StatusNoContent) +} diff --git a/bmc/redfish.go b/bmc/redfish.go index 5466ca879..5245c5420 100644 --- a/bmc/redfish.go +++ b/bmc/redfish.go @@ -1157,3 +1157,212 @@ func (r *RedfishBaseBMC) DeleteEventSubscription(ctx context.Context, uri string } return nil } + +// GetCertificateService retrieves the certificate service for the BMC. +func (r *RedfishBaseBMC) GetCertificateService(ctx context.Context) (*schemas.CertificateService, error) { + service := r.client.GetService() + certService, err := service.CertificateService() + if err != nil { + return nil, fmt.Errorf("failed to get certificate service: %w", err) + } + return certService, nil +} + +// GenerateCSR generates a Certificate Signing Request on the BMC. +func (r *RedfishBaseBMC) GenerateCSR(ctx context.Context, req CSRRequest) (*CSRResponse, error) { + log := ctrl.LoggerFrom(ctx) + + certService, err := r.GetCertificateService(ctx) + if err != nil { + return nil, err + } + + // Get manager to find certificate collection + managers, err := r.client.GetService().Managers() + if err != nil { + return nil, fmt.Errorf("failed to get managers: %w", err) + } + if len(managers) == 0 { + return nil, fmt.Errorf("no managers found") + } + manager := managers[0] + + certCollectionURI := manager.ODataID + "/NetworkProtocol/HTTPS/Certificates" + + // Set default key algorithm if not specified + keyAlgorithm := req.KeyPairAlgorithm + if keyAlgorithm == "" { + keyAlgorithm = "RSA2048" + } + + // Prepare CSR generation parameters using gofish schemas + params := &schemas.CertificateServiceGenerateCSRParameters{ + CertificateCollection: certCollectionURI, + CommonName: req.CommonName, + Organization: req.Organization, + OrganizationalUnit: req.OrganizationalUnit, + Country: req.Country, + State: req.State, + City: req.City, + Email: req.Email, + KeyPairAlgorithm: keyAlgorithm, + AlternativeNames: req.AlternativeNames, + } + + log.Info("Generating CSR on BMC", "commonName", req.CommonName, "algorithm", keyAlgorithm) + + // Call GenerateCSR using gofish library + response, err := certService.GenerateCSR(params) + if err != nil { + return nil, fmt.Errorf("failed to generate CSR: %w", err) + } + + if response.CSRString == "" { + return nil, fmt.Errorf("CSR generation returned empty CSR string") + } + + log.Info("CSR generated successfully") + + return &CSRResponse{ + CSRString: response.CSRString, + CertificateCollection: certCollectionURI, + }, nil +} + +// InstallCertificate installs a certificate on the BMC. +func (r *RedfishBaseBMC) InstallCertificate(ctx context.Context, certificatePEM string, certificateType CertificateType) error { + log := ctrl.LoggerFrom(ctx) + + // Get manager to find certificate collection + managers, err := r.client.GetService().Managers() + if err != nil { + return fmt.Errorf("failed to get managers: %w", err) + } + if len(managers) == 0 { + return fmt.Errorf("no managers found") + } + manager := managers[0] + + certCollectionURI := manager.ODataID + "/NetworkProtocol/HTTPS/Certificates" + + // Prepare certificate installation payload + payload := map[string]any{ + "CertificateString": certificatePEM, + "CertificateType": string(certificateType), + } + + log.Info("Installing certificate on BMC", "uri", certCollectionURI) + + resp, err := r.client.Post(certCollectionURI, payload) + if err != nil { + return fmt.Errorf("failed to install certificate: %w", err) + } + defer func() { + _ = resp.Body.Close() + }() + + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return fmt.Errorf("certificate installation failed with status: %d", resp.StatusCode) + } + + log.Info("Certificate installed successfully") + return nil +} + +// GetCertificates retrieves all installed certificates on the BMC. +func (r *RedfishBaseBMC) GetCertificates(ctx context.Context) ([]CertificateInfo, error) { + // Get manager to find certificate collection + managers, err := r.client.GetService().Managers() + if err != nil { + return nil, fmt.Errorf("failed to get managers: %w", err) + } + if len(managers) == 0 { + return nil, fmt.Errorf("no managers found") + } + manager := managers[0] + + certCollectionURI := manager.ODataID + "/NetworkProtocol/HTTPS/Certificates" + + resp, err := r.client.Get(certCollectionURI) + if err != nil { + return nil, fmt.Errorf("failed to get certificate collection: %w", err) + } + defer func() { + _ = resp.Body.Close() + }() + + var collection struct { + Members []struct { + ODataID string `json:"@odata.id"` + } `json:"Members"` + } + + if err := json.NewDecoder(resp.Body).Decode(&collection); err != nil { + return nil, fmt.Errorf("failed to decode certificate collection: %w", err) + } + + // Retrieve each certificate + var certificates []CertificateInfo + for _, member := range collection.Members { + certResp, err := r.client.Get(member.ODataID) + if err != nil { + continue // Skip failed certificates + } + + var cert struct { + ODataID string `json:"@odata.id"` + CertificateType string `json:"CertificateType"` + Issuer struct { + CommonName string `json:"CommonName"` + } `json:"Issuer"` + Subject struct { + CommonName string `json:"CommonName"` + } `json:"Subject"` + ValidNotBefore string `json:"ValidNotBefore"` + ValidNotAfter string `json:"ValidNotAfter"` + SerialNumber string `json:"SerialNumber"` + Fingerprint string `json:"Fingerprint"` + } + + if err := json.NewDecoder(certResp.Body).Decode(&cert); err != nil { + _ = certResp.Body.Close() + continue + } + _ = certResp.Body.Close() + + certificates = append(certificates, CertificateInfo{ + URI: cert.ODataID, + Type: CertificateType(cert.CertificateType), + Issuer: cert.Issuer.CommonName, + Subject: cert.Subject.CommonName, + ValidNotBefore: cert.ValidNotBefore, + ValidNotAfter: cert.ValidNotAfter, + SerialNumber: cert.SerialNumber, + Fingerprint: cert.Fingerprint, + }) + } + + return certificates, nil +} + +// DeleteCertificate deletes a certificate from the BMC. +func (r *RedfishBaseBMC) DeleteCertificate(ctx context.Context, certificateURI string) error { + log := ctrl.LoggerFrom(ctx) + + log.Info("Deleting certificate from BMC", "uri", certificateURI) + + resp, err := r.client.Delete(certificateURI) + if err != nil { + return fmt.Errorf("failed to delete certificate: %w", err) + } + defer func() { + _ = resp.Body.Close() + }() + + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return fmt.Errorf("certificate deletion failed with status: %d", resp.StatusCode) + } + + log.Info("Certificate deleted successfully") + return nil +} diff --git a/cmd/main.go b/cmd/main.go index 3773991c0..8802b00b4 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -59,52 +59,61 @@ func init() { func main() { // nolint: gocyclo var ( - metricsAddr string - metricsCertPath string - metricsCertName string - metricsCertKey string - webhookCertPath string - webhookCertName string - webhookCertKey string - enableLeaderElection bool - probeAddr string - secureMetrics bool - enableHTTP2 bool - macPrefixesFile string - insecure bool - protocol string - skipCertValidation bool - managerNamespace string - probeImage string - probeOSImage string - registryPort int - registryProtocol string - registryURL string - eventPort int - eventURL string - eventProtocol string - registryClientTimeout time.Duration - registryDataMaxAge time.Duration - registryResyncInterval time.Duration - webhookPort int - enforceFirstBoot bool - enforcePowerOff bool - discoveryIgnitionPath string - serverResyncInterval time.Duration - maintenanceResyncInterval time.Duration - powerPollingInterval time.Duration - powerPollingTimeout time.Duration - resourcePollingInterval time.Duration - resourcePollingTimeout time.Duration - discoveryTimeout time.Duration - biosSettingsApplyTimeout time.Duration - bmcFailureResetDelay time.Duration - bmcResetResyncInterval time.Duration - bmcResetWaitingInterval time.Duration - serverMaxConcurrentReconciles int - serverClaimMaxConcurrentReconciles int - dnsRecordTemplatePath string - defaultFailedAutoRetryCount int + metricsAddr string + metricsCertPath string + metricsCertName string + metricsCertKey string + webhookCertPath string + webhookCertName string + webhookCertKey string + enableLeaderElection bool + probeAddr string + secureMetrics bool + enableHTTP2 bool + macPrefixesFile string + insecure bool + protocol string + skipCertValidation bool + managerNamespace string + probeImage string + probeOSImage string + registryPort int + registryProtocol string + registryURL string + eventPort int + eventURL string + eventProtocol string + registryClientTimeout time.Duration + registryDataMaxAge time.Duration + registryResyncInterval time.Duration + webhookPort int + enforceFirstBoot bool + enforcePowerOff bool + discoveryIgnitionPath string + serverResyncInterval time.Duration + maintenanceResyncInterval time.Duration + powerPollingInterval time.Duration + powerPollingTimeout time.Duration + resourcePollingInterval time.Duration + resourcePollingTimeout time.Duration + discoveryTimeout time.Duration + biosSettingsApplyTimeout time.Duration + bmcFailureResetDelay time.Duration + bmcResetResyncInterval time.Duration + bmcResetWaitingInterval time.Duration + serverMaxConcurrentReconciles int + serverClaimMaxConcurrentReconciles int + dnsRecordTemplatePath string + defaultFailedAutoRetryCount int + certificateManagementEnabled bool + certificateSignerName string + certificateApprovalMode string + certificateRenewalThreshold time.Duration + certificateSubjectOrganization string + certificateSubjectOrganizationalUnit string + certificateSubjectCountry string + certificateSubjectState string + certificateSubjectLocality string ) flag.IntVar(&serverMaxConcurrentReconciles, "server-max-concurrent-reconciles", 5, @@ -179,6 +188,24 @@ func main() { // nolint: gocyclo "Path to the DNS record template file used for creating DNS records for Servers.") flag.IntVar(&defaultFailedAutoRetryCount, "default-failed-auto-retry-count", 0, "The default number of auto retries for a CRD when it fails. 0 for no retries.") + flag.BoolVar(&certificateManagementEnabled, "certificate-management-enabled", false, + "Enable automatic certificate management for all BMCs by default.") + flag.StringVar(&certificateSignerName, "certificate-signer-name", "metal.ironcore.dev/bmc-https", + "Default signer name for BMC CertificateSigningRequests.") + flag.StringVar(&certificateApprovalMode, "certificate-approval-mode", "external", + "Default CSR approval mode: 'auto' or 'external'. Auto-approval should only be used in trusted environments.") + flag.DurationVar(&certificateRenewalThreshold, "certificate-renewal-threshold", 720*time.Hour, + "Default threshold before certificate expiration to trigger renewal (default: 30 days).") + flag.StringVar(&certificateSubjectOrganization, "certificate-subject-organization", "", + "Default organization for certificate subject.") + flag.StringVar(&certificateSubjectOrganizationalUnit, "certificate-subject-organizational-unit", "", + "Default organizational unit for certificate subject.") + flag.StringVar(&certificateSubjectCountry, "certificate-subject-country", "", + "Default country for certificate subject.") + flag.StringVar(&certificateSubjectState, "certificate-subject-state", "", + "Default state for certificate subject.") + flag.StringVar(&certificateSubjectLocality, "certificate-subject-locality", "", + "Default locality for certificate subject.") opts := zap.Options{ Development: true, @@ -423,6 +450,33 @@ func main() { // nolint: gocyclo setupLog.Error(err, "Failed to create controller", "controller", "BMCSecret") os.Exit(1) } + + var certificateSubject *metalv1alpha1.CertificateSubject + if certificateSubjectOrganization != "" || certificateSubjectOrganizationalUnit != "" || + certificateSubjectCountry != "" || certificateSubjectState != "" || certificateSubjectLocality != "" { + certificateSubject = &metalv1alpha1.CertificateSubject{ + Organization: certificateSubjectOrganization, + OrganizationalUnit: certificateSubjectOrganizationalUnit, + Country: certificateSubjectCountry, + State: certificateSubjectState, + Locality: certificateSubjectLocality, + } + } + + var certApprovalMode metalv1alpha1.CertificateApprovalPolicy + switch certificateApprovalMode { + case "auto": + certApprovalMode = metalv1alpha1.CertificateApprovalPolicyAuto + case "external": + certApprovalMode = metalv1alpha1.CertificateApprovalPolicyExternal + default: + if certificateApprovalMode != "" { + setupLog.Error(fmt.Errorf("invalid certificate-approval-mode: %s", certificateApprovalMode), + "Valid values are 'auto' or 'external'") + os.Exit(1) + } + } + if err = (&controller.BMCReconciler{ Client: mgr.GetClient(), Scheme: mgr.GetScheme(), @@ -438,6 +492,11 @@ func main() { // nolint: gocyclo BMCOptions: bmc.Options{ BasicAuth: true, }, + DefaultCertificateManagementEnabled: certificateManagementEnabled, + DefaultCertificateSignerName: certificateSignerName, + DefaultCertificateApprovalMode: certApprovalMode, + DefaultCertificateRenewalThreshold: certificateRenewalThreshold, + DefaultCertificateSubject: certificateSubject, }).SetupWithManager(mgr); err != nil { setupLog.Error(err, "Failed to create controller", "controller", "BMC") os.Exit(1) diff --git a/config/crd/bases/metal.ironcore.dev_bmcs.yaml b/config/crd/bases/metal.ironcore.dev_bmcs.yaml index 7857c9a4d..885bff69f 100644 --- a/config/crd/bases/metal.ironcore.dev_bmcs.yaml +++ b/config/crd/bases/metal.ironcore.dev_bmcs.yaml @@ -120,6 +120,20 @@ spec: description: BMCUUID is the unique identifier for the BMC as defined in Redfish API. type: string + certificateManagementPolicy: + description: |- + CertificateManagementPolicy controls automatic certificate management for this BMC. + When not specified, the BMC inherits the operator-level default (configured via controller flags). + Set to Manual to explicitly disable certificate management for this specific BMC. + Set to Automatic to explicitly enable certificate management (overriding operator default if disabled). + + Certificate configuration (signer name, approval policy, renewal threshold, subject fields) is + configured at the operator level via controller manager flags and cannot be overridden per-BMC. + This ensures consistent certificate policy across all BMCs in the cluster. + enum: + - Manual + - Automatic + type: string consoleProtocol: description: ConsoleProtocol specifies the protocol to be used for console access to the BMC. @@ -188,6 +202,50 @@ spec: status: description: BMCStatus defines the observed state of BMC. properties: + certificateInfo: + description: CertificateInfo contains information about the BMC's + current certificate. + properties: + issuer: + description: Issuer is the certificate issuer DN. + type: string + notAfter: + description: NotAfter is the certificate validity end time. + format: date-time + type: string + notBefore: + description: NotBefore is the certificate validity start time. + format: date-time + type: string + serialNumber: + description: SerialNumber is the certificate serial number. + type: string + subject: + description: Subject is the certificate subject DN. + type: string + thumbprint: + description: Thumbprint is the SHA-256 thumbprint of the certificate. + type: string + type: object + certificateSecretRef: + description: |- + CertificateSecretRef references the Secret containing the installed certificate. + The Secret is created in the metal-operator controller's namespace. + properties: + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. Instances of this type with an empty value here are + almost certainly wrong. + More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names + type: string + type: object + x-kubernetes-map-type: atomic + certificateSigningRequestRef: + description: CertificateSigningRequestRef references the current CertificateSigningRequest. + type: string conditions: description: Conditions represents the latest available observations of the BMC's current state. diff --git a/config/rbac/role.yaml b/config/rbac/role.yaml index be414d599..51cf7b194 100644 --- a/config/rbac/role.yaml +++ b/config/rbac/role.yaml @@ -36,6 +36,38 @@ rules: - patch - update - watch +- apiGroups: + - certificates.k8s.io + resources: + - certificatesigningrequests + verbs: + - create + - delete + - get + - list + - watch +- apiGroups: + - certificates.k8s.io + resources: + - certificatesigningrequests/approval + verbs: + - update +- apiGroups: + - certificates.k8s.io + resources: + - certificatesigningrequests/status + verbs: + - get + - patch + - update +- apiGroups: + - certificates.k8s.io + resourceNames: + - metal.ironcore.dev/bmc-https + resources: + - signers + verbs: + - approve - apiGroups: - metal.ironcore.dev resources: diff --git a/config/samples/metal_v1alpha1_bmc_with_certificate.yaml b/config/samples/metal_v1alpha1_bmc_with_certificate.yaml new file mode 100644 index 000000000..bdc86a878 --- /dev/null +++ b/config/samples/metal_v1alpha1_bmc_with_certificate.yaml @@ -0,0 +1,74 @@ +--- +# BMC with automatic certificate management (inherits operator defaults) +# Certificate configuration (signer name, approval policy, renewal threshold, subject) +# is configured at the operator level via controller manager flags. +apiVersion: metal.ironcore.dev/v1alpha1 +kind: BMC +metadata: + name: bmc-with-certificate +spec: + endpoint: + ip: 10.0.0.1 + bmcSecretRef: + name: bmc-credentials + protocol: + name: Redfish + port: 443 + scheme: https + # Enable certificate management for this BMC + # All other certificate settings inherited from operator flags + certificateManagementPolicy: Automatic + +--- +# BMC with manual certificate management (opt-out) +# Certificate management is disabled for this specific BMC. +apiVersion: metal.ironcore.dev/v1alpha1 +kind: BMC +metadata: + name: bmc-manual-cert +spec: + endpoint: + ip: 10.0.0.2 + bmcSecretRef: + name: bmc-credentials-2 + protocol: + name: Redfish + port: 443 + scheme: https + # Explicitly disable certificate management + certificateManagementPolicy: Manual + +--- +# BMC without certificate field (uses operator default) +# If operator has --certificate-management-enabled=true, this BMC gets automatic certificates. +# If operator has --certificate-management-enabled=false, this BMC has manual certificates. +apiVersion: metal.ironcore.dev/v1alpha1 +kind: BMC +metadata: + name: bmc-default +spec: + endpoint: + ip: 10.0.0.3 + bmcSecretRef: + name: bmc-credentials-3 + protocol: + name: Redfish + port: 443 + scheme: https + # No certificateManagementPolicy specified - inherits operator default + +--- +# Example operator configuration (set via Deployment args or Helm values): +# +# args: +# - --certificate-management-enabled=true +# - --certificate-signer-name=metal.ironcore.dev/bmc-https +# - --certificate-approval-mode=external +# - --certificate-renewal-threshold=720h +# - --certificate-subject-organization="Example Corp" +# - --certificate-subject-country=US +# +# With these settings: +# - bmc-with-certificate: Gets automatic certificates (explicitly enabled) +# - bmc-manual-cert: No certificates (explicitly disabled) +# - bmc-default: Gets automatic certificates (inherits operator default) diff --git a/dist/chart/templates/crd/metal.ironcore.dev_bmcs.yaml b/dist/chart/templates/crd/metal.ironcore.dev_bmcs.yaml index c328f965f..92048f56c 100755 --- a/dist/chart/templates/crd/metal.ironcore.dev_bmcs.yaml +++ b/dist/chart/templates/crd/metal.ironcore.dev_bmcs.yaml @@ -126,6 +126,20 @@ spec: description: BMCUUID is the unique identifier for the BMC as defined in Redfish API. type: string + certificateManagementPolicy: + description: |- + CertificateManagementPolicy controls automatic certificate management for this BMC. + When not specified, the BMC inherits the operator-level default (configured via controller flags). + Set to Manual to explicitly disable certificate management for this specific BMC. + Set to Automatic to explicitly enable certificate management (overriding operator default if disabled). + + Certificate configuration (signer name, approval policy, renewal threshold, subject fields) is + configured at the operator level via controller manager flags and cannot be overridden per-BMC. + This ensures consistent certificate policy across all BMCs in the cluster. + enum: + - Manual + - Automatic + type: string consoleProtocol: description: ConsoleProtocol specifies the protocol to be used for console access to the BMC. @@ -194,6 +208,50 @@ spec: status: description: BMCStatus defines the observed state of BMC. properties: + certificateInfo: + description: CertificateInfo contains information about the BMC's + current certificate. + properties: + issuer: + description: Issuer is the certificate issuer DN. + type: string + notAfter: + description: NotAfter is the certificate validity end time. + format: date-time + type: string + notBefore: + description: NotBefore is the certificate validity start time. + format: date-time + type: string + serialNumber: + description: SerialNumber is the certificate serial number. + type: string + subject: + description: Subject is the certificate subject DN. + type: string + thumbprint: + description: Thumbprint is the SHA-256 thumbprint of the certificate. + type: string + type: object + certificateSecretRef: + description: |- + CertificateSecretRef references the Secret containing the installed certificate. + The Secret is created in the metal-operator controller's namespace. + properties: + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. Instances of this type with an empty value here are + almost certainly wrong. + More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names + type: string + type: object + x-kubernetes-map-type: atomic + certificateSigningRequestRef: + description: CertificateSigningRequestRef references the current CertificateSigningRequest. + type: string conditions: description: Conditions represents the latest available observations of the BMC's current state. diff --git a/dist/chart/templates/rbac/role.yaml b/dist/chart/templates/rbac/role.yaml index f37becf76..53822c5c3 100755 --- a/dist/chart/templates/rbac/role.yaml +++ b/dist/chart/templates/rbac/role.yaml @@ -39,6 +39,38 @@ rules: - patch - update - watch +- apiGroups: + - certificates.k8s.io + resources: + - certificatesigningrequests + verbs: + - create + - delete + - get + - list + - watch +- apiGroups: + - certificates.k8s.io + resources: + - certificatesigningrequests/approval + verbs: + - update +- apiGroups: + - certificates.k8s.io + resources: + - certificatesigningrequests/status + verbs: + - get + - patch + - update +- apiGroups: + - certificates.k8s.io + resourceNames: + - metal.ironcore.dev/bmc-https + resources: + - signers + verbs: + - approve - apiGroups: - metal.ironcore.dev resources: diff --git a/internal/controller/bmc_controller.go b/internal/controller/bmc_controller.go index bb464d149..1d63d86ae 100644 --- a/internal/controller/bmc_controller.go +++ b/internal/controller/bmc_controller.go @@ -6,16 +6,23 @@ package controller import ( "bytes" "context" + "crypto/ecdsa" + "crypto/rsa" + "crypto/x509" + "encoding/pem" "errors" "fmt" + "slices" "strings" "text/template" "time" + certificatesv1 "k8s.io/api/certificates/v1" apierrors "k8s.io/apimachinery/pkg/api/errors" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" "k8s.io/apimachinery/pkg/types" "k8s.io/apimachinery/pkg/util/yaml" + "k8s.io/utils/ptr" "sigs.k8s.io/controller-runtime/pkg/handler" "github.com/ironcore-dev/controller-utils/clientutils" @@ -38,8 +45,29 @@ import ( const ( BMCFinalizer = "metal.ironcore.dev/bmc" + // Certificate management condition types + bmcCertificateInstalledConditionType = "CertificateInstalled" + bmcCertificateExpiringConditionType = "CertificateExpiring" + + // Certificate management reasons + bmcCertificateInstalledReason = "CertificateInstalled" + bmcCertificateExpiringSoonReason = "CertificateExpiringSoon" + bmcCSRGenerationFailedReason = "CSRGenerationFailed" + bmcCertificateInstallFailedReason = "CertificateInstallFailed" + bmcCSRExpiredReason = "CSRExpired" + bmcCSRDeniedReason = "CSRDenied" + bmcCertificateRequestedReason = "CertificateRequested" + bmcCertificateValidReason = "CertificateValid" + bmcUserResetMessage = "BMC reset initiated by user. Waiting for it to come back online." bmcAutoResetMessage = "BMC reset initiated automatically after repeated connection failures. Waiting for it to come back online." + + // DefaultCertificateRenewalThreshold is the default time before certificate expiration to trigger renewal. + // Set to 30 days (720 hours) which is 1/3 of a typical 90-day certificate lifetime. + DefaultCertificateRenewalThreshold = 720 * time.Hour + + // DefaultCertificateSignerName is the default signer for BMC certificates. + DefaultCertificateSignerName = "metal.ironcore.dev/bmc-https" ) // legacyBMCConditionReasons maps old condition reason strings to their new values. @@ -66,6 +94,13 @@ type BMCReconciler struct { // DNSRecordTemplatePath is the path to the file containing the DNSRecord template. DNSRecordTemplate string Conditions *conditionutils.Accessor + + // Certificate management defaults (applied to BMCs that don't specify these fields) + DefaultCertificateManagementEnabled bool + DefaultCertificateSignerName string + DefaultCertificateApprovalMode metalv1alpha1.CertificateApprovalPolicy + DefaultCertificateRenewalThreshold time.Duration + DefaultCertificateSubject *metalv1alpha1.CertificateSubject } // +kubebuilder:rbac:groups=metal.ironcore.dev,resources=endpoints,verbs=get;list;watch @@ -73,6 +108,11 @@ type BMCReconciler struct { // +kubebuilder:rbac:groups=metal.ironcore.dev,resources=bmcs,verbs=get;list;watch;create;update;patch;delete // +kubebuilder:rbac:groups=metal.ironcore.dev,resources=bmcs/status,verbs=get;update;patch // +kubebuilder:rbac:groups=metal.ironcore.dev,resources=bmcs/finalizers,verbs=update +// +kubebuilder:rbac:groups=certificates.k8s.io,resources=certificatesigningrequests,verbs=get;list;watch;create;delete +// +kubebuilder:rbac:groups=certificates.k8s.io,resources=certificatesigningrequests/status,verbs=get;update;patch +// +kubebuilder:rbac:groups=certificates.k8s.io,resources=certificatesigningrequests/approval,verbs=update +// +kubebuilder:rbac:groups=certificates.k8s.io,resources=signers,resourceNames=metal.ironcore.dev/bmc-https,verbs=approve +// +kubebuilder:rbac:groups="",resources=secrets,verbs=get;list;watch;create;update;patch // Reconcile is part of the main kubernetes reconciliation loop which aims to // move the current state of the cluster closer to the desired state. @@ -203,6 +243,19 @@ func (r *BMCReconciler) reconcile(ctx context.Context, bmcObj *metalv1alpha1.BMC return ctrl.Result{}, err } + // Handle certificate management + // Certificate reconciliation errors should not prevent BMC from reaching Ready state + if err := r.reconcileCertificate(ctx, bmcObj, bmcClient); err != nil { + log.Error(err, "Failed to reconcile certificate") + // Update condition to reflect certificate error, but continue reconciliation + if condErr := r.updateConditions(ctx, bmcObj, true, bmcCertificateInstalledConditionType, + corev1.ConditionFalse, bmcCSRGenerationFailedReason, + fmt.Sprintf("Certificate reconciliation failed: %v", err)); condErr != nil { + log.Error(condErr, "Failed to update certificate condition") + } + } + r.checkCertificateExpiration(ctx, bmcObj) + log.V(1).Info("Reconciled BMC") return ctrl.Result{}, nil } @@ -629,11 +682,648 @@ func (r *BMCReconciler) enqueueBMCByBMCSecret(ctx context.Context, obj client.Ob } } +func (r *BMCReconciler) applyCertificateDefaults(bmcObj *metalv1alpha1.BMC) { + if bmcObj.Spec.CertificateManagementPolicy == nil && r.DefaultCertificateManagementEnabled { + bmcObj.Spec.CertificateManagementPolicy = ptr.To(metalv1alpha1.CertificateManagementPolicyAutomatic) + } +} + +func (r *BMCReconciler) reconcileCertificate(ctx context.Context, bmcObj *metalv1alpha1.BMC, bmcClient bmc.BMC) error { + log := ctrl.LoggerFrom(ctx) + + r.applyCertificateDefaults(bmcObj) + + if bmcObj.Spec.CertificateManagementPolicy == nil || + *bmcObj.Spec.CertificateManagementPolicy != metalv1alpha1.CertificateManagementPolicyAutomatic { + return nil + } + if bmcObj.Status.CertificateSigningRequestRef != nil { + return r.reconcilePendingCSR(ctx, bmcObj, bmcClient) + } + if bmcObj.Status.CertificateSecretRef != nil { + needsRenewal, err := r.needsCertificateRenewal(ctx, bmcObj) + if err != nil { + return err + } + if !needsRenewal { + return nil + } + log.Info("Certificate needs renewal") + } + + return r.initiateCertificateRequest(ctx, bmcObj, bmcClient) +} + +func (r *BMCReconciler) initiateCertificateRequest(ctx context.Context, bmcObj *metalv1alpha1.BMC, bmcClient bmc.BMC) error { + log := ctrl.LoggerFrom(ctx) + log.Info("Initiating certificate request for BMC") + + commonName := bmcObj.Status.IP.String() + if bmcObj.Spec.Hostname != nil && *bmcObj.Spec.Hostname != "" { + commonName = *bmcObj.Spec.Hostname + } + + csrReq := bmc.CSRRequest{ + CommonName: commonName, + KeyPairAlgorithm: "RSA2048", + AlternativeNames: []string{commonName, bmcObj.Status.IP.String()}, + } + + if r.DefaultCertificateSubject != nil { + csrReq.Organization = r.DefaultCertificateSubject.Organization + csrReq.OrganizationalUnit = r.DefaultCertificateSubject.OrganizationalUnit + csrReq.Country = r.DefaultCertificateSubject.Country + csrReq.State = r.DefaultCertificateSubject.State + csrReq.City = r.DefaultCertificateSubject.Locality + } + + log.Info("Generating CSR on BMC", "commonName", commonName) + csrResp, err := bmcClient.GenerateCSR(ctx, csrReq) + if err != nil { + _ = r.updateConditions(ctx, bmcObj, true, bmcCertificateInstalledConditionType, + corev1.ConditionFalse, bmcCSRGenerationFailedReason, + fmt.Sprintf("Failed to generate CSR on BMC: %v", err)) + return err + } + + if err := r.validateBMCCSR(csrResp.CSRString, csrReq); err != nil { + _ = r.updateConditions(ctx, bmcObj, true, bmcCertificateInstalledConditionType, + corev1.ConditionFalse, bmcCSRGenerationFailedReason, + fmt.Sprintf("Invalid CSR from BMC: %v", err)) + return fmt.Errorf("CSR validation failed: %w", err) + } + + signerName := r.DefaultCertificateSignerName + if signerName == "" { + signerName = DefaultCertificateSignerName + } + + // Generate safe CSR name (max 253 chars for Kubernetes metadata.name) + // Format: bmc-- + // Ensure bmcName is truncated if needed to stay under limit + uidStr := string(bmcObj.UID) + shortUID := uidStr[:8] // First 8 chars of UUID + maxBMCNameLen := 253 - 4 - 1 - 8 - 1 // 253 - "bmc-" - "-" - shortUID - null = 239 + bmcName := bmcObj.Name + if len(bmcName) > maxBMCNameLen { + bmcName = bmcName[:maxBMCNameLen] + } + csrName := fmt.Sprintf("bmc-%s-%s", bmcName, shortUID) + + k8sCSR := &certificatesv1.CertificateSigningRequest{ + ObjectMeta: metav1.ObjectMeta{ + Name: csrName, + Labels: map[string]string{ + "metal.ironcore.dev/bmc": bmcObj.Name, + }, + }, + Spec: certificatesv1.CertificateSigningRequestSpec{ + Request: []byte(csrResp.CSRString), + SignerName: signerName, + Usages: []certificatesv1.KeyUsage{ + certificatesv1.UsageDigitalSignature, + certificatesv1.UsageKeyEncipherment, + certificatesv1.UsageServerAuth, + }, + ExpirationSeconds: ptr.To(int32(90 * 24 * 60 * 60)), // 90 days + }, + } + + if err := controllerutil.SetControllerReference(bmcObj, k8sCSR, r.Scheme); err != nil { + return fmt.Errorf("failed to set owner reference on CSR: %w", err) + } + + if err := r.Create(ctx, k8sCSR); err != nil { + if apierrors.IsAlreadyExists(err) { + log.Info("CSR already exists, validating ownership and content", "name", k8sCSR.Name) + + existingCSR := &certificatesv1.CertificateSigningRequest{} + if err := r.Get(ctx, client.ObjectKey{Name: k8sCSR.Name}, existingCSR); err != nil { + return fmt.Errorf("failed to fetch existing CSR: %w", err) + } + + if !metav1.IsControlledBy(existingCSR, bmcObj) { + return fmt.Errorf("CSR %s exists but is owned by a different controller - possible name collision attack", k8sCSR.Name) + } + + if !bytes.Equal(existingCSR.Spec.Request, k8sCSR.Spec.Request) { + log.Info("CSR exists with different content, deleting and recreating", + "name", k8sCSR.Name, + "reason", "CSR content mismatch") + if err := r.Delete(ctx, existingCSR); err != nil { + return fmt.Errorf("failed to delete stale CSR: %w", err) + } + if err := r.Create(ctx, k8sCSR); err != nil { + return fmt.Errorf("failed to recreate CSR after delete: %w", err) + } + } else { + log.V(1).Info("Reusing existing CSR with matching content", "name", k8sCSR.Name) + k8sCSR = existingCSR + } + } else { + return fmt.Errorf("failed to create CSR: %w", err) + } + } + + approvalPolicy := r.DefaultCertificateApprovalMode + if approvalPolicy == "" { + approvalPolicy = metalv1alpha1.CertificateApprovalPolicyExternal + } + + if approvalPolicy == metalv1alpha1.CertificateApprovalPolicyAuto { + log.Info("SECURITY: Auto-approving CSR - ensure this BMC is in a trusted environment", + "bmc", bmcObj.Name, + "bmcIP", bmcObj.Status.IP, + "commonName", commonName, + "warning", "Auto-approval should only be used with verified, trusted BMC hardware") + if err := r.approveCSR(ctx, k8sCSR); err != nil { + return fmt.Errorf("failed to auto-approve CSR: %w", err) + } + } + + bmcBase := bmcObj.DeepCopy() + bmcObj.Status.CertificateSigningRequestRef = &k8sCSR.Name + if err := r.Status().Patch(ctx, bmcObj, client.MergeFrom(bmcBase)); err != nil { + return err + } + + log.Info("CertificateSigningRequest created", "name", k8sCSR.Name) + _ = r.updateConditions(ctx, bmcObj, true, bmcCertificateInstalledConditionType, + corev1.ConditionFalse, bmcCertificateRequestedReason, + "Certificate signing request submitted") + + return nil +} + +// reconcilePendingCSR handles a pending CertificateSigningRequest. +func (r *BMCReconciler) reconcilePendingCSR(ctx context.Context, bmcObj *metalv1alpha1.BMC, bmcClient bmc.BMC) error { + log := ctrl.LoggerFrom(ctx) + + csrName := *bmcObj.Status.CertificateSigningRequestRef + k8sCSR := &certificatesv1.CertificateSigningRequest{} + if err := r.Get(ctx, client.ObjectKey{Name: csrName}, k8sCSR); err != nil { + if apierrors.IsNotFound(err) { + return r.clearCSRReference(ctx, bmcObj) + } + return err + } + + if r.isCertificateDenied(k8sCSR) { + return r.handleDeniedCSR(ctx, bmcObj, k8sCSR, csrName) + } + + if !r.isCertificateApproved(k8sCSR) { + log.V(1).Info("Waiting for CertificateSigningRequest approval", "name", csrName) + return nil + } + + if len(k8sCSR.Status.Certificate) == 0 { + return r.handlePendingSignature(ctx, bmcObj, k8sCSR, csrName) + } + + return r.installCertificate(ctx, bmcObj, k8sCSR, bmcClient) +} + +func (r *BMCReconciler) clearCSRReference(ctx context.Context, bmcObj *metalv1alpha1.BMC) error { + bmcBase := bmcObj.DeepCopy() + bmcObj.Status.CertificateSigningRequestRef = nil + return r.Status().Patch(ctx, bmcObj, client.MergeFrom(bmcBase)) +} + +func (r *BMCReconciler) handleDeniedCSR(ctx context.Context, bmcObj *metalv1alpha1.BMC, k8sCSR *certificatesv1.CertificateSigningRequest, csrName string) error { + log := ctrl.LoggerFrom(ctx) + log.Info("CertificateSigningRequest was denied, will retry", "name", csrName) + + if err := r.Delete(ctx, k8sCSR); err != nil { + log.Error(err, "Failed to delete denied CSR") + } + + if err := r.clearCSRReference(ctx, bmcObj); err != nil { + return err + } + + _ = r.updateConditions(ctx, bmcObj, true, bmcCertificateInstalledConditionType, + corev1.ConditionFalse, bmcCSRDeniedReason, + "CSR was denied, will retry on next reconciliation") + return nil +} + +func (r *BMCReconciler) handlePendingSignature(ctx context.Context, bmcObj *metalv1alpha1.BMC, k8sCSR *certificatesv1.CertificateSigningRequest, csrName string) error { + log := ctrl.LoggerFrom(ctx) + log.V(1).Info("Waiting for CertificateSigningRequest to be signed", "name", csrName) + + if r.isCSRExpired(k8sCSR) { + log.Info("CertificateSigningRequest expired before signing, recreating", "name", csrName) + + if err := r.Delete(ctx, k8sCSR); err != nil { + log.Error(err, "Failed to delete expired CSR") + } + + if err := r.clearCSRReference(ctx, bmcObj); err != nil { + return err + } + + _ = r.updateConditions(ctx, bmcObj, true, bmcCertificateInstalledConditionType, + corev1.ConditionFalse, bmcCSRExpiredReason, + "CSR expired before signing, will recreate") + } + + return nil +} + +func (r *BMCReconciler) installCertificate(ctx context.Context, bmcObj *metalv1alpha1.BMC, k8sCSR *certificatesv1.CertificateSigningRequest, bmcClient bmc.BMC) error { + log := ctrl.LoggerFrom(ctx) + log.Info("Installing certificate on BMC") + + block, _ := pem.Decode(k8sCSR.Status.Certificate) + if block == nil { + return fmt.Errorf("invalid PEM-encoded certificate") + } + cert, err := x509.ParseCertificate(block.Bytes) + if err != nil { + return fmt.Errorf("failed to parse certificate: %w", err) + } + + if err := r.validateCertificateAgainstCSR(k8sCSR.Status.Certificate, k8sCSR.Spec.Request); err != nil { + return fmt.Errorf("certificate validation failed: %w", err) + } + + if time.Now().After(cert.NotAfter) { + return fmt.Errorf("received expired certificate") + } + if time.Now().Before(cert.NotBefore) { + return fmt.Errorf("received certificate not yet valid") + } + + expectedCN := bmcObj.Status.IP.String() + if bmcObj.Spec.Hostname != nil && *bmcObj.Spec.Hostname != "" { + expectedCN = *bmcObj.Spec.Hostname + } + + expectedSANs := map[string]bool{ + bmcObj.Status.IP.String(): true, + } + if bmcObj.Spec.Hostname != nil && *bmcObj.Spec.Hostname != "" { + expectedSANs[*bmcObj.Spec.Hostname] = true + } + + foundValidSAN := false + for _, dnsName := range cert.DNSNames { + if expectedSANs[dnsName] { + foundValidSAN = true + break + } + } + if !foundValidSAN { + for _, ipAddr := range cert.IPAddresses { + if expectedSANs[ipAddr.String()] { + foundValidSAN = true + break + } + } + } + + if !foundValidSAN { + return fmt.Errorf("certificate does not contain any expected SANs: %v (found DNS: %v, IPs: %v)", + expectedSANs, cert.DNSNames, cert.IPAddresses) + } + + if cert.Subject.CommonName != expectedCN { + log.Info("Certificate CN does not match expected value (SANs are valid)", + "expected", expectedCN, "actual", cert.Subject.CommonName) + } + + if !slices.Contains(cert.ExtKeyUsage, x509.ExtKeyUsageServerAuth) { + return fmt.Errorf("certificate missing ServerAuth extended key usage") + } + + // Create/update secret first + secret := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: fmt.Sprintf("bmc-%s-cert", bmcObj.Name), + Namespace: r.ManagerNamespace, + }, + } + + opResult, err := controllerutil.CreateOrUpdate(ctx, r.Client, secret, func() error { + if secret.Labels == nil { + secret.Labels = make(map[string]string) + } + secret.Labels["metal.ironcore.dev/bmc"] = bmcObj.Name + secret.Type = corev1.SecretTypeTLS + secret.Data = map[string][]byte{ + "tls.crt": k8sCSR.Status.Certificate, + } + return controllerutil.SetControllerReference(bmcObj, secret, r.Scheme) + }) + if err != nil { + return fmt.Errorf("failed to create or update certificate secret: %w", err) + } + log.Info("Certificate secret created or updated", "operation", opResult) + + // Persist secret reference to status before calling BMC + bmcBase := bmcObj.DeepCopy() + bmcObj.Status.CertificateSecretRef = &corev1.LocalObjectReference{Name: secret.Name} + bmcObj.Status.CertificateSigningRequestRef = nil + + if err := r.Status().Patch(ctx, bmcObj, client.MergeFrom(bmcBase)); err != nil { + return fmt.Errorf("failed to persist certificate secret reference: %w", err) + } + + // Now install certificate on BMC + // If this fails, on retry we'll have secretRef set and can skip secret creation + if err := bmcClient.InstallCertificate(ctx, string(k8sCSR.Status.Certificate), bmc.CertificateTypeHTTPS); err != nil { + _ = r.updateConditions(ctx, bmcObj, true, bmcCertificateInstalledConditionType, + corev1.ConditionFalse, bmcCertificateInstallFailedReason, + fmt.Sprintf("Failed to install certificate: %v", err)) + return err + } + + if err := r.updateCertificateInfo(ctx, bmcObj, bmcClient); err != nil { + log.Error(err, "Failed to update certificate info") + } + + log.Info("Certificate installed successfully") + _ = r.updateConditions(ctx, bmcObj, true, bmcCertificateInstalledConditionType, + corev1.ConditionTrue, bmcCertificateInstalledReason, + "Certificate installed on BMC") + + return nil +} + +func (r *BMCReconciler) checkCertificateExpiration(ctx context.Context, bmcObj *metalv1alpha1.BMC) { + log := ctrl.LoggerFrom(ctx) + + if bmcObj.Status.CertificateInfo == nil { + return + } + + renewalThreshold := r.DefaultCertificateRenewalThreshold + if renewalThreshold == 0 { + renewalThreshold = DefaultCertificateRenewalThreshold + } + + if bmcObj.Status.CertificateInfo.NotAfter != nil { + expiryTime := bmcObj.Status.CertificateInfo.NotAfter.Time + timeUntilExpiry := time.Until(expiryTime) + + if timeUntilExpiry < renewalThreshold { + log.Info("Certificate expiring soon", "expiresIn", timeUntilExpiry) + _ = r.updateConditions(ctx, bmcObj, true, bmcCertificateExpiringConditionType, + corev1.ConditionTrue, bmcCertificateExpiringSoonReason, + fmt.Sprintf("Certificate expires in %s", timeUntilExpiry)) + } else { + _ = r.updateConditions(ctx, bmcObj, true, bmcCertificateExpiringConditionType, + corev1.ConditionFalse, bmcCertificateValidReason, + fmt.Sprintf("Certificate valid until %s", expiryTime)) + } + } +} + +func (r *BMCReconciler) approveCSR(ctx context.Context, csr *certificatesv1.CertificateSigningRequest) error { + allowedSigners := []string{ + DefaultCertificateSignerName, + } + if r.DefaultCertificateSignerName != "" && r.DefaultCertificateSignerName != DefaultCertificateSignerName { + allowedSigners = append(allowedSigners, r.DefaultCertificateSignerName) + } + + if !slices.Contains(allowedSigners, csr.Spec.SignerName) { + return fmt.Errorf("controller not authorized to approve signer: %s (only allowed: %v)", + csr.Spec.SignerName, allowedSigners) + } + + csr.Status.Conditions = append(csr.Status.Conditions, certificatesv1.CertificateSigningRequestCondition{ + Type: certificatesv1.CertificateApproved, + Status: corev1.ConditionTrue, + Reason: "AutoApproved", + Message: fmt.Sprintf("Auto-approved by BMC controller for BMC %s", csr.Labels["metal.ironcore.dev/bmc"]), + LastUpdateTime: metav1.Now(), + LastTransitionTime: metav1.Now(), + }) + + if err := r.SubResource("approval").Update(ctx, csr); err != nil { + return err + } + + return nil +} + +func (r *BMCReconciler) isCertificateApproved(csr *certificatesv1.CertificateSigningRequest) bool { + for _, condition := range csr.Status.Conditions { + if condition.Type == certificatesv1.CertificateApproved { + return condition.Status == corev1.ConditionTrue + } + if condition.Type == certificatesv1.CertificateDenied { + return false + } + } + return false +} + +func (r *BMCReconciler) isCertificateDenied(csr *certificatesv1.CertificateSigningRequest) bool { + for _, condition := range csr.Status.Conditions { + if condition.Type == certificatesv1.CertificateDenied { + return condition.Status == corev1.ConditionTrue + } + } + return false +} + +func (r *BMCReconciler) isCSRExpired(csr *certificatesv1.CertificateSigningRequest) bool { + // CSR expires based on spec.expirationSeconds after creation + if csr.Spec.ExpirationSeconds == nil { + return false // No expiration set + } + + expirationDuration := time.Duration(*csr.Spec.ExpirationSeconds) * time.Second + expirationTime := csr.CreationTimestamp.Add(expirationDuration) + + return time.Now().After(expirationTime) +} + +func (r *BMCReconciler) needsCertificateRenewal(ctx context.Context, bmcObj *metalv1alpha1.BMC) (bool, error) { + if bmcObj.Status.CertificateSecretRef == nil { + return true, nil + } + secret := &corev1.Secret{} + if err := r.Get(ctx, client.ObjectKey{ + Name: bmcObj.Status.CertificateSecretRef.Name, + Namespace: r.ManagerNamespace, + }, secret); err != nil { + if apierrors.IsNotFound(err) { + return true, nil + } + return false, err + } + + // Parse certificate and check expiration + certPEM := secret.Data["tls.crt"] + block, _ := pem.Decode(certPEM) + if block == nil { + return true, nil + } + + cert, err := x509.ParseCertificate(block.Bytes) + if err != nil { + return true, nil + } + + renewalThreshold := r.DefaultCertificateRenewalThreshold + if renewalThreshold == 0 { + renewalThreshold = DefaultCertificateRenewalThreshold + } + + timeUntilExpiry := time.Until(cert.NotAfter) + return timeUntilExpiry < renewalThreshold, nil +} + +func (r *BMCReconciler) updateCertificateInfo(ctx context.Context, bmcObj *metalv1alpha1.BMC, bmcClient bmc.BMC) error { + certs, err := bmcClient.GetCertificates(ctx) + if err != nil { + return err + } + + // Find HTTPS certificate (determined by URI path, not CertificateType field) + for _, cert := range certs { + if strings.Contains(cert.URI, "/HTTPS/Certificates") { + notBefore, _ := time.Parse(time.RFC3339, cert.ValidNotBefore) + notAfter, _ := time.Parse(time.RFC3339, cert.ValidNotAfter) + + bmcObj.Status.CertificateInfo = &metalv1alpha1.CertificateInfo{ + Issuer: cert.Issuer, + Subject: cert.Subject, + NotBefore: &metav1.Time{Time: notBefore}, + NotAfter: &metav1.Time{Time: notAfter}, + SerialNumber: cert.SerialNumber, + Thumbprint: cert.Fingerprint, + } + break + } + } + + return nil +} + +// validateBMCCSR validates the CSR received from BMC hardware. +func (r *BMCReconciler) validateBMCCSR(csrPEM string, expectedReq bmc.CSRRequest) error { + csrBlock, _ := pem.Decode([]byte(csrPEM)) + if csrBlock == nil { + return fmt.Errorf("invalid PEM format") + } + if csrBlock.Type != "CERTIFICATE REQUEST" { + return fmt.Errorf("invalid PEM type: expected CERTIFICATE REQUEST, got %s", csrBlock.Type) + } + + parsedCSR, err := x509.ParseCertificateRequest(csrBlock.Bytes) + if err != nil { + return fmt.Errorf("failed to parse CSR: %w", err) + } + + if err := parsedCSR.CheckSignature(); err != nil { + return fmt.Errorf("invalid CSR signature: %w", err) + } + + // Validate CommonName matches what we requested + if parsedCSR.Subject.CommonName != expectedReq.CommonName { + return fmt.Errorf("CN mismatch: expected %s, got %s (possible MITM/spoofing attack)", + expectedReq.CommonName, parsedCSR.Subject.CommonName) + } + + // Validate SANs (Subject Alternative Names) + expectedSANs := make(map[string]bool) + for _, san := range expectedReq.AlternativeNames { + expectedSANs[san] = true + } + + // Check DNS SANs + for _, dnsName := range parsedCSR.DNSNames { + if !expectedSANs[dnsName] { + return fmt.Errorf("unexpected DNS SAN in CSR: %s", dnsName) + } + delete(expectedSANs, dnsName) + } + + // Check IP SANs + for _, ipAddr := range parsedCSR.IPAddresses { + ipStr := ipAddr.String() + if !expectedSANs[ipStr] { + return fmt.Errorf("unexpected IP SAN in CSR: %s", ipStr) + } + delete(expectedSANs, ipStr) + } + + // All expected SANs should be present + if len(expectedSANs) > 0 { + missing := []string{} + for san := range expectedSANs { + missing = append(missing, san) + } + return fmt.Errorf("missing expected SANs in CSR: %v", missing) + } + + // Validate key strength + switch pub := parsedCSR.PublicKey.(type) { + case *rsa.PublicKey: + if pub.N.BitLen() < 2048 { + return fmt.Errorf("RSA key too weak: %d bits (minimum 2048)", pub.N.BitLen()) + } + case *ecdsa.PublicKey: + if pub.Curve.Params().BitSize < 256 { + return fmt.Errorf("ECDSA key too weak: %d bits (minimum 256)", pub.Curve.Params().BitSize) + } + default: + return fmt.Errorf("unsupported key type: %T", pub) + } + + return nil +} + +// validateCertificateAgainstCSR validates that a signed certificate matches the original CSR. +func (r *BMCReconciler) validateCertificateAgainstCSR(certPEM []byte, csrPEM []byte) error { + certBlock, _ := pem.Decode(certPEM) + if certBlock == nil { + return fmt.Errorf("invalid certificate PEM") + } + cert, err := x509.ParseCertificate(certBlock.Bytes) + if err != nil { + return fmt.Errorf("failed to parse certificate: %w", err) + } + + csrBlock, _ := pem.Decode(csrPEM) + if csrBlock == nil { + return fmt.Errorf("invalid CSR PEM in K8s CSR object") + } + csr, err := x509.ParseCertificateRequest(csrBlock.Bytes) + if err != nil { + return fmt.Errorf("failed to parse CSR: %w", err) + } + + // Marshal public keys for comparison + certPubKey, err := x509.MarshalPKIXPublicKey(cert.PublicKey) + if err != nil { + return fmt.Errorf("failed to marshal certificate public key: %w", err) + } + csrPubKey, err := x509.MarshalPKIXPublicKey(csr.PublicKey) + if err != nil { + return fmt.Errorf("failed to marshal CSR public key: %w", err) + } + + // Compare public keys + if !bytes.Equal(certPubKey, csrPubKey) { + return fmt.Errorf("certificate public key does not match CSR public key (possible key substitution attack)") + } + + return nil +} + // SetupWithManager sets up the controller with the Manager. func (r *BMCReconciler) SetupWithManager(mgr ctrl.Manager) error { return ctrl.NewControllerManagedBy(mgr). For(&metalv1alpha1.BMC{}). Owns(&metalv1alpha1.Server{}). + Owns(&corev1.Secret{}). + Owns(&certificatesv1.CertificateSigningRequest{}). Watches(&metalv1alpha1.Endpoint{}, handler.EnqueueRequestsFromMapFunc(r.enqueueBMCByEndpoint)). Watches(&metalv1alpha1.BMCSecret{}, handler.EnqueueRequestsFromMapFunc(r.enqueueBMCByBMCSecret)). Complete(r) diff --git a/internal/controller/bmc_controller_test.go b/internal/controller/bmc_controller_test.go index 2c9913ebf..875d5b01e 100644 --- a/internal/controller/bmc_controller_test.go +++ b/internal/controller/bmc_controller_test.go @@ -15,6 +15,7 @@ import ( apierrors "k8s.io/apimachinery/pkg/api/errors" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/utils/ptr" + "sigs.k8s.io/controller-runtime/pkg/client" . "sigs.k8s.io/controller-runtime/pkg/envtest/komega" ) @@ -692,4 +693,127 @@ var _ = Describe("BMC Conditions", func() { Eventually(Get(bmc)).Should(Satisfy(apierrors.IsNotFound)) Eventually(Get(server)).Should(Satisfy(apierrors.IsNotFound)) }) + + Context("Certificate Management", func() { + var ( + bmcObj *metalv1alpha1.BMC + bmcSecret *metalv1alpha1.BMCSecret + ) + + BeforeEach(func(ctx SpecContext) { + By("Creating a BMCSecret") + bmcSecret = &metalv1alpha1.BMCSecret{ + ObjectMeta: metav1.ObjectMeta{ + GenerateName: "test-bmc-secret-", + }, + Data: map[string][]byte{ + metalv1alpha1.BMCSecretUsernameKeyName: []byte("admin"), + metalv1alpha1.BMCSecretPasswordKeyName: []byte("password"), + }, + } + Expect(k8sClient.Create(ctx, bmcSecret)).To(Succeed()) + + By("Creating a BMC with automatic certificate management") + bmcObj = &metalv1alpha1.BMC{ + ObjectMeta: metav1.ObjectMeta{ + GenerateName: "test-bmc-cert-", + }, + Spec: metalv1alpha1.BMCSpec{ + Endpoint: &metalv1alpha1.InlineEndpoint{ + IP: metalv1alpha1.MustParseIP(MockServerIP), + }, + BMCSecretRef: v1.LocalObjectReference{Name: bmcSecret.Name}, + Protocol: metalv1alpha1.Protocol{ + Name: metalv1alpha1.ProtocolNameRedfish, + Port: MockServerPort, + Scheme: metalv1alpha1.HTTPProtocolScheme, + }, + CertificateManagementPolicy: ptr.To(metalv1alpha1.CertificateManagementPolicyAutomatic), + }, + } + Expect(k8sClient.Create(ctx, bmcObj)).To(Succeed()) + DeferCleanup(k8sClient.Delete, bmcObj) + DeferCleanup(k8sClient.Delete, bmcSecret) + }) + + It("should create a CertificateSigningRequest when automatic policy is enabled", func(ctx SpecContext) { + By("Waiting for BMC to be enabled") + Eventually(Object(bmcObj)).Should(SatisfyAll( + HaveField("Status.State", metalv1alpha1.BMCStateEnabled), + )) + + By("Verifying that certificate management was initiated") + // Certificate management might complete quickly, so check for evidence: + // - CSR ref exists (pending), OR + // - Certificate secret exists (completed) + // - Certificate condition is present + Eventually(func(g Gomega) { + g.Expect(k8sClient.Get(ctx, client.ObjectKeyFromObject(bmcObj), bmcObj)).To(Succeed()) + // Either CSR is pending OR certificate is installed + hasCSR := bmcObj.Status.CertificateSigningRequestRef != nil + hasCert := bmcObj.Status.CertificateSecretRef != nil + g.Expect(hasCSR || hasCert).To(BeTrue(), "Expected either CSR ref or certificate secret ref to be set") + }).Should(Succeed()) + }) + + It("should skip certificate management when policy is Manual", func(ctx SpecContext) { + By("Creating a BMC with manual certificate management") + manualBMC := &metalv1alpha1.BMC{ + ObjectMeta: metav1.ObjectMeta{ + GenerateName: "test-bmc-manual-", + }, + Spec: metalv1alpha1.BMCSpec{ + Endpoint: &metalv1alpha1.InlineEndpoint{ + IP: metalv1alpha1.MustParseIP(MockServerIP), + }, + BMCSecretRef: v1.LocalObjectReference{Name: bmcSecret.Name}, + Protocol: metalv1alpha1.Protocol{ + Name: metalv1alpha1.ProtocolNameRedfish, + Port: MockServerPort, + Scheme: metalv1alpha1.HTTPProtocolScheme, + }, + CertificateManagementPolicy: ptr.To(metalv1alpha1.CertificateManagementPolicyManual), + }, + } + Expect(k8sClient.Create(ctx, manualBMC)).To(Succeed()) + DeferCleanup(k8sClient.Delete, manualBMC) + + By("Waiting for BMC to be enabled") + Eventually(Object(manualBMC)).Should(SatisfyAll( + HaveField("Status.State", metalv1alpha1.BMCStateEnabled), + )) + + By("Verifying that no CSR was created") + Consistently(Object(manualBMC)).Should( + HaveField("Status.CertificateSigningRequestRef", BeNil()), + ) + }) + + It("should update certificate info in status", func(ctx SpecContext) { + By("Waiting for BMC to be enabled") + Eventually(Object(bmcObj)).Should(SatisfyAll( + HaveField("Status.State", metalv1alpha1.BMCStateEnabled), + )) + + By("Updating certificate info") + bmcCopy := bmcObj.DeepCopy() + bmcCopy.Status.CertificateInfo = &metalv1alpha1.CertificateInfo{ + Issuer: "CN=Test CA", + Subject: "CN=test-bmc", + NotBefore: &metav1.Time{Time: time.Now()}, + NotAfter: &metav1.Time{Time: time.Now().Add(90 * 24 * time.Hour)}, + SerialNumber: "12345", + Thumbprint: "abcdef", + } + Expect(k8sClient.Status().Update(ctx, bmcCopy)).To(Succeed()) + + By("Verifying certificate info was stored") + Eventually(Object(bmcObj)).Should( + HaveField("Status.CertificateInfo", Not(BeNil())), + ) + Eventually(Object(bmcObj)).Should( + HaveField("Status.CertificateInfo.Issuer", "CN=Test CA"), + ) + }) + }) }) diff --git a/internal/controller/suite_test.go b/internal/controller/suite_test.go index 981c47642..830c851d4 100644 --- a/internal/controller/suite_test.go +++ b/internal/controller/suite_test.go @@ -177,16 +177,20 @@ func SetupTest(redfishMockServers []netip.AddrPort) *corev1.Namespace { Expect(err).NotTo(HaveOccurred()) Expect((&BMCReconciler{ - Client: k8sManager.GetClient(), - Scheme: k8sManager.GetScheme(), - DefaultProtocol: metalv1alpha1.HTTPProtocolScheme, - SkipCertValidation: true, - ManagerNamespace: ns.Name, - BMCResetWaitTime: 400 * time.Millisecond, - BMCClientRetryInterval: 25 * time.Millisecond, - EventURL: "http://localhost:8008", - DNSRecordTemplate: dnsTemplate, - Conditions: accessor, + Client: k8sManager.GetClient(), + Scheme: k8sManager.GetScheme(), + DefaultProtocol: metalv1alpha1.HTTPProtocolScheme, + SkipCertValidation: true, + ManagerNamespace: ns.Name, + BMCResetWaitTime: 400 * time.Millisecond, + BMCClientRetryInterval: 25 * time.Millisecond, + EventURL: "http://localhost:8008", + DNSRecordTemplate: dnsTemplate, + Conditions: accessor, + DefaultCertificateManagementEnabled: true, + DefaultCertificateSignerName: "metal.ironcore.dev/bmc-https", + DefaultCertificateApprovalMode: metalv1alpha1.CertificateApprovalPolicyAuto, + DefaultCertificateRenewalThreshold: 720 * time.Hour, BMCOptions: bmc.Options{ PowerPollingInterval: 50 * time.Millisecond, PowerPollingTimeout: 200 * time.Millisecond,