diff --git a/inc/vCenter-SSL.ps1 b/inc/vCenter-SSL.ps1 index c86e53a7..01db240a 100644 --- a/inc/vCenter-SSL.ps1 +++ b/inc/vCenter-SSL.ps1 @@ -1,29 +1,106 @@ #!/usr/bin/env pwsh # ----------------------------------------------------------------------------------- -# vCenter + Posh-ACME Script (Fully Automated, Idempotent, Verbose Logging) +# vCenter + Posh-ACME Script +# Fully automated, idempotent, with logging and fingerprint comparison # ----------------------------------------------------------------------------------- . /opt/idssys/nodemgmt/conf/powerwall/settings.ps1 +# ---------------------------- +# Logging setup +# ---------------------------- +$LogFile = "/opt/idssys/nodemgmt/logs/vc-ssl.log" +$logDir = Split-Path -Path $LogFile -Parent +if (-not (Test-Path $logDir)) { + New-Item -Path $logDir -ItemType Directory -Force | Out-Null +} + +function Write-Log { + param( + [string]$Level, + [string]$Message, + [string]$Color = "White" + ) + $ts = Get-Date -Format "yyyy-MM-dd HH:mm:ss" + $line = "[{0}] {1}: {2}" -f $ts, $Level.ToUpper(), $Message + Write-Host $line -ForegroundColor $Color + Add-Content -Path $LogFile -Value $line +} + +function Show-Banner { + param( + [string]$Text, + [string]$Type = "INFO" + ) + switch ($Type.ToUpper()) { + "SUCCESS" { $color = "Green"; $level = "SUCCESS" } + "ERROR" { $color = "Red"; $level = "ERROR" } + "WARN" { $color = "Yellow"; $level = "WARN" } + default { $color = "Cyan"; $level = "INFO" } + } + $line = ("=" * [Math]::Max($Text.Length, 40)) + Write-Host $line -ForegroundColor $color + Write-Host "$level: $Text" -ForegroundColor $color + Write-Host $line -ForegroundColor $color + $ts = Get-Date -Format "yyyy-MM-dd HH:mm:ss" + $log = "[{0}] {1}: {2}" -f $ts, $level, $Text + Add-Content -Path $LogFile -Value $log +} + +# ---------------------------- +# Global variables for troubleshooting +# ---------------------------- $global:helpme = $null $global:responseBody = $null +# ---------------------------- +# Error handler +# ---------------------------- function Show-Failure { param([System.Management.Automation.ErrorRecord]$ErrorRecord) + $global:responseBody = $ErrorRecord.Exception.Message - $global:helpme = $global:responseBody - Write-Host "----------------------------------------" -ForegroundColor Red - Write-Host "Status: A system exception was caught." -ForegroundColor Red - Write-Host $global:responseBody -ForegroundColor Red - Write-Host "The request/response body has been saved to `$global:helpme" -ForegroundColor Red - Write-Host "----------------------------------------" -ForegroundColor Red + $global:helpme = $global:responseBody + + Show-Banner -Text $ErrorRecord.Exception.Message -Type "ERROR" + Write-Log -Level "ERROR" -Message "Exception: $($ErrorRecord | Out-String)" -Color "Red" + exit 1 } # ---------------------------- -# PowerCLI +# Certificate fingerprint helper (SHA-256) +# ---------------------------- +function Get-CertFingerprintFromPem { + param( + [Parameter(Mandatory = $true)] + [string]$PemString + ) + + try { + $pem = $PemString -replace "\r","" -split "`n" | Where-Object { + ($_ -notlike "-----BEGIN*") -and ($_ -notlike "-----END*") -and ($_ -ne "") + } | Out-String + $pem = $pem.Trim() + + $bytes = [Convert]::FromBase64String($pem) + $cert = [System.Security.Cryptography.X509Certificates.X509Certificate2]::new($bytes) + + $sha256 = [System.Security.Cryptography.SHA256]::Create() + $hash = $sha256.ComputeHash($cert.RawData) + ($hash | ForEach-Object { $_.ToString("X2") }) -join "" + } + catch { + Write-Log -Level "WARN" -Message "Failed to compute fingerprint: $($_.Exception.Message)" -Color "Yellow" + return $null + } +} + +# ---------------------------- +# Ensure PowerCLI module # ---------------------------- if (-not (Get-Module -ListAvailable -Name VMware.PowerCLI)) { + Write-Log -Level "INFO" -Message "VMware.PowerCLI not found. Installing..." -Color "Yellow" Install-Module -Name VMware.PowerCLI -Force -Scope AllUsers } Import-Module VMware.PowerCLI -ErrorAction Stop @@ -33,9 +110,9 @@ Set-PowerCLIConfiguration -InvalidCertificateAction Ignore -Confirm:$false | Out # Connect to vCenter # ---------------------------- try { - Write-Host "Connecting to vCenter at $VCENTERHOST ..." -ForegroundColor Cyan + Write-Log -Level "INFO" -Message "Connecting to vCenter at $VCENTERHOST ..." -Color "Cyan" $vCenterConn = Connect-VIServer -Server $VCENTERHOST -User $VCENTERUSER -Password $VCENTERPASS -Force - Write-Host "Connected to vCenter API. Session established." -ForegroundColor Green + Show-Banner -Text "Connected to vCenter API. Session established." -Type "SUCCESS" } catch { Show-Failure -ErrorRecord $_ } @@ -45,33 +122,61 @@ try { # ---------------------------- try { $vms = Get-VM - Write-Host "Retrieved VM list:" -ForegroundColor Cyan - $vms | ForEach-Object { Write-Host " - $($_.Name)" } -} catch { Write-Host "Unable to retrieve VM list, continuing..." -ForegroundColor Yellow } + Write-Log -Level "INFO" -Message "Retrieved VM list ($($vms.Count) VMs)." -Color "Cyan" + $vms | ForEach-Object { Write-Log -Level "INFO" -Message "VM: $($_.Name)" -Color "Gray" } +} catch { + Write-Log -Level "WARN" -Message "Unable to retrieve VM list, continuing..." -Color "Yellow" +} # ---------------------------- -# Posh-ACME +# Ensure Posh-ACME module # ---------------------------- if (-not (Get-Module -ListAvailable -Name Posh-ACME)) { + Write-Log -Level "INFO" -Message "Posh-ACME not found. Installing..." -Color "Yellow" Install-Module -Name Posh-ACME -Force -Scope AllUsers } Import-Module Posh-ACME -ErrorAction Stop # ---------------------------- -# Determine if ACME request is needed +# ACME / PowerDNS certificate request with 30-day + rate-limit handling # ---------------------------- $certSuccess = $false -$existingCert = Get-PAOrder -Domain $VCENTERHOST -ErrorAction SilentlyContinue | Sort-Object Created -Descending | Select-Object -First 1 +$certPath = $null +$keyPath = $null +$chainPath = $null -if ($existingCert -and ($existingCert.Cert.NotAfter -gt (Get-Date).AddDays(30))) { - Write-Host "Existing certificate valid >30 days (expires $($existingCert.Cert.NotAfter)), skipping ACME request." -ForegroundColor Green - $certFolder = $existingCert.CertFolder - $certPath = Join-Path $certFolder "cert.pem" - $keyPath = Join-Path $certFolder "privkey.pem" - $chainPath = Join-Path $certFolder "chain.pem" +# Get latest existing cert (Posh-ACME) +$existingPACert = Get-PACertificate -Domain $VCENTERHOST -ErrorAction SilentlyContinue | + Sort-Object NotAfter -Descending | + Select-Object -First 1 + +$renewCert = $true +$skipReason = "" + +if ($existingPACert) { + $daysLeft = ($existingPACert.NotAfter - (Get-Date)).TotalDays + Write-Log -Level "INFO" -Message "Existing Posh-ACME cert expires $($existingPACert.NotAfter) (~$([math]::Round($daysLeft)) days left)." -Color "Gray" + + if ($daysLeft -gt 30) { + $renewCert = $false + $skipReason = "Existing certificate is valid for more than 30 days." + } +} + +if (-not $renewCert -and $existingPACert) { + Write-Log -Level "INFO" -Message "Skipping ACME request: $skipReason" -Color "Green" + $certPath = $existingPACert.CertificatePath + $keyPath = $existingPACert.PrivateKeyPath + $chainPath = $existingPACert.ChainPath $certSuccess = $true -} else { - if ($PDNSAPI -is [string]) { $securePDNSAPI = ConvertTo-SecureString $PDNSAPI -AsPlainText -Force } else { $securePDNSAPI = $PDNSAPI } +} +else { + # Need or want to renew + if ($PDNSAPI -is [string]) { + $securePDNSAPI = ConvertTo-SecureString $PDNSAPI -AsPlainText -Force + } else { + $securePDNSAPI = $PDNSAPI + } $pArgs = @{ PowerDNSApiHost = $WDNSHOST @@ -82,58 +187,113 @@ if ($existingCert -and ($existingCert.Cert.NotAfter -gt (Get-Date).AddDays(30))) } try { - Write-Host "Requesting certificate via Posh-ACME..." -ForegroundColor Cyan - New-PACertificate -Domain $VCENTERHOST -DnsPlugin PowerDNS -PluginArgs $pArgs -Contact $ACMEEMAIL -AcceptTOS -Verbose -Force -DnsSleep 15 - $paOrder = Get-PAOrder | Sort-Object Created -Descending | Select-Object -First 1 - $certFolder = $paOrder.CertFolder - $certPath = Join-Path $certFolder "cert.pem" - $keyPath = Join-Path $certFolder "privkey.pem" - $chainPath = Join-Path $certFolder "chain.pem" - $certSuccess = $true - } catch { - Write-Host "ACME request failed: $($_.Exception.Message)" -ForegroundColor Yellow - $global:helpme = $_.Exception.Message - } -} + Write-Log -Level "INFO" -Message "Requesting new certificate via Posh-ACME..." -Color "Cyan" + New-PACertificate -Domain $VCENTERHOST -DnsPlugin PowerDNS -PluginArgs $pArgs ` + -Contact $ACMEEMAIL -AcceptTOS -Verbose -Force -DnsSleep 15 -# ---------------------------- -# Validate certificate files -# ---------------------------- -if ($certSuccess) { - foreach ($f in @($certPath, $keyPath, $chainPath)) { - if (-not (Test-Path $f)) { - Write-Host "Certificate file missing: $f" -ForegroundColor Yellow + $newCert = Get-PACertificate -Domain $VCENTERHOST | + Sort-Object NotAfter -Descending | + Select-Object -First 1 + + if ($newCert) { + $certPath = $newCert.CertificatePath + $keyPath = $newCert.PrivateKeyPath + $chainPath = $newCert.ChainPath + $certSuccess = $true + Show-Banner -Text "New ACME certificate successfully created." -Type "SUCCESS" + } else { + Write-Log -Level "ERROR" -Message "New-PACertificate succeeded but Get-PACertificate returned nothing." -Color "Red" + } + } + catch { + $msg = $_.Exception.Message + Write-Log -Level "ERROR" -Message "ACME certificate request failed: $msg" -Color "Red" + $global:helpme = $msg + + # If we hit LE rate limit, fall back to existing cert (if any) + if ($msg -like "*too many certificates*") { + Show-Banner -Text "Let’s Encrypt rate-limit reached. Using existing certificate if available." -Type "WARN" + if ($existingPACert) { + $certPath = $existingPACert.CertificatePath + $keyPath = $existingPACert.PrivateKeyPath + $chainPath = $existingPACert.ChainPath + $certSuccess = $true + } else { + Write-Log -Level "ERROR" -Message "No existing certificate available to fall back to." -Color "Red" + $certSuccess = $false + } + } else { $certSuccess = $false } } } # ---------------------------- -# Check if vCenter already has this cert +# Verify certificate files exist # ---------------------------- -$updateNeeded = $true if ($certSuccess) { - try { - $sessionHeaders = @{ 'vmware-api-session-id' = $vCenterConn.ExtensionData.Content.SessionManager.SessionId } - $vcenterCertUri = "https://$VCENTERHOST/rest/vcenter/certificate-management/vcenter/tls" - $vCenterCert = Invoke-RestMethod -Uri $vcenterCertUri -Method Get -SkipCertificateCheck -Headers $sessionHeaders -ErrorAction Stop - - $currentCertPem = $vCenterCert.certificate | Out-String - $newCertPem = Get-Content -Path $certPath -Raw - - if ($currentCertPem -eq $newCertPem) { - Write-Host "vCenter already has the latest certificate applied. Skipping upload/apply." -ForegroundColor Green - $updateNeeded = $false + foreach ($f in @($certPath, $keyPath, $chainPath)) { + if (-not (Test-Path $f)) { + Write-Log -Level "ERROR" -Message "Certificate file missing: $f" -Color "Red" + $certSuccess = $false } - } catch { - Write-Host "Unable to verify current vCenter certificate, will attempt update." -ForegroundColor Yellow } } +if (-not $certSuccess) { + Show-Banner -Text "No usable certificate available. Aborting before vCenter update." -Type "ERROR" + exit 1 +} + # ---------------------------- -# Upload & apply if needed +# Compare fingerprints with current vCenter cert # ---------------------------- -if ($certSuccess -and $updateNeeded) { +$updateNeeded = $true +$sessionHeaders = @{ + 'vmware-api-session-id' = $vCenterConn.ExtensionData.Content.SessionManager.SessionId +} +$vcenterCertUri = "https://$VCENTERHOST/rest/vcenter/certificate-management/vcenter/tls" + +try { + Write-Log -Level "INFO" -Message "Querying current vCenter TLS certificate..." -Color "Cyan" + $vcResp = Invoke-RestMethod -Uri $vcenterCertUri -Method Get -Headers $sessionHeaders -SkipCertificateCheck -ErrorAction Stop + + # Try to find PEM in common shapes + $currentPem = $null + if ($vcResp.value -and $vcResp.value.cert) { + $currentPem = $vcResp.value.cert + } elseif ($vcResp.certificate) { + $currentPem = $vcResp.certificate + } + + if ($currentPem) { + $currentFp = Get-CertFingerprintFromPem -PemString $currentPem + $newPem = Get-Content -Path $certPath -Raw + $newFp = Get-CertFingerprintFromPem -PemString $newPem + + if ($currentFp -and $newFp -and ($currentFp -eq $newFp)) { + Write-Log -Level "INFO" -Message "vCenter already has the same certificate (SHA-256 fingerprint match)." -Color "Green" + $updateNeeded = $false + } else { + Write-Log -Level "INFO" -Message "vCenter certificate fingerprint differs. Update is required." -Color "Yellow" + $updateNeeded = $true + } + } else { + Write-Log -Level "WARN" -Message "Could not find existing vCenter certificate in REST response. Assuming update is required." -Color "Yellow" + $updateNeeded = $true + } +} +catch { + Write-Log -Level "WARN" -Message "Failed to query current vCenter certificate: $($_.Exception.Message). Proceeding with update." -Color "Yellow" + $updateNeeded = $true +} + +# ---------------------------- +# Upload and apply certificate via REST (only if needed) +# ---------------------------- +$restartNeeded = $false + +if ($updateNeeded) { try { $body = @{ cert = Get-Content -Path $certPath -Raw @@ -141,52 +301,65 @@ if ($certSuccess -and $updateNeeded) { chain = Get-Content -Path $chainPath -Raw } - Write-Host "Uploading TLS certificate to vCenter..." -ForegroundColor Cyan - Invoke-RestMethod -Uri $vcenterCertUri -Method Post -Body ($body | ConvertTo-Json -Compress) -ContentType 'application/json' -Headers $sessionHeaders -SkipCertificateCheck - Write-Host "Certificate uploaded successfully." -ForegroundColor Green + Write-Log -Level "INFO" -Message "Uploading TLS certificate to vCenter..." -Color "Cyan" + Invoke-RestMethod -Uri $vcenterCertUri -Method Post -Body ($body | ConvertTo-Json -Compress) ` + -ContentType 'application/json' -Headers $sessionHeaders -SkipCertificateCheck + Show-Banner -Text "Certificate uploaded to vCenter." -Type "SUCCESS" $uriApply = "https://$VCENTERHOST/rest/vcenter/certificate-management/vcenter/tls?action=apply" - Write-Host "Applying TLS certificate to vCenter..." -ForegroundColor Cyan + Write-Log -Level "INFO" -Message "Applying TLS certificate to vCenter..." -Color "Cyan" Invoke-RestMethod -Uri $uriApply -Method Post -Headers $sessionHeaders -SkipCertificateCheck - Write-Host "Certificate applied successfully." -ForegroundColor Green - } catch { - Write-Host "Certificate upload/apply failed: $($_.Exception.Message)" -ForegroundColor Red + Show-Banner -Text "Certificate applied to vCenter." -Type "SUCCESS" + + $restartNeeded = $true + } + catch { + Write-Log -Level "ERROR" -Message "Certificate upload/apply failed: $($_.Exception.Message)" -Color "Red" $global:helpme = $_.Exception.Message + Show-Banner -Text "Failed to upload/apply certificate to vCenter." -Type "ERROR" + $restartNeeded = $false } -} elseif ($certSuccess -and -not $updateNeeded) { - Write-Host "Certificate already current, no changes applied." -ForegroundColor Green +} else { + Show-Banner -Text "vCenter certificate is already up to date. No upload/apply needed." -Type "SUCCESS" + $restartNeeded = $false } # ---------------------------- -# Automatic vpxd restart via REST +# Automatic vpxd restart via REST (only if we changed the cert) # ---------------------------- -$maxRetries = 20 -$retryCount = 0 -$restartSucceeded = $false +if ($restartNeeded) { + $maxRetries = 20 + $retryCount = 0 + $restartSucceeded = $false -while ($retryCount -lt $maxRetries -and -not $restartSucceeded) { - try { - $healthUri = "https://$VCENTERHOST/rest/appliance/health/system" - Write-Host "Checking vCenter REST health endpoint..." -ForegroundColor Cyan - Invoke-RestMethod -Uri $healthUri -Method Get -SkipCertificateCheck -ErrorAction Stop + while ($retryCount -lt $maxRetries -and -not $restartSucceeded) { + try { + $healthUri = "https://$VCENTERHOST/rest/appliance/health/system" + Write-Log -Level "INFO" -Message "Checking vCenter REST health endpoint..." -Color "Cyan" + Invoke-RestMethod -Uri $healthUri -Method Get -SkipCertificateCheck -ErrorAction Stop - $restartUri = "https://$VCENTERHOST/rest/appliance/system/services/vpxd?action=restart" - Write-Host "Requesting vpxd service restart via REST..." -ForegroundColor Cyan - Invoke-RestMethod -Uri $restartUri -Method Post -SkipCertificateCheck -ErrorAction Stop - Write-Host "vpxd service restart requested successfully." -ForegroundColor Green - $restartSucceeded = $true - } catch { - Write-Host "vpxd REST endpoint not ready, retrying 15 seconds... (Attempt $($retryCount+1)/$maxRetries)" -ForegroundColor Yellow - Start-Sleep -Seconds 15 - $retryCount++ + $restartUri = "https://$VCENTERHOST/rest/appliance/system/services/vpxd?action=restart" + Write-Log -Level "INFO" -Message "Requesting vpxd service restart via REST..." -Color "Cyan" + Invoke-RestMethod -Uri $restartUri -Method Post -SkipCertificateCheck -ErrorAction Stop + Show-Banner -Text "vpxd service restart requested successfully." -Type "SUCCESS" + $restartSucceeded = $true + } + catch { + Write-Log -Level "WARN" -Message "vpxd REST endpoint not ready, retrying in 15 seconds... (Attempt $($retryCount+1)/$maxRetries)" -Color "Yellow" + Start-Sleep -Seconds 15 + $retryCount++ + } } + + if (-not $restartSucceeded) { + Show-Banner -Text "Automatic vpxd restart failed after $maxRetries attempts. Please restart manually via SSH." -Type "ERROR" + Write-Log -Level "ERROR" -Message "Manual restart command: ssh root@$VCENTERHOST 'service-control --stop vpxd; service-control --start vpxd'" -Color "Red" + } +} else { + Write-Log -Level "INFO" -Message "Skipping vpxd restart because no certificate changes were applied." -Color "Green" } -if (-not $restartSucceeded) { - Write-Host "Automatic vpxd restart failed after $maxRetries attempts." -ForegroundColor Red - Write-Host "Please restart manually via SSH:" -ForegroundColor Red - Write-Host "ssh root@$VCENTERHOST 'service-control --stop vpxd; service-control --start vpxd'" -ForegroundColor Red -} - -Write-Host "" -Write-Host "Script completed. Check `$global:helpme for any error details." -ForegroundColor Green +# ---------------------------- +# Completion message +# ---------------------------- +Show-Banner -Text "Script completed. Check $LogFile and `$global:helpme for details if needed." -Type "INFO"