#!/usr/bin/env pwsh # ----------------------------------------------------------------------------------- # 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 Show-Banner -Text $ErrorRecord.Exception.Message -Type "ERROR" Write-Log -Level "ERROR" -Message "Exception: $($ErrorRecord | Out-String)" -Color "Red" exit 1 } # ---------------------------- # Certificate fingerprint helper (SHA-256) # ---------------------------- function Get-CertFingerprintFromPem { param( [Parameter(Mandatory = $true)] [string]$PemString ) try { # Strip the BEGIN/END lines and blank lines $pemBody = $PemString -replace "`r","" -split "`n" | Where-Object { ($_ -notlike "-----BEGIN*") -and ($_ -notlike "-----END*") -and ($_ -ne "") } $pemBody = ($pemBody -join "") # single base64 string $bytes = [Convert]::FromBase64String($pemBody) $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 Set-PowerCLIConfiguration -InvalidCertificateAction Ignore -Confirm:$false | Out-Null # ---------------------------- # Connect to vCenter # ---------------------------- try { Write-Log -Level "INFO" -Message "Connecting to vCenter at $VCENTERHOST ..." -Color "Cyan" $vCenterConn = Connect-VIServer -Server $VCENTERHOST -User $VCENTERUSER -Password $VCENTERPASS -Force Show-Banner -Text "Connected to vCenter API. Session established." -Type "SUCCESS" } catch { Show-Failure -ErrorRecord $_ } # ---------------------------- # Retrieve VM list (optional) # ---------------------------- try { $vms = Get-VM 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" } # ---------------------------- # 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 # ---------------------------- # ACME / Posh-ACME certificate logic (v4.30-compatible) # ---------------------------- $certSuccess = $false $certPath = $null $keyPath = $null $chainPath = $null # Get most recent Posh-ACME certificate for this domain $existingPACert = Get-PACertificate | Where-Object { $_.MainDomain -eq $VCENTERHOST } | Sort-Object NotAfter -Descending | Select-Object -First 1 $renewCert = $true $skipReason = "" # Rule 1: Skip if cert valid >30 days if ($existingPACert) { $daysLeft = ($existingPACert.NotAfter - (Get-Date)).TotalDays Write-Log -Level "INFO" -Message "Existing 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." } } # Rule 2: LE rate-limit safety (don't request if last issuance < 168h) if ($renewCert -and $existingPACert) { $hoursSinceIssued = ((Get-Date) - $existingPACert.Created).TotalHours if ($hoursSinceIssued -lt 168) { $renewCert = $false $skipReason = "LE rate-limit safety: last cert issued $([math]::Round($hoursSinceIssued)) hours ago (must wait 168h)." } } if (-not $renewCert -and $existingPACert) { Write-Log -Level "INFO" -Message "Skipping ACME issuance: $skipReason" -Color "Yellow" $certPath = $existingPACert.CertificatePath $keyPath = $existingPACert.PrivateKeyPath $chainPath = $existingPACert.ChainPath $certSuccess = $true } else { # We either have no existing cert, it's near expiry, or outside LE safety window Write-Log -Level "INFO" -Message "Requesting new ACME certificate via Posh-ACME..." -Color "Cyan" if ($PDNSAPI -is [string]) { $securePDNSAPI = ConvertTo-SecureString $PDNSAPI -AsPlainText -Force } else { $securePDNSAPI = $PDNSAPI } $pArgs = @{ PowerDNSApiHost = $WDNSHOST PowerDNSApiKey = $securePDNSAPI PowerDNSUseTLS = $true PowerDNSPort = 443 PowerDNSServerName = 'localhost' } try { New-PACertificate -Domain $VCENTERHOST ` -DnsPlugin PowerDNS ` -PluginArgs $pArgs ` -Contact $ACMEEMAIL ` -AcceptTOS ` -DnsSleep 15 ` -Force ` -Verbose $newCert = Get-PACertificate | Where-Object { $_.MainDomain -eq $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 "ACME issuance succeeded but no certificate object found from Get-PACertificate." -Color "Red" $certSuccess = $false } } catch { $errorMessage = $_.Exception.Message Write-Log -Level "ERROR" -Message "ACME request failed: $errorMessage" -Color "Red" $global:helpme = $errorMessage # If LE rate-limit hit, try to fall back to existing cert if ($errorMessage -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 { Show-Banner -Text "No existing certificate available to fall back to!" -Type "ERROR" $certSuccess = $false } } else { $certSuccess = $false } } } # Verify cert files exist if ($certSuccess) { foreach ($f in @($certPath, $keyPath, $chainPath)) { if (-not (Test-Path $f)) { Write-Log -Level "ERROR" -Message "Certificate file missing: $f" -Color "Red" $certSuccess = $false } } } if (-not $certSuccess) { Show-Banner -Text "No usable certificate available. Aborting before vCenter update." -Type "ERROR" exit 1 } # ---------------------------- # vCenter REST: Compare fingerprints and update if needed # ---------------------------- $sessionHeaders = @{ 'vmware-api-session-id' = $vCenterConn.ExtensionData.Content.SessionManager.SessionId } $vcenterCertUri = "https://$VCENTERHOST/rest/vcenter/certificate-management/vcenter/tls" $updateNeeded = $true 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 $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 (fingerprint match)." -Color "Green" $updateNeeded = $false } else { Write-Log -Level "INFO" -Message "vCenter certificate differs from Posh-ACME cert. Update is required." -Color "Yellow" $updateNeeded = $true } } else { Write-Log -Level "WARN" -Message "Could not parse existing vCenter certificate from REST response. Assuming update 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/apply cert if needed # ---------------------------- $restartNeeded = $false if ($updateNeeded) { try { $body = @{ cert = Get-Content -Path $certPath -Raw key = Get-Content -Path $keyPath -Raw chain = Get-Content -Path $chainPath -Raw } 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-Log -Level "INFO" -Message "Applying TLS certificate to vCenter..." -Color "Cyan" Invoke-RestMethod -Uri $uriApply -Method Post -Headers $sessionHeaders -SkipCertificateCheck 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 } } else { Show-Banner -Text "vCenter certificate is already up to date. No upload/apply needed." -Type "SUCCESS" $restartNeeded = $false } # ---------------------------- # vpxd restart via REST (only if cert changed) # ---------------------------- if ($restartNeeded) { $maxRetries = 20 $retryCount = 0 $restartSucceeded = $false 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-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: 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" } # ---------------------------- # Completion message # ---------------------------- Show-Banner -Text "Script completed. Check $LogFile and `$global:helpme for details if needed." -Type "INFO"