<# .SYNOPSIS Veeam Backup Diagnostic Script — Run via Datto RMM Quick Job .DESCRIPTION Checks Veeam services, backup job status, disk space, event logs, and network connectivity. Returns structured JSON for pipeline consumption. .NOTES Deploy as a Datto RMM component. Output via Write-Host for StdOut capture. Compatible with PowerShell 5.1+. #> try { $ErrorActionPreference = 'SilentlyContinue' $result = @{ timestamp = ([DateTime]::UtcNow.ToString('yyyy-MM-dd HH:mm:ss UTC')) hostname = $env:COMPUTERNAME checks = @{} issues_found = @() recommendations = @() } # ============================================================================ # 1. Veeam Services Status # ============================================================================ $veeamServices = @( 'VeeamBackupSvc', 'VeeamBrokerSvc', 'VeeamCatalogSvc', 'VeeamCloudSvc', 'VeeamDeploySvc', 'VeeamDistributionSvc', 'VeeamMountSvc', 'VeeamNFSSvc', 'VeeamTransportSvc', 'VeeamEndpointBackupSvc', 'VeeamFilesysVssSvc' ) $serviceResults = @() $stoppedCritical = @() foreach ($svcName in $veeamServices) { $svc = Get-Service -Name $svcName -ErrorAction SilentlyContinue if ($svc) { $serviceResults += @{ name = $svc.Name display = $svc.DisplayName status = $svc.Status.ToString() start_type = $svc.StartType.ToString() } if ($svc.Status -ne 'Running' -and $svc.StartType -ne 'Disabled') { $stoppedCritical += $svc.DisplayName } } } $result.checks.services = @{ total_found = $serviceResults.Count services = $serviceResults stopped_critical = $stoppedCritical } if ($stoppedCritical.Count -gt 0) { $result.issues_found += "Veeam services not running: $($stoppedCritical -join ', ')" $result.recommendations += "Restart stopped Veeam services: $($stoppedCritical -join ', ')" } # ============================================================================ # 2. Veeam Backup Job Status (via PowerShell Snap-in if available) # ============================================================================ $jobResults = @() $vbrSnapinLoaded = $false try { if (Get-PSSnapin -Registered -Name VeeamPSSnapin -ErrorAction SilentlyContinue) { Add-PSSnapin VeeamPSSnapin -ErrorAction Stop $vbrSnapinLoaded = $true } elseif (Get-Module -ListAvailable -Name Veeam.Backup.PowerShell -ErrorAction SilentlyContinue) { Import-Module Veeam.Backup.PowerShell -ErrorAction Stop $vbrSnapinLoaded = $true } } catch { # Snap-in not available — skip VBR-specific checks } if ($vbrSnapinLoaded) { try { $jobs = Get-VBRJob -ErrorAction SilentlyContinue foreach ($job in $jobs) { $lastSession = $job.FindLastSession() $jobResults += @{ name = $job.Name type = $job.TypeToString is_enabled = $job.IsScheduleEnabled status = if ($lastSession) { $lastSession.Result.ToString() } else { 'NoSession' } last_run = if ($lastSession) { $lastSession.CreationTime.ToString('yyyy-MM-dd HH:mm:ss') } else { $null } end_time = if ($lastSession) { $lastSession.EndTime.ToString('yyyy-MM-dd HH:mm:ss') } else { $null } duration_min = if ($lastSession -and $lastSession.EndTime -gt $lastSession.CreationTime) { [math]::Round(($lastSession.EndTime - $lastSession.CreationTime).TotalMinutes, 1) } else { $null } failure_msg = if ($lastSession -and $lastSession.Result -eq 'Failed') { ($lastSession.GetTaskSessions() | Where-Object { $_.Status -eq 'Failed' } | Select-Object -First 1 -ExpandProperty Details -ErrorAction SilentlyContinue) } else { $null } } } $failedJobs = $jobResults | Where-Object { $_.status -eq 'Failed' } if ($failedJobs.Count -gt 0) { $result.issues_found += "Failed backup jobs: $(($failedJobs | ForEach-Object { $_.name }) -join ', ')" $result.recommendations += "Investigate failed jobs and check task session logs in Veeam console" } # Check for stuck/running jobs > 24h $stuckJobs = $jobResults | Where-Object { $_.status -eq 'Working' -and $_.last_run -and ((Get-Date) - [datetime]$_.last_run).TotalHours -gt 24 } if ($stuckJobs.Count -gt 0) { $result.issues_found += "Stuck jobs running >24h: $(($stuckJobs | ForEach-Object { $_.name }) -join ', ')" $result.recommendations += "Consider stopping and restarting stuck backup jobs" } } catch { $jobResults = @(@{ error = $_.Exception.Message }) } } $result.checks.backup_jobs = @{ vbr_available = $vbrSnapinLoaded total_jobs = $jobResults.Count jobs = $jobResults } # ============================================================================ # 3. Disk Space Check (all fixed drives) # ============================================================================ $diskResults = @() $lowDiskDrives = @() $drives = Get-WmiObject Win32_LogicalDisk -Filter "DriveType=3" -ErrorAction SilentlyContinue foreach ($drive in $drives) { $freeGB = [math]::Round($drive.FreeSpace / 1GB, 2) $totalGB = [math]::Round($drive.Size / 1GB, 2) $usedPct = if ($totalGB -gt 0) { [math]::Round((($totalGB - $freeGB) / $totalGB) * 100, 1) } else { 0 } $diskResults += @{ drive = $drive.DeviceID label = $drive.VolumeName total_gb = $totalGB free_gb = $freeGB used_pct = $usedPct } if ($usedPct -gt 90) { $lowDiskDrives += "$($drive.DeviceID) ($usedPct% used, $freeGB GB free)" } } $result.checks.disk_space = @{ drives = $diskResults low_disk = $lowDiskDrives } if ($lowDiskDrives.Count -gt 0) { $result.issues_found += "Low disk space: $($lowDiskDrives -join ', ')" $result.recommendations += "Free disk space or expand storage on affected drives" } # ============================================================================ # 4. Windows Event Log — Veeam errors (last 48 hours) # ============================================================================ $eventResults = @() $cutoff = (Get-Date).AddHours(-48) # Veeam Backup log $veeamEvents = Get-WinEvent -FilterHashtable @{ LogName = 'Veeam Backup' Level = @(1, 2) # Critical, Error StartTime = $cutoff } -MaxEvents 20 -ErrorAction SilentlyContinue foreach ($evt in $veeamEvents) { $eventResults += @{ source = 'Veeam Backup' level = $evt.LevelDisplayName id = $evt.Id time = $evt.TimeCreated.ToString('yyyy-MM-dd HH:mm:ss') message = $evt.Message.Substring(0, [Math]::Min(500, $evt.Message.Length)) } } # Veeam Agent log $agentEvents = Get-WinEvent -FilterHashtable @{ LogName = 'Veeam Agent' Level = @(1, 2) StartTime = $cutoff } -MaxEvents 10 -ErrorAction SilentlyContinue foreach ($evt in $agentEvents) { $eventResults += @{ source = 'Veeam Agent' level = $evt.LevelDisplayName id = $evt.Id time = $evt.TimeCreated.ToString('yyyy-MM-dd HH:mm:ss') message = $evt.Message.Substring(0, [Math]::Min(500, $evt.Message.Length)) } } # Application log — Veeam source $appEvents = Get-WinEvent -FilterHashtable @{ LogName = 'Application' ProviderName = @('Veeam*') Level = @(1, 2) StartTime = $cutoff } -MaxEvents 10 -ErrorAction SilentlyContinue foreach ($evt in $appEvents) { $eventResults += @{ source = "Application/$($evt.ProviderName)" level = $evt.LevelDisplayName id = $evt.Id time = $evt.TimeCreated.ToString('yyyy-MM-dd HH:mm:ss') message = $evt.Message.Substring(0, [Math]::Min(500, $evt.Message.Length)) } } $result.checks.event_logs = @{ total_errors = $eventResults.Count events = $eventResults } if ($eventResults.Count -gt 0) { $result.issues_found += "$($eventResults.Count) Veeam error events in last 48h" } # ============================================================================ # 5. Veeam Process Check — is anything stuck? # ============================================================================ $veeamProcesses = Get-Process -Name "Veeam*" -ErrorAction SilentlyContinue | Select-Object Name, Id, CPU, @{N='MemoryMB';E={[math]::Round($_.WorkingSet64/1MB,1)}}, @{N='RunningHours';E={[math]::Round(((Get-Date) - $_.StartTime).TotalHours, 1)}} $stuckProcesses = $veeamProcesses | Where-Object { $_.RunningHours -gt 48 } $result.checks.processes = @{ running = @($veeamProcesses | ForEach-Object { @{ name = $_.Name; pid = $_.Id; memory_mb = $_.MemoryMB; running_hours = $_.RunningHours } }) stuck = @($stuckProcesses | ForEach-Object { $_.Name }) } if ($stuckProcesses.Count -gt 0) { $result.issues_found += "Potentially stuck Veeam processes (>48h): $(($stuckProcesses | ForEach-Object { $_.Name }) -join ', ')" $result.recommendations += "Review and potentially restart long-running Veeam processes" } # ============================================================================ # 6. Network Connectivity to Backup Targets # ============================================================================ $networkResults = @() # Try to find backup repository paths from registry $repoKeys = Get-ItemProperty -Path "HKLM:\SOFTWARE\Veeam\Veeam Backup and Replication" -ErrorAction SilentlyContinue $sqlServer = $repoKeys.SqlServerName if ($sqlServer) { $testSql = Test-NetConnection -ComputerName $sqlServer -Port 1433 -WarningAction SilentlyContinue -ErrorAction SilentlyContinue $networkResults += @{ target = "SQL: $sqlServer" port = 1433 success = $testSql.TcpTestSucceeded } if (-not $testSql.TcpTestSucceeded) { $result.issues_found += "Cannot reach Veeam SQL server: $sqlServer" $result.recommendations += "Check network connectivity and SQL Server service on $sqlServer" } } # Test common backup infrastructure ports $vbrServer = $repoKeys.SqlDatabaseName # Often same host $localPorts = @( @{ Name = "Veeam Backup Service"; Port = 9392 }, @{ Name = "Veeam REST API"; Port = 9419 }, @{ Name = "Veeam Cloud Connect"; Port = 6180 } ) foreach ($p in $localPorts) { $test = Test-NetConnection -ComputerName 'localhost' -Port $p.Port -WarningAction SilentlyContinue -ErrorAction SilentlyContinue $networkResults += @{ target = $p.Name port = $p.Port success = $test.TcpTestSucceeded } } $result.checks.network = @{ tests = $networkResults } # ============================================================================ # 7. Summary # ============================================================================ $result.total_issues = $result.issues_found.Count $result.severity = if ($result.issues_found.Count -eq 0) { 'OK' } elseif ($result.issues_found.Count -le 2) { 'WARNING' } else { 'CRITICAL' } # ============================================================================ # 8. Upload to B2 (S3-compatible) and output object key # ============================================================================ $jsonOutput = $result | ConvertTo-Json -Depth 5 -Compress # B2 credentials — set these as Datto RMM component variables or site variables $b2KeyId = if ($env:B2_KEY_ID) { $env:B2_KEY_ID } else { $env:usrB2KeyId } $b2AppKey = if ($env:B2_APP_KEY) { $env:B2_APP_KEY } else { $env:usrB2AppKey } $b2Bucket = if ($env:B2_BUCKET) { $env:B2_BUCKET } else { if ($env:usrB2Bucket) { $env:usrB2Bucket } else { 'wulf-audits' } } $b2Region = if ($env:B2_REGION) { $env:B2_REGION } else { if ($env:usrB2Region) { $env:usrB2Region } else { 'us-west-002' } } $b2Endpoint = if ($env:B2_ENDPOINT) { $env:B2_ENDPOINT } else { "s3.$b2Region.backblazeb2.com" } $datePrefix = (Get-Date).ToUniversalTime().ToString('yyyy-MM-dd') $timeStamp = (Get-Date).ToUniversalTime().ToString('yyyyMMddTHHmmssZ') $objectKey = "diagnostics/$($env:COMPUTERNAME)/$datePrefix/$timeStamp.json" if ($b2KeyId -and $b2AppKey) { try { # S3v4 presigned PUT $method = 'PUT' $host_ = $b2Endpoint $canonicalUri = "/$b2Bucket/$objectKey" $algorithm = 'AWS4-HMAC-SHA256' $amzDate = $timeStamp $dateStamp = $amzDate.Substring(0, 8) $credScope = "$dateStamp/$b2Region/s3/aws4_request" $contentHash = [System.BitConverter]::ToString( [System.Security.Cryptography.SHA256]::Create().ComputeHash( [System.Text.Encoding]::UTF8.GetBytes($jsonOutput) ) ).Replace('-','').ToLower() $canonicalHeaders = "content-type:application/json`nhost:$host_`nx-amz-content-sha256:$contentHash`nx-amz-date:$amzDate`n" $signedHeaders = 'content-type;host;x-amz-content-sha256;x-amz-date' $canonicalRequest = "$method`n$canonicalUri`n`n$canonicalHeaders`n$signedHeaders`n$contentHash" $crHash = [System.BitConverter]::ToString( [System.Security.Cryptography.SHA256]::Create().ComputeHash( [System.Text.Encoding]::UTF8.GetBytes($canonicalRequest) ) ).Replace('-','').ToLower() $stringToSign = "$algorithm`n$amzDate`n$credScope`n$crHash" # Derive signing key function HmacSHA256($key, $data) { $hmac = New-Object System.Security.Cryptography.HMACSHA256 $hmac.Key = if ($key -is [byte[]]) { $key } else { [System.Text.Encoding]::UTF8.GetBytes($key) } return $hmac.ComputeHash([System.Text.Encoding]::UTF8.GetBytes($data)) } $kDate = HmacSHA256 "AWS4$b2AppKey" $dateStamp $kRegion = HmacSHA256 $kDate $b2Region $kService = HmacSHA256 $kRegion 's3' $kSigning = HmacSHA256 $kService 'aws4_request' $signature = [System.BitConverter]::ToString( (HmacSHA256 $kSigning $stringToSign) ).Replace('-','').ToLower() $authHeader = "$algorithm Credential=$b2KeyId/$credScope, SignedHeaders=$signedHeaders, Signature=$signature" $headers = @{ 'Authorization' = $authHeader 'x-amz-date' = $amzDate 'x-amz-content-sha256' = $contentHash 'Content-Type' = 'application/json' } $uri = "https://$host_$canonicalUri" $bodyBytes = [System.Text.Encoding]::UTF8.GetBytes($jsonOutput) # Use .NET WebRequest for PS 5.1 compatibility $webRequest = [System.Net.HttpWebRequest]::Create($uri) $webRequest.Method = 'PUT' $webRequest.ContentType = 'application/json' $webRequest.ContentLength = $bodyBytes.Length foreach ($h in $headers.GetEnumerator()) { if ($h.Key -notin @('Content-Type')) { $webRequest.Headers.Add($h.Key, $h.Value) } } $stream = $webRequest.GetRequestStream() $stream.Write($bodyBytes, 0, $bodyBytes.Length) $stream.Close() $response = $webRequest.GetResponse() $statusCode = [int]$response.StatusCode $response.Close() if ($statusCode -eq 200) { # Success — output object key for pipeline to fetch Write-Host $objectKey } else { # Upload failed — fall back to inline JSON Write-Host "UPLOAD_FAILED:$statusCode" Write-Host $jsonOutput } } catch { # Upload error — fall back to inline JSON Write-Host "UPLOAD_ERROR:$($_.Exception.Message)" Write-Host $jsonOutput } } else { # No B2 credentials — output JSON directly (fallback) Write-Host $jsonOutput } } catch { # Ensure errors are visible in RMM StdErr/StdOut $errorResult = @{ hostname = $env:COMPUTERNAME error = $_.Exception.Message line = $_.InvocationInfo.ScriptLineNumber severity = 'SCRIPT_ERROR' } | ConvertTo-Json -Compress Write-Host $errorResult exit 1 }