diff --git a/bdr-veeam/Find-VeeamS3OrphanedBackupData.ps1 b/bdr-veeam/Find-VeeamS3OrphanedBackupData.ps1 deleted file mode 100644 index fe174a9..0000000 --- a/bdr-veeam/Find-VeeamS3OrphanedBackupData.ps1 +++ /dev/null @@ -1,448 +0,0 @@ -# Find-VeeamS3OrphanedBackupData.ps1 -# This script finds orphaned backup data in Veeam backup copy jobs or repositories targeting S3 storage - -param ( - [Parameter(Mandatory = $false)] - [string]$VBRServer = "localhost", - - [Parameter(Mandatory = $false)] - [string]$JobName, - - [Parameter(Mandatory = $false)] - [string]$RepositoryName, - - [Parameter(Mandatory = $false)] - [ValidateSet("Jobs", "Repositories", "Both")] - [string]$AnalysisMode = "Both", - - [Parameter(Mandatory = $false)] - [switch]$DetailedReport, - - [Parameter(Mandatory = $false)] - [switch]$ExportCSV, - - [Parameter(Mandatory = $false)] - [string]$CSVPath = ".\VeeamOrphanedBackups_$(Get-Date -Format 'yyyyMMdd_HHmmss').csv" -) - -# Import Veeam Module -try { - Import-Module Veeam.Backup.PowerShell -ErrorAction Stop -} -catch { - Write-Error "Failed to load Veeam PowerShell module. Please ensure Veeam Backup & Replication is installed." - Write-Error "Error: $_" - exit 1 -} - -# Connect to VBR Server -try { - Write-Host "Connecting to Veeam Backup & Replication server ($VBRServer)..." -ForegroundColor Yellow - Connect-VBRServer -Server $VBRServer - Write-Host "Connected successfully." -ForegroundColor Green -} -catch { - Write-Error "Failed to connect to VBR server: $_" - exit 1 -} - -function Get-VeeamS3BackupCopyJobs { - param ( - [string]$JobName - ) - - try { - if ($JobName) { - $backupCopyJobs = Get-VBRBackupCopyJob | Where-Object { - $_.Name -like "*$JobName*" -and - $_.Target.Type -eq "Cloud" - } - } - else { - $backupCopyJobs = Get-VBRBackupCopyJob | Where-Object { - $_.Target.Type -eq "Cloud" - } - } - - return $backupCopyJobs - } - catch { - Write-Error "Failed to retrieve backup copy jobs: $_" - return $null - } -} - -function Get-VeeamS3Repositories { - param ( - [string]$RepositoryName - ) - - try { - if ($RepositoryName) { - $s3Repos = Get-VBRBackupRepository | Where-Object { - $_.Name -like "*$RepositoryName*" -and - ($_.Type -eq "AmazonS3" -or $_.Type -eq "S3Compatible" -or - $_.Type -match "S3") - } - } - else { - $s3Repos = Get-VBRBackupRepository | Where-Object { - $_.Type -eq "AmazonS3" -or $_.Type -eq "S3Compatible" -or - $_.Type -match "S3" - } - } - - return $s3Repos - } - catch { - Write-Error "Failed to retrieve S3 repositories: $_" - return $null - } -} - -function Get-OrphanedBackupData { - param ( - [Parameter(Mandatory = $true)] - [PSObject]$BackupCopyJob - ) - - try { - Write-Host "Analyzing job: $($BackupCopyJob.Name)" -ForegroundColor Cyan - - # Get backup objects from repository - $jobBackups = Get-VBRBackup -Job $BackupCopyJob - - if (-not $jobBackups) { - Write-Host " No backups found for this job" -ForegroundColor Yellow - return $null - } - - $orphanedData = @() - - foreach ($backup in $jobBackups) { - # Get storage data - $storageData = [Veeam.Backup.Core.CBackupSession]::GetRepositoryStorageInfo($backup.Id) - - if (-not $storageData) { - continue - } - - # Get all files from storage - $allBackupFiles = $backup.GetAllStorageFiles() - - # Get files from the active points - $activePoints = $backup.GetPoints() - $activeFiles = @() - - foreach ($point in $activePoints) { - $pointFiles = $point.GetStorageFiles() - $activeFiles += $pointFiles - } - - # Find orphaned files - $orphanedFiles = $allBackupFiles | Where-Object { - $file = $_ - -not ($activeFiles | Where-Object { $_.Name -eq $file.Name }) - } - - if ($orphanedFiles) { - foreach ($file in $orphanedFiles) { - $orphanObject = [PSCustomObject]@{ - JobName = $BackupCopyJob.Name - BackupName = $backup.Name - FileName = $file.Name - FileSize = [Math]::Round($file.Size / 1GB, 2) - FileSizeBytes = $file.Size - Path = $file.Path - CreationTime = $file.CreationTime - Source = "Backup Copy Job" - RepositoryName = $backup.RepositoryName - } - - $orphanedData += $orphanObject - } - } - } - - return $orphanedData - } - catch { - Write-Error "Error analyzing job $($BackupCopyJob.Name): $_" - return $null - } -} - -function Get-OrphanedRepositoryData { - param ( - [Parameter(Mandatory = $true)] - [PSObject]$Repository - ) - - try { - Write-Host "Analyzing repository: $($Repository.Name)" -ForegroundColor Cyan - - # Get all backups in this repository - $repoBackups = Get-VBRBackup | Where-Object { $_.RepositoryId -eq $Repository.Id } - - if (-not $repoBackups -or $repoBackups.Count -eq 0) { - Write-Host " No backups found in this repository" -ForegroundColor Yellow - return $null - } - - $orphanedData = @() - - foreach ($backup in $repoBackups) { - Write-Host " Processing backup: $($backup.Name)" -ForegroundColor DarkGray - - try { - # Get all restore points - $activePoints = $backup.GetPoints() - - if (-not $activePoints -or $activePoints.Count -eq 0) { - Write-Host " No restore points found for this backup" -ForegroundColor DarkGray - continue - } - - $activeFiles = @() - - # For each restore point, get its storage files - foreach ($point in $activePoints) { - try { - $pointFiles = $point.GetStorageFiles() - if ($pointFiles) { - $activeFiles += $pointFiles - } - } - catch { - Write-Host " Error getting files for restore point: $_" -ForegroundColor DarkGray - } - } - - # For S3 repositories, we can't scan the files directly - # Instead, we can use the backup's FindAllStorages method if available - try { - $potentialOrphans = @() - - # Get all files from backup object - Write-Host " Checking for orphaned files in backup metadata..." -ForegroundColor DarkGray - - # Use reflection to see if GetAllStorages method exists - $backupType = $backup.GetType() - $getAllStoragesMethod = $backupType.GetMethod("GetAllStorages") - - if ($getAllStoragesMethod) { - $allStorages = $getAllStoragesMethod.Invoke($backup, $null) - if ($allStorages) { - $potentialOrphans = $allStorages | Where-Object { $_.RepositoryId -eq $Repository.Id } - Write-Host " Found $($potentialOrphans.Count) potential storage items in backup metadata" -ForegroundColor DarkGray - } - } - else { - Write-Host " GetAllStorages method not available on backup object" -ForegroundColor DarkGray - } - - # Compare active files with all potential storages to find orphans - if ($potentialOrphans.Count -gt 0 -and $activeFiles.Count -gt 0) { - $orphanedFiles = $potentialOrphans | Where-Object { - $file = $_ - -not ($activeFiles | Where-Object { $_.Id -eq $file.Id -or $_.Name -eq $file.Name }) - } - - Write-Host " Found $($activeFiles.Count) active files and $($orphanedFiles.Count) potentially orphaned files" -ForegroundColor DarkGray - - if ($orphanedFiles) { - foreach ($file in $orphanedFiles) { - $fileSize = if ($file.Size) { $file.Size } else { 0 } - $orphanObject = [PSCustomObject]@{ - JobName = $backup.JobName - BackupName = $backup.Name - FileName = $file.Name - FileSize = [Math]::Round($fileSize / 1GB, 2) - FileSizeBytes = $fileSize - Path = if ($file.Path) { $file.Path } else { "S3 Storage" } - CreationTime = if ($file.CreationTime) { $file.CreationTime } else { Get-Date } - Source = "Repository" - RepositoryName = $Repository.Name - } - - $orphanedData += $orphanObject - } - } - } - else { - # Alternative approach - try to detect orphans based on point data - Write-Host " Using alternative approach to detect orphaned files..." -ForegroundColor DarkGray - - # Get info from property bag if available - $storageData = $null - - try { - if ($backup.PSObject.Properties['Info'] -and $backup.Info.PSObject.Properties['Storage']) { - $storageData = $backup.Info.Storage - } - } - catch { - Write-Host " Cannot access storage info properties: $_" -ForegroundColor DarkGray - } - - if ($storageData) { - # Analyze storage data - Write-Host " Found storage data in backup info" -ForegroundColor DarkGray - - # Extract any useful information from storage data - # This is very version-dependent, so we're providing basic information - $orphanObject = [PSCustomObject]@{ - JobName = $backup.JobName - BackupName = $backup.Name - FileName = "See Veeam console for details" - FileSize = 0 - FileSizeBytes = 0 - Path = "S3 Storage" - CreationTime = Get-Date - Source = "Repository" - RepositoryName = $Repository.Name - AdditionalInfo = "Unable to directly scan S3 storage. Check Veeam console for detailed information." - } - - $orphanedData += $orphanObject - } - else { - Write-Host " No storage data found for this backup" -ForegroundColor DarkGray - } - } - } - catch { - Write-Host " Error analyzing backup storages: $_" -ForegroundColor DarkGray - - # Provide information about inability to analyze S3 repository - $orphanObject = [PSCustomObject]@{ - JobName = $backup.JobName - BackupName = $backup.Name - FileName = "N/A" - FileSize = 0 - FileSizeBytes = 0 - Path = "S3 Storage" - CreationTime = Get-Date - Source = "Repository" - RepositoryName = $Repository.Name - AdditionalInfo = "Cannot analyze S3 repository directly. Use Veeam console to manually check for orphaned backups." - } - - $orphanedData += $orphanObject - } - } - catch { - Write-Host " Error processing backup $($backup.Name): $_" -ForegroundColor DarkGray - } - } - - return $orphanedData - } - catch { - Write-Error "Error analyzing repository $($Repository.Name): $_" - return $null - } -} - -# Main script execution -$totalOrphanedData = @() -$foundData = $false - -# Analyze backup copy jobs if selected -if ($AnalysisMode -eq "Jobs" -or $AnalysisMode -eq "Both") { - Write-Host "`nAnalyzing S3 backup copy jobs..." -ForegroundColor Cyan - - # Get S3 backup copy jobs - $s3BackupCopyJobs = Get-VeeamS3BackupCopyJobs -JobName $JobName - - if ($s3BackupCopyJobs -and $s3BackupCopyJobs.Count -gt 0) { - Write-Host "Found $($s3BackupCopyJobs.Count) S3 backup copy job(s)" -ForegroundColor Green - $foundData = $true - - # Process each job - foreach ($job in $s3BackupCopyJobs) { - $orphanedData = Get-OrphanedBackupData -BackupCopyJob $job - - if ($orphanedData) { - $totalOrphanedData += $orphanedData - - # Display results for current job - $jobTotal = ($orphanedData | Measure-Object -Property FileSizeBytes -Sum).Sum / 1GB - Write-Host " Found $($orphanedData.Count) orphaned files in job '$($job.Name)' (Total: $([Math]::Round($jobTotal, 2)) GB)" -ForegroundColor Yellow - - if ($DetailedReport) { - $orphanedData | Format-Table -AutoSize JobName, BackupName, FileName, FileSize, CreationTime - } - } - else { - Write-Host " No orphaned data found in job '$($job.Name)'" -ForegroundColor Green - } - } - } - else { - Write-Host "No S3 backup copy jobs found." -ForegroundColor Yellow - } -} - -# Analyze S3 repositories directly if selected -if ($AnalysisMode -eq "Repositories" -or $AnalysisMode -eq "Both") { - Write-Host "`nAnalyzing S3 repositories directly..." -ForegroundColor Cyan - - # Get S3 repositories - $s3Repositories = Get-VeeamS3Repositories -RepositoryName $RepositoryName - - if ($s3Repositories -and $s3Repositories.Count -gt 0) { - Write-Host "Found $($s3Repositories.Count) S3 repositories" -ForegroundColor Green - $foundData = $true - - # Process each repository - foreach ($repo in $s3Repositories) { - $orphanedData = Get-OrphanedRepositoryData -Repository $repo - - if ($orphanedData) { - $totalOrphanedData += $orphanedData - - # Display results for current repository - $repoTotal = ($orphanedData | Measure-Object -Property FileSizeBytes -Sum).Sum / 1GB - Write-Host " Found $($orphanedData.Count) orphaned files in repository '$($repo.Name)' (Total: $([Math]::Round($repoTotal, 2)) GB)" -ForegroundColor Yellow - - if ($DetailedReport) { - $orphanedData | Format-Table -AutoSize RepositoryName, BackupName, FileName, FileSize, CreationTime - } - } - else { - Write-Host " No orphaned data found in repository '$($repo.Name)'" -ForegroundColor Green - } - } - } - else { - Write-Host "No S3 repositories found." -ForegroundColor Yellow - } -} - -if (-not $foundData) { - Write-Host "`nNo S3 backup copy jobs or repositories found. Please check your configuration." -ForegroundColor Yellow - Disconnect-VBRServer - exit -} - -# Summary -if ($totalOrphanedData.Count -gt 0) { - $totalSize = ($totalOrphanedData | Measure-Object -Property FileSizeBytes -Sum).Sum / 1GB - Write-Host "`nSummary:" -ForegroundColor Cyan - Write-Host "Found $($totalOrphanedData.Count) orphaned files across all S3 backup sources" -ForegroundColor Yellow - Write-Host "Total orphaned data size: $([Math]::Round($totalSize, 2)) GB" -ForegroundColor Yellow - - # Export to CSV if requested - if ($ExportCSV) { - $totalOrphanedData | Export-Csv -Path $CSVPath -NoTypeInformation - Write-Host "Results exported to CSV file: $CSVPath" -ForegroundColor Green - } -} -else { - Write-Host "`nNo orphaned backup data found." -ForegroundColor Green -} - -# Disconnect from VBR Server -Disconnect-VBRServer -Write-Host "Script completed successfully." -ForegroundColor Green \ No newline at end of file diff --git a/bdr-veeam/README.md b/bdr-veeam/README.md new file mode 100644 index 0000000..4d5b945 --- /dev/null +++ b/bdr-veeam/README.md @@ -0,0 +1,205 @@ +# BDR Veeam Scripts + +Scripts for managing Veeam Backup & Replication servers deployed by DTC via NinjaRMM. + +## Core Automation Scripts (New) + +These scripts form the automated BDR provisioning and monitoring pipeline. They are designed to run from NinjaRMM with org-level and device-level custom fields. + +### veeam-configure-backblaze-repo.ps1 +Creates a Backblaze B2 bucket and registers it as a Veeam S3-compatible repository. + +**What it does:** +1. Reads org UUID from NinjaRMM org-level field +2. Creates a B2 bucket: `{org_uuid_nodashes}-{time_short_id}-veeam` +3. Enables Object Lock (for immutability) and SSE-B2 encryption +4. Sets lifecycle rule to purge hidden file versions after immutability + 1 day +5. Creates a scoped B2 application key restricted to that bucket only +6. Saves bucket name + scoped key to NinjaRMM device fields (before Veeam step) +7. Registers the Veeam S3 repository with immutability enabled + +**Idempotency:** If bucket name + keys already exist in NinjaRMM, skips B2 creation and only creates the Veeam repo. Safe to re-run after failures. + +**NinjaRMM Fields:** + +| Variable | Level | Type | Purpose | +|----------|-------|------|---------| +| `CUSTOM_FIELD_ORG_UUID` | Org | Text | Organization UUID (read via Ninja-Property-Get) | +| `B2_ADMIN_KEY_ID` | Org | Text | Master B2 key ID (never stored on device) | +| `B2_ADMIN_APP_KEY` | Org | Secure | Master B2 app key | +| `B2_ENDPOINT` | Org | Text | e.g. `https://s3.us-west-002.backblazeb2.com` | +| `B2_REGION` | Org | Text | e.g. `us-west-002` | +| `IMMUTABILITY_DAYS` | Script | Text | Default: 14 | +| `CUSTOM_FIELD_S3_BUCKET_NAME` | Device | Text | Output: bucket name | +| `CUSTOM_FIELD_S3_KEY_ID` | Device | Secure | Output: scoped key ID | +| `CUSTOM_FIELD_S3_APP_KEY` | Device | Secure | Output: scoped app key | + +### veeam-configure-s3-copy-job.ps1 +Creates or updates the S3 backup copy job. Ensures all backup jobs are linked as sources. + +**What it does:** +1. Finds the S3 repository (from NinjaRMM field or auto-detect) +2. Collects all source backup jobs (Get-VBRJob + Get-VBRComputerBackupJob, excludes copy jobs) +3. If a copy job targeting the S3 repo exists: adds missing sources, renames to match pattern +4. If none exists: creates one in Periodic mode +5. Applies schedule, encryption, and enables the job (every run, not just creation) + +**Schedule:** Mon-Fri 10 PM - 5 AM, Sat-Sun all day + +**NinjaRMM Fields:** + +| Variable | Level | Type | Purpose | +|----------|-------|------|---------| +| `CUSTOM_FIELD_S3_BUCKET_NAME` | Device | Text | Used to find the S3 repo | +| `CUSTOM_FIELD_CLOUD_RETENTION` | Org | Text | Retention days (default: 30) | + +### veeam-configure-local-backup-jobs.ps1 +Configures backup windows on all local backup jobs. + +**Schedule:** Mon-Fri 6 AM - 9 PM, Sat-Sun disabled + +This complements the S3 copy job schedule. 1-hour buffer (9 PM - 10 PM) between local backups stopping and S3 copy starting. + +### veeam-inventory.ps1 +Comprehensive inventory and health check. Populates multiple NinjaRMM fields. + +**What it detects:** +- S3 bucket name and storage size (via Veeam backup copy job + child backup storages) +- Orphaned/stale backups across all repos (configurable threshold, default 30 days) +- Failed backup jobs (checks most recent session per job, clears when job succeeds) +- Missing S3 copy job (no S3 repo, no copy job, or missing source jobs) + +**NinjaRMM Fields:** + +| Variable | Type | Purpose | +|----------|------|---------| +| `CUSTOM_FIELD_S3_BUCKET_NAME` | Text | Last used S3 bucket name | +| `CUSTOM_FIELD_S3_BUCKET_SIZE` | Text | Last used S3 bucket size | +| `CUSTOM_FIELD_S3_INVENTORY` | WYSIWYG | HTML table of all S3 repos | +| `CUSTOM_FIELD_ORPHANS_FOUND` | Checkbox | 1 if orphaned backups exist | +| `CUSTOM_FIELD_ORPHANED_BACKUPS` | WYSIWYG | HTML table of orphaned backups | +| `CUSTOM_FIELD_FAILED_BACKUP` | Checkbox | 1 if any job's last run failed | +| `CUSTOM_FIELD_FAILED_BACKUPS` | WYSIWYG | HTML table of failed jobs | +| `CUSTOM_FIELD_S3_COPY_MISSING` | Checkbox | 1 if S3 copy job missing/incomplete | +| `ORPHAN_DAYS_THRESHOLD` | Config | Days before stale (default: 30) | + +### veeam-clear-management-server.ps1 +Removes old Veeam management server registration from endpoints. Run on the endpoint (not the server). + +**Use when:** Migrating endpoints to a new Veeam B&R server and getting "host is managed by another backup server" error. + +## Backblaze B2 Scripts (iaas-backblaze/) + +### b2-bucket-audit.ps1 +Lists all B2 buckets in the account, compares against a known-good list, flags unused buckets in red. + +### b2-fix-lifecycle.ps1 +Applies lifecycle rules to existing B2 buckets to purge hidden file versions. Fixes storage bloat from B2 keeping all versions forever. + +## Technical Reference + +### PS7 Bootstrap +All scripts that use the Veeam PowerShell module include a PS7 bootstrap. Veeam 12.x requires PowerShell 7+. + +```powershell +if ($PSVersionTable.PSVersion.Major -lt 7) { + $PWSH_PATH = "$env:ProgramFiles\PowerShell\7\pwsh.exe" + if (-not (Test-Path $PWSH_PATH)) { + $PWSH_CANDIDATE = Get-Command pwsh.exe -ErrorAction SilentlyContinue + $PWSH_PATH = if ($PWSH_CANDIDATE) { $PWSH_CANDIDATE.Source } else { $null } + } + # ... +} +``` + +**Critical:** Always prefer 64-bit PS7 (`$env:ProgramFiles`, not `$env:ProgramFiles(x86)`). Veeam's native SQLite DLL is x64 only. Using 32-bit PS7 causes `SqliteConnection` type initializer failures. + +### PS7.4+ SQLite Conflict +PowerShell 7.4+ ships `Microsoft.Data.Sqlite` that conflicts with Veeam's bundled version. The fix has two parts: + +1. **Add Veeam's native DLL path to PATH** so `e_sqlite3.dll` is found: +```powershell +$VEEAM_RUNTIMES = "$env:ProgramFiles\Veeam\Backup and Replication\Backup\runtimes\win-x64\native" +$env:PATH = "$VEEAM_RUNTIMES;$env:PATH" +``` + +2. **Register an AssemblyResolve handler** to redirect managed DLL loads to Veeam's versions: +```powershell +$null = [System.AppDomain]::CurrentDomain.add_AssemblyResolve({ + param($sender, $args) + if (-not $args.Name) { return $null } # Null check required + $ASSEMBLY_NAME = [System.Reflection.AssemblyName]::new($args.Name) + $VEEAM_DLL = Join-Path $VEEAM_BACKUP_DIR "$($ASSEMBLY_NAME.Name).dll" + if (Test-Path $VEEAM_DLL) { return [System.Reflection.Assembly]::LoadFrom($VEEAM_DLL) } + return $null +}) +``` + +**Important:** The null check on `$args.Name` is required. .NET passes empty assembly names which crash the handler without it. + +### Veeam PowerShell Gotchas (Veeam 12.x) + +**Non-interactive execution:** +- Do NOT use `-NonInteractive` in the PS7 bootstrap. Veeam cmdlets use `Read-Host` internally for certificate trust prompts, which hard-fails in NonInteractive mode (hangs instead of erroring). +- Set `$ConfirmPreference = 'None'` before Veeam cmdlet calls. +- `-Confirm:$false` is NOT a valid parameter on most Veeam cmdlets (they don't implement `ShouldProcess`). + +**Add-VBRAmazonS3CompatibleRepository:** +- `-EnableBucketAutoProvision:$false` is required. Default changed to `$true` in Veeam 12.3.1 and hangs on non-AWS S3 endpoints. +- `-EnableBackupImmutability` is required when the B2 bucket has Object Lock enabled. Without it: "Enable backup immutability because the S3 Object Lock feature is enabled." +- Do NOT set bucket-level `defaultRetention` via B2 API. Veeam manages immutability per-object. Bucket default causes "default retention is not supported" errors. + +**Add-VBRBackupCopyJob:** +- `-Mode` is a required parameter (values: `Periodic`, `Immediate`). Script hangs if not provided. +- Periodic mode: uses `ScheduleOptions`, does NOT support `Anytime` or `BackupWindowOptions`. +- Immediate mode: supports `Anytime` and `BackupWindowOptions`. +- Job name max length: 50 characters. + +**New-VBRBackupCopyJobStorageOptions:** +- `CompressionLevel` and `StorageOptimizationType` are MANDATORY. Hangs if not provided. +- CompressionLevel values: `Auto`, `None`, `DedupeFriendly`, `Optimal`, `High`, `Extreme` +- StorageOptimizationType values: `Automatic`, `LocalTarget`, `LocalTargetHugeBackup`, `LANTarget`, `WANTarget` +- Use `Automatic` for copy jobs (not `LocalTarget` which errors on "image-level backup copy job"). + +**Get-VBRObjectStorageRepository:** +- Returns skeleton objects with NULL sub-properties (ArchiveRepository, AmazonCompatibleOptions, Options, Info all null). +- To get bucket name: use `Get-VBRBackupCopyJob` -> `.TargetRepository.AmazonCompatibleOptions.BucketName` instead. + +**GetObjects() on agent backup jobs:** +- Returns 0 for Windows Agent Backup/Policy types. Only works for VM backups. +- For agent backups, use child backup names or time-based detection instead. + +**Backup size calculation:** +- Parent backups with `IsTruePerVmContainer = true` have 0 storages. +- Must call `FindChildBackups()` and sum `GetAllStorages()` -> `Stats.BackupSize` across children. + +**RetentionType enum:** +- Values are `RestoreDays` and `RestorePoints` (not `Days` or `Cycles`). + +### Backblaze B2 API Reference + +**Authentication:** +- Use `-Authentication Basic -Credential $PSCredential` (not manual header construction). B2 auth tokens contain `=` characters that `Invoke-RestMethod` header validation rejects. +- For post-auth API calls, use `-SkipHeaderValidation $true` in the headers. +- API v2 is the most reliable. v4 works but v3 does not exist. + +**Bucket creation:** +- `fileLockEnabled: true` enables Object Lock (required for Veeam immutability). Cannot be added after creation. +- Object Lock requires versioning (automatic). Cannot disable versioning on Object Lock buckets. +- Set lifecycle rule `daysFromHidingToDeleting` to purge old hidden versions. Without this, B2 keeps ALL versions forever (causes massive storage bloat, e.g. 11 TB active = 50 TB in B2). + +**Scoped application keys:** +- Must include `listAllBucketNames` capability for bucket-restricted keys. Without it, Veeam gets "Invalid credentials" because S3 ListBuckets fails. +- Full capability list for Veeam: `listBuckets`, `listAllBucketNames`, `readBuckets`, `listFiles`, `readFiles`, `writeFiles`, `deleteFiles`, `readBucketEncryption`, `writeBucketEncryption`, `readBucketRetentions`, `writeBucketRetentions`, `readFileRetentions`, `writeFileRetentions`, `readFileLegalHolds`, `writeFileLegalHolds`, `bypassGovernance` + +**Daily usage reports:** +- No real-time bucket size API exists. +- B2 generates daily audit CSVs in `b2-reports-{accountId}` bucket with `storageByteCount` per bucket. + +### NinjaRMM Integration + +**Org-level fields:** Read with `Ninja-Property-Get $env:FIELD_NAME`. The env var contains the field API name (e.g. `dtcOrgGuid`), not the value. + +**Device-level fields:** Write with `Ninja-Property-Set $fieldName $value`. Read with `Ninja-Property-Get $fieldName`. + +**Important:** `Ninja-Property-Get -Organization` does NOT work from scripts. Org-level fields must be configured to inherit to device level in NinjaRMM, then read normally. diff --git a/bdr-veeam/veeam-add-backup-copy-job.ps1 b/bdr-veeam/veeam-add-backup-copy-job.ps1 deleted file mode 100644 index 899c10b..0000000 --- a/bdr-veeam/veeam-add-backup-copy-job.ps1 +++ /dev/null @@ -1,112 +0,0 @@ -# This script creates a backup copy job. So far this only supports creating only 1 job, and only 1 so it will exit if 1 exists. -# Only supports S3 Compatible Storage - -# Make sure PSModulePath includes Veeam Console -Write-Host "Installing Veeam PowerShell Module if not installed already." -$MyModulePath = "C:\Program Files\Veeam\Backup and Replication\Console\" -$env:PSModulePath = $env:PSModulePath + "$([System.IO.Path]::PathSeparator)$MyModulePath" -if ($Modules = Get-Module -ListAvailable -Name Veeam.Backup.PowerShell) { - try { - $Modules | Import-Module -WarningAction SilentlyContinue - } - catch { - throw "Failed to load Veeam Modules" - } - } - -# Get timestamp -Write-Host "Getting timestamp." -$timeStamp = [int](Get-Date -UFormat %s -Millisecond 0) - -# Getting input from user if not running from RMM else set variables from RMM. -if ($rmm -ne 1) { - $validInput = 0 - # Only running if S3 Copy Job is true for this part. - while ($validInput -ne 1) { - $targetRepoType = Read-Host "Enter the backup copy target repo type (S3 1, WinLocal 2)" - if ($targetRepoType -eq 2) { - $targetRepository = Read-Host "Enter the target WinLocal repo name" - $validInput = 1 - - } elseif ($targetRepoType -eq 1) { - $validInput = 1 - - } else { - Write-Host "Invalid input. Please enter 1 or 2." - $validInput = 0 - - } - - - } - $logPath = "$env:WINDIR\logs\veeam-add-backup-repo.log" - $description = Read-Host "Please enter the ticket # and your initials. Its used as the description for the job" - - -} else { - # ticketNumber from RMM is set to the description. - # targetWinLocalRepository is the targetRepository if targetRepoType is 2. - # targetRepoType is targetRepoType. - # RMMScript path is set as a - $logPath = "$rmmScriptPath\logs\veeam-add-backup-repo.log" - - -} - - -Start-Transcript -Path $logPath - -if ($targetRepoType -eq 1) { - # Find existing backup copy jobs to S3 storage. This only supports S3 Compatible and not Amazon S3, Google, or Azure. - # We'll exit if one already exists. - $backupCopyTarget = Get-VBRBackupCopyJob | Select -Expand Target - $s3CompCopyJob = $backupCopyTarget | ForEach-Object {Get-VBRBackupRepository -Name $_ | Where -Property Type -eq AmazonS3Compatible} - - if ($s3CompCopyJob) { - Write-Host "Existing S3 Copy Job exists. Exiting." - Exit - } - - $targetRepository = Get-VBRBackupRepository |Where -Property Type -eq AmazonS3Compatible - - -} elseif ($targetRepoType -eq 2) { - Write-Host "This script doesn't support WinLocal backup copy jobs yet." - Exit - -} else { - Write-Host "Target repo type doesn't contain valid input. Exiting" - Exit -} - -$encryptionKey = Get-VBREncryptionKey | Sort ModificationDate -Descending | Select -First 1 -$daily = New-VBRDailyOptions -DayofWeek Monday,Tuesday,Wednesday,Thursday,Friday,Saturday,Sunday -Period 20:00 -$schedule = New-VBRScheduleOptions -Type Daily -DailyOptions $daily -$storageOptions = New-VBRBackupCopyJobStorageOptions -EnableEncryption -EncryptionKey $encryptionKey -CompressionLevel Auto -EnableDataDeduplication -StorageOptimizationType Automatic -$backupJobs = Get-VBRJob | Where -Property TypeToString -ne "Backup Copy" -# $schedule = New-VBRServerScheduleOptions -Type Periodically -PeriodicallyOptions $scheduleOption -EnableRetry -RetryCount 3 -RetryTimeout 30 -EnableBackupTerminationWindow -TerminationWindow $windowOption - -Write-Host "The varialbes are now set." -Write-Host "Repository target: $targetRepository" -Write-Host "Encryption key: $encryptionKey" -Write-Host "Window option: $windowOption" -Write-Host "Schedule option: $scheudleOption" -Write-Host "Storage options: $storageOptions" -Write-Host "Backup jobs: $backupJobs" -Write-Host "Schedule: $schedule" - -# Add Backup copy job for all jobs. -Write-Host "Adding backup copy job for all backup jobs." -$backupCopyJob = Add-VBRBackupCopyJob -BackupJob $backupJobs -ScheduleOptions $schedule -Description "$description" -Mode periodic -Name "S3 Copy $timestamp" -ProcessLatestAvailablePoint -RetentionNumber 30 -RetentionType RestoreDays -StorageOptions $storageOptions -TargetRepository $targetRepository -DirectOperation - -$backupCopyJob - -Write-Host "Upgrading all bakup job then re-enabling" - -# Upgrade VBR Backup Chain to True Per VM -Get-VBRBackup |Where -Property TypeToString -eq "Hyper-V Backup" | Upgrade-VBRBackup - -# Enable all backup jobs -Get-VBRJob | Where -Property TypeToString -eq "Hyper-V Backup" | Enable-VBRJob - -Stop-Transcript diff --git a/bdr-veeam/veeam-add-backup-repo.ps1 b/bdr-veeam/veeam-add-backup-repo.ps1 deleted file mode 100644 index 55edab8..0000000 --- a/bdr-veeam/veeam-add-backup-repo.ps1 +++ /dev/null @@ -1,150 +0,0 @@ -## Please note this script can only support the following backup repository types ## -# S3 Compabitble & Local. Both are forced. - -Start-Transcript -Path $env:WINDIR\logs\veeam-add-backup-repo.log - -Write-Host "Checking if we are running from a RMM or not." - -# if ($rmm -ne 1) { - # Set the repository details - # $repositoryType = "Please enter the repository type (1 S3 Compatible; 2 Windows Local; 3 Both)" - # $moveBackups = "Enter 1 if you wish to move your local backups" - # $description = Read-Host "Please enter the ticket # or project ticket # related to this configuration" - # $immutabilityPeriod = Read-Host "Enter how many days every object is immutable for" - # $repositoryName = Read-Host "Enter the repository name" | Out-String - # $accessKey = Read-Host "Enter the access key" - # $secretKey = Read-Host "Enter the secret key" - # $endpoint = Read-Host "Enter the S3 endpoint url" - # $regionId = Read-Host "Enter the region ID" - # $bucketName = Read-Host "Enter the bucket name" - -# } - -Write-Host "The varialbes are now set." -Write-Host "Repository type: $repositoryType" -Write-Host "Move backups: $moveBackups" -Write-Host "Description: $description" -Write-Host "Immutability period: $immutabilityPeriod" -Write-Host "Access key: $accessKey" -Write-Host "Secret key: **redacted for sensativity**" -Write-Host "S3 Endpoint: $endpoint" -Write-Host "S3 Region ID: $regionId" -Write-Host "S3 Bucket Name: $bucketName" -Write-Host "Automatic volume letter to create WinLocal repo: " -Write-Host "Drive letters to create WinLocal Repos: $driveLetters" - - -# Make sure PSModulePath includes Veeam Console -Write-Host "Installing Veeam PowerShell Module if not installed already." -$MyModulePath = "C:\Program Files\Veeam\Backup and Replication\Console\" -$env:PSModulePath = $env:PSModulePath + "$([System.IO.Path]::PathSeparator)$MyModulePath" -if ($Modules = Get-Module -ListAvailable -Name Veeam.Backup.PowerShell) { - try { - $Modules | Import-Module -WarningAction SilentlyContinue - } - catch { - throw "Failed to load Veeam Modules" - } - } - -# Set Timestamp -# Write-Host "Getting timestamp for repository names." -# $timeStamp = [int](Get-Date -UFormat %s -Millisecond 0) -# REMOVED TO MAKE FOLDERNAME LOCATION GUID $folderName = $timeStamp - - -if ($repositoryType -eq 1 -Or $repositoryType -eq 3) { - Write-Host "Creating S3 Repository: S3 $FolderName" - - # Add the S3 Account - $account = Add-VBRAmazonAccount -AccessKey $accessKey -SecretKey $secretKey -Description "$description $bucketName" - - # Create the S3 repository - $connect = Connect-VBRAmazonS3CompatibleService -Account $account -CustomRegionId $regionId -ServicePoint $endpoint - $bucket = Get-VBRAmazonS3Bucket -Connection $connect -Name $bucketName - $folder = New-VBRAmazonS3Folder -Name $folderName -Connection $connect -Bucket $bucket - - # Get Veeam server version from DLL and convert to [version] - $veeamVersion = [version](Get-Item 'C:\Program Files\Veeam\Backup and Replication\Backup\Packages\VeeamDeploymentDll.dll').VersionInfo.ProductVersion - $requiredVersion = [version]"12.3.1.1139" - - # Conditionally run the appropriate command based on version - if ($veeamVersion -ge $requiredVersion) { - Add-VBRAmazonS3CompatibleRepository ` - -AmazonS3Folder $folder ` - -Connection $connect ` - -Name "S3 $FolderName" ` - -EnableBackupImmutability ` - -ImmutabilityPeriod $immutabilityPeriod ` - -Description "$description $bucketName" ` - -EnableBucketAutoProvision:$false - } else { - Add-VBRAmazonS3CompatibleRepository ` - -AmazonS3Folder $folder ` - -Connection $connect ` - -Name "S3 $FolderName" ` - -EnableBackupImmutability ` - -ImmutabilityPeriod $immutabilityPeriod ` - -Description "$description $bucketName" - } - - # Display the added repository details - $repository -} - - -if ($repositoryType -eq 2 -Or $repositoryType -eq 3){ - Write-Host "Creating local repository Local $FolderName" - # Get all logical drives on the system - $drives = Get-WmiObject -Class Win32_LogicalDisk | Where-Object { $_.DriveType -eq 3 } - - # Find the drive with the largest total capacity - if ($drives.Count -gt 1){ - $filteredDrives = $drives | Where-Object { $_.DeviceId -ne 'C:' } - - } else { - $filteredDrives = $drives - } - - # Create the local repository - $filteredDrives | ForEach-Object { - # $timeStamp = [int](Get-Date -UFormat %s -Millisecond 0) - $repositoryPath = Join-Path -Path $_.DeviceID -ChildPath "\veeam\$FolderName" - $repositoryName = "Local $FolderName" - Write-Host "Repository name: $repositoryName" - Write-Host "Repository path: $repositoryPath" - $repository = Add-VBRBackupRepository -Type WinLocal -Name "$repositoryName" -Folder $repositoryPath -Description "$description" - # Display the added repository details - $repository - $localRepository = $repository - Start-Sleep -Seconds 1 - } -} - - - -# Move all local backups -if ($moveBackups -eq 1){ - $backups = Get-VBRBackup | Where -Property TypeToString -ne "Backup Copy" - $localRepository = Get-VBRBackupRepository | Where -Property Name -like "Local*" | Select -First 1 | Select -Expand Name - - Write-Host "moving all backups to $localRepository" - $backups | ForEach-Object { - Move-VBRBackup -Repository $localRepository -Backup $_ -RunAsync -Force - } -} - -# Move listed backups -if ($moveListedBackups){ - Write-Host "Moving $moveListedBackups to $localRepository." - - $moveListedBackups | ForEach-Object { - Move-VBRBackup -Repository $localRepository -Backup $_ -RunAsync - } - -} - -# Move local/scale out repo to new repo - - -Stop-Transcript diff --git a/bdr-veeam/veeam-clear-management-server.ps1 b/bdr-veeam/veeam-clear-management-server.ps1 new file mode 100644 index 0000000..f7c5331 --- /dev/null +++ b/bdr-veeam/veeam-clear-management-server.ps1 @@ -0,0 +1,130 @@ +## Removes the old Veeam management server registration from an endpoint. +## Use when migrating an endpoint to a new Veeam B&R server and you get: +## "host is managed by another backup server" +## +## Run on the ENDPOINT (not the Veeam server). Requires admin privileges. +## +## $env:DESCRIPTION - Ticket # or initials for audit trail +## $env:RMM - Set to 1 when running from RMM platform + +$SCRIPT_LOG_NAME = "veeam-clear-management-server.log" + +if ($env:RMM -ne "1") { + $env:DESCRIPTION = Read-Host "Ticket # or initials for audit trail" + $LOG_PATH = "$env:WINDIR\logs\$SCRIPT_LOG_NAME" +} else { + if (-not $env:DESCRIPTION) { $env:DESCRIPTION = "No Description" } + if ($env:RMM_SCRIPT_PATH) { + $LOG_DIR = "$env:RMM_SCRIPT_PATH\logs" + if (-not (Test-Path $LOG_DIR)) { New-Item -ItemType Directory -Path $LOG_DIR -Force | Out-Null } + $LOG_PATH = "$LOG_DIR\$SCRIPT_LOG_NAME" + } else { + $LOG_PATH = "$env:WINDIR\logs\$SCRIPT_LOG_NAME" + } +} + +Start-Transcript -Path $LOG_PATH + +Write-Host "=== Veeam Clear Management Server ===" +Write-Host "Description: $env:DESCRIPTION" +Write-Host "Hostname: $env:COMPUTERNAME" +Write-Host "" + +# ============================================================ +# 1. Remove Veeam certificates from Trusted Root +# ============================================================ +Write-Host "Checking for Veeam certificates in Trusted Root store..." + +$VEEAM_CERTS = Get-ChildItem Cert:\LocalMachine\Root | Where-Object { + $_.Issuer -like "*Veeam*" -or $_.Subject -like "*Veeam*" +} + +if ($VEEAM_CERTS) { + foreach ($CERT in $VEEAM_CERTS) { + Write-Host " Removing: $($CERT.Subject)" + Write-Host " Issuer: $($CERT.Issuer)" + Write-Host " Thumbprint: $($CERT.Thumbprint)" + try { + Remove-Item $CERT.PSPath -Force + Write-Host " [OK] Removed." + } catch { + Write-Warning " Failed to remove: $_" + } + } +} else { + Write-Host " No Veeam certificates found." +} + +Write-Host "" + +# ============================================================ +# 2. Clear management server registration from registry +# ============================================================ +Write-Host "Clearing management server registry entries..." + +$REG_PATHS = @( + "HKLM:\SOFTWARE\Veeam\Veeam Agent for Microsoft Windows", + "HKLM:\SOFTWARE\Veeam\Veeam Backup and Replication", + "HKLM:\SOFTWARE\Veeam\Veeam Agent" +) + +$REG_KEYS = @( + "ManagementServerAddress", + "ManagementServerPort", + "ManagementServerId", + "ManagementServerCertificateThumbprint", + "ManagementServerCertificateSubject", + "BackupServerAddress", + "BackupServerPort", + "BackupServerId" +) + +foreach ($PATH in $REG_PATHS) { + if (-not (Test-Path $PATH)) { continue } + + Write-Host " Registry: $PATH" + $PROPS = Get-ItemProperty -Path $PATH -ErrorAction SilentlyContinue + + foreach ($KEY in $REG_KEYS) { + if ($null -ne $PROPS.$KEY) { + Write-Host " $KEY = $($PROPS.$KEY)" + try { + Remove-ItemProperty -Path $PATH -Name $KEY -Force -ErrorAction Stop + Write-Host " [OK] Cleared." + } catch { + Write-Warning " Failed to clear: $_" + } + } + } +} + +Write-Host "" + +# ============================================================ +# 3. Restart Veeam agent service +# ============================================================ +Write-Host "Restarting Veeam agent service..." + +$VEEAM_SERVICES = @( + "VeeamEndpointBackupSvc", + "VeeamAgentSvc" +) + +foreach ($SVC_NAME in $VEEAM_SERVICES) { + $SVC = Get-Service -Name $SVC_NAME -ErrorAction SilentlyContinue + if ($SVC) { + Write-Host " Restarting: $SVC_NAME (currently $($SVC.Status))" + try { + Restart-Service -Name $SVC_NAME -Force -ErrorAction Stop + Write-Host " [OK] Restarted." + } catch { + Write-Warning " Failed to restart: $_" + } + } +} + +Write-Host "" +Write-Host "=== Complete ===" +Write-Host "This endpoint can now be added to a new Veeam management server." + +Stop-Transcript diff --git a/bdr-veeam/veeam-configure-backblaze-repo.ps1 b/bdr-veeam/veeam-configure-backblaze-repo.ps1 new file mode 100644 index 0000000..5992134 --- /dev/null +++ b/bdr-veeam/veeam-configure-backblaze-repo.ps1 @@ -0,0 +1,588 @@ +## Creates a Backblaze B2 bucket, generates a scoped application key with +## read/write access to only that bucket, registers the Veeam S3 repository +## using the scoped key, and stores credentials in NinjaRMM device fields. +## +## Bucket naming: {ORG_UUID_no_dashes}-{time_based_short_id}-veeam +## Veeam repository name = bucket name +## +## ADMIN CREDENTIALS (org-level in NinjaRMM, used to create bucket + scoped key): +## $env:B2_ADMIN_KEY_ID - Master/admin B2 application key ID +## $env:B2_ADMIN_APP_KEY - Master/admin B2 application key +## +## CONFIGURATION: +## $env:CUSTOM_FIELD_ORG_UUID - NinjaOne text field containing the organization UUID (REQUIRED) +## $env:B2_ENDPOINT - S3 endpoint (e.g. https://s3.us-west-002.backblazeb2.com) +## $env:B2_REGION - S3 region ID (e.g. us-west-002) +## $env:IMMUTABILITY_DAYS - Object lock immutability period in days (default: 14) +## +## OUTPUT FIELDS (device-level, written after creation): +## $env:CUSTOM_FIELD_S3_BUCKET_NAME - Text: bucket name +## $env:CUSTOM_FIELD_S3_KEY_ID - Text: scoped B2 key ID (bucket-only access) +## $env:CUSTOM_FIELD_S3_APP_KEY - Text: scoped B2 app key (bucket-only access) +## +## $env:DESCRIPTION - Ticket # or initials for audit trail +## $env:RMM - Set to 1 when running from RMM platform +## $env:RMM_SCRIPT_PATH - Script path provided by RMM (used for log location) + +# ============================================================ +# PS7 BOOTSTRAP +# ============================================================ +if ($PSVersionTable.PSVersion.Major -lt 7) { + # Always prefer 64-bit PS7. Veeam's native SQLite DLL is x64 only. + $PWSH_PATH = "$env:ProgramFiles\PowerShell\7\pwsh.exe" + if (-not (Test-Path $PWSH_PATH)) { + $PWSH_CANDIDATE = Get-Command pwsh.exe -ErrorAction SilentlyContinue + $PWSH_PATH = if ($PWSH_CANDIDATE) { $PWSH_CANDIDATE.Source } else { $null } + } + if (Test-Path $PWSH_PATH) { + Write-Host "PowerShell $($PSVersionTable.PSVersion) detected. Re-launching in PowerShell 7 at: $PWSH_PATH" + $PS_ARGS = @('-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', $MyInvocation.MyCommand.Path) + & $PWSH_PATH @PS_ARGS + exit $LASTEXITCODE + } else { + Write-Warning "PowerShell 7 (pwsh.exe) not found. Veeam module may fail to load." + } +} + +# Fix PS7.4+ / Veeam SQLite conflict. +# The type initializer for Microsoft.Data.Sqlite.SqliteConnection fails because +# it can't find the native e_sqlite3 library. Add Veeam's directory to the +# native DLL search path so the managed assembly can find its native dependency. +if ($PSVersionTable.PSVersion.Major -ge 7) { + $VEEAM_BACKUP_DIR = "$env:ProgramFiles\Veeam\Backup and Replication\Backup" + $VEEAM_RUNTIMES = "$env:ProgramFiles\Veeam\Backup and Replication\Backup\runtimes\win-x64\native" + + # Add Veeam paths to PATH so native DLLs (e_sqlite3.dll) are found + foreach ($DIR in @($VEEAM_RUNTIMES, $VEEAM_BACKUP_DIR)) { + if ((Test-Path $DIR) -and $env:PATH -notlike "*$DIR*") { + $env:PATH = "$DIR;$env:PATH" + } + } + + # Also register assembly resolve for managed DLLs + if (Test-Path $VEEAM_BACKUP_DIR) { + $null = [System.AppDomain]::CurrentDomain.add_AssemblyResolve({ + param($sender, $args) + if (-not $args.Name) { return $null } + $ASSEMBLY_NAME = [System.Reflection.AssemblyName]::new($args.Name) + $VEEAM_DLL = Join-Path $VEEAM_BACKUP_DIR "$($ASSEMBLY_NAME.Name).dll" + if (Test-Path $VEEAM_DLL) { + return [System.Reflection.Assembly]::LoadFrom($VEEAM_DLL) + } + return $null + }) + } +} + +# ============================================================ +# HELPER FUNCTIONS +# ============================================================ + +function New-TimeBasedShortId { + # Generates a short time-based ID from a UUIDv7-style timestamp. + # Uses milliseconds since Unix epoch encoded in base36 for compactness. + # 8 chars of base36 = ~2.8 trillion values, unique to the millisecond. + $MS = [long]([DateTimeOffset]::UtcNow.ToUnixTimeMilliseconds()) + $CHARS = "0123456789abcdefghijklmnopqrstuvwxyz" + $RESULT = "" + while ($MS -gt 0) { + $RESULT = $CHARS[$MS % 36] + $RESULT + $MS = [Math]::Floor($MS / 36) + } + # Pad to 8 chars or truncate + return $RESULT.PadLeft(8, '0').Substring(0, 8) +} + +function Invoke-B2Api { + # Wrapper for B2 API calls that handles PowerShell's strict header validation. + # The B2 auth token contains = characters which Invoke-RestMethod rejects. + param( + [string]$Uri, + [string]$Method = "POST", + [string]$AuthToken, + [string]$Body + ) + $PARAMS = @{ + Uri = $Uri + Method = $Method + ContentType = "application/json" + SkipHeaderValidation = $true + Headers = @{ Authorization = $AuthToken } + ErrorAction = "Stop" + } + if ($Body) { $PARAMS['Body'] = $Body } + return Invoke-RestMethod @PARAMS +} + +function Set-NinjaField { + param( + [string]$FieldName, + [string]$Value, + [switch]$Secret + ) + if (-not $FieldName) { return } + $DISPLAY = if ($Secret -and $Value.Length -gt 4) { "$($Value.Substring(0, 4))***" } else { $Value } + $NINJA_CMD = Get-Command "Ninja-Property-Set" -ErrorAction SilentlyContinue + if ($NINJA_CMD) { + try { + Ninja-Property-Set $FieldName $Value + Write-Host " [OK] $FieldName = $DISPLAY" + } catch { + Write-Warning " Failed to write $FieldName : $_" + } + } else { + Write-Host " [SKIP] Ninja-Property-Set not available. $FieldName = $DISPLAY" + } +} + +# ============================================================ +# INPUT HANDLING +# ============================================================ + +$SCRIPT_LOG_NAME = "veeam-create-s3-repo.log" + +if ($env:RMM -ne "1") { + # Interactive mode + $VALID_INPUT = 0 + while ($VALID_INPUT -ne 1) { + $env:DESCRIPTION = Read-Host "Ticket # or initials for audit trail" + if ($env:DESCRIPTION) { $VALID_INPUT = 1 } else { Write-Host "Required." } + } + if (-not $env:CUSTOM_FIELD_ORG_UUID) { $env:CUSTOM_FIELD_ORG_UUID = Read-Host "Organization UUID (REQUIRED)" } + if (-not $env:B2_ADMIN_KEY_ID) { $env:B2_ADMIN_KEY_ID = Read-Host "B2 admin key ID (master key)" } + if (-not $env:B2_ADMIN_APP_KEY) { $env:B2_ADMIN_APP_KEY = Read-Host "B2 admin app key (master key)" } + if (-not $env:B2_ENDPOINT) { $env:B2_ENDPOINT = Read-Host "B2 S3 endpoint (e.g. https://s3.us-west-002.backblazeb2.com)" } + if (-not $env:B2_REGION) { $env:B2_REGION = Read-Host "B2 region (e.g. us-west-002)" } + if (-not $env:IMMUTABILITY_DAYS) { $env:IMMUTABILITY_DAYS = Read-Host "Immutability period in days (default 14)" } + + $LOG_PATH = "$env:WINDIR\logs\$SCRIPT_LOG_NAME" +} else { + if ($env:RMM_SCRIPT_PATH) { + $LOG_DIR = "$env:RMM_SCRIPT_PATH\logs" + if (-not (Test-Path $LOG_DIR)) { New-Item -ItemType Directory -Path $LOG_DIR -Force | Out-Null } + $LOG_PATH = "$LOG_DIR\$SCRIPT_LOG_NAME" + } else { + $LOG_PATH = "$env:WINDIR\logs\$SCRIPT_LOG_NAME" + } + if (-not $env:DESCRIPTION) { $env:DESCRIPTION = "No Description" } +} + +# Validate required inputs +if (-not $env:B2_ADMIN_KEY_ID -or -not $env:B2_ADMIN_APP_KEY) { + Write-Error "B2_ADMIN_KEY_ID and B2_ADMIN_APP_KEY are required." + exit 1 +} +if (-not $env:B2_ENDPOINT) { + Write-Error "B2_ENDPOINT is required." + exit 1 +} +if (-not $env:B2_REGION) { + Write-Error "B2_REGION is required." + exit 1 +} + +# Defaults +$IMMUTABILITY_DAYS = 14 +if ($env:IMMUTABILITY_DAYS) { + try { $IMMUTABILITY_DAYS = [int]$env:IMMUTABILITY_DAYS } catch {} +} + +# ============================================================ +# GENERATE BUCKET NAME +# ============================================================ + +Start-Transcript -Path $LOG_PATH + +Write-Host "=== Veeam S3 Repository Creation ===" +Write-Host "Description: $env:DESCRIPTION" +Write-Host "" + +# Org UUID: CUSTOM_FIELD_ORG_UUID contains the NinjaOne field NAME (e.g. "dtcOrgGuid"). +# We pass that to Ninja-Property-Get to read the actual UUID value. +$ORG_UUID = $null +if ($env:CUSTOM_FIELD_ORG_UUID) { + try { + $ORG_UUID = Ninja-Property-Get $env:CUSTOM_FIELD_ORG_UUID 2>$null + } catch { } +} +if (-not $ORG_UUID) { + Write-Error "CUSTOM_FIELD_ORG_UUID is empty. Set the org UUID field in NinjaRMM." + Stop-Transcript + exit 1 +} +# Validate it's actually a UUID, not a field name or garbage +if ($ORG_UUID -notmatch '^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$') { + Write-Error "CUSTOM_FIELD_ORG_UUID value '$ORG_UUID' is not a valid UUID. Expected format: xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" + Stop-Transcript + exit 1 +} +Write-Host "Org UUID: $ORG_UUID" +$ORG_PREFIX = $ORG_UUID.Replace("-", "").ToLower() + +# Generate time-based short ID for this bucket +$BUCKET_SHORT_ID = New-TimeBasedShortId +$BUCKET_NAME = "$ORG_PREFIX-$BUCKET_SHORT_ID-veeam" + +# S3 bucket name validation: lowercase alphanumeric + hyphens, 3-50 chars +$BUCKET_NAME = $BUCKET_NAME -replace '[^a-z0-9\-]', '' +if ($BUCKET_NAME.Length -gt 50) { + # Truncate org prefix to fit + $MAX_ORG = 50 - 1 - 8 - 1 - 5 # dash + shortid + dash + "veeam" + $ORG_PREFIX = $ORG_PREFIX.Substring(0, $MAX_ORG) + $BUCKET_NAME = "$ORG_PREFIX-$BUCKET_SHORT_ID-veeam" +} + +Write-Host "Bucket name: $BUCKET_NAME" +Write-Host "Org UUID: $ORG_UUID" +Write-Host "Org prefix: $ORG_PREFIX" +Write-Host "Bucket short ID: $BUCKET_SHORT_ID" +Write-Host "Endpoint: $env:B2_ENDPOINT" +Write-Host "Region: $env:B2_REGION" +Write-Host "Immutability: $IMMUTABILITY_DAYS days" +Write-Host "" + +# ============================================================ +# LOAD VEEAM MODULE +# ============================================================ + +Write-Host "Loading Veeam PowerShell module..." +$MY_MODULE_PATH = "$env:ProgramFiles\Veeam\Backup and Replication\Console\" +$env:PSModulePath = $env:PSModulePath + "$([System.IO.Path]::PathSeparator)$MY_MODULE_PATH" + +if ($VBR_MODULES = Get-Module -ListAvailable -Name Veeam.Backup.PowerShell) { + try { + $VBR_MODULES | Import-Module -WarningAction SilentlyContinue + Write-Host " [OK] Veeam module loaded." + } catch { + Stop-Transcript + throw "Failed to load Veeam modules: $_" + } +} else { + Stop-Transcript + throw "Veeam.Backup.PowerShell module not found." +} + +# ============================================================ +# CHECK IF BUCKET + KEYS ALREADY EXIST (idempotency) +# ============================================================ + +# Try reading existing bucket name and scoped keys from NinjaOne +Write-Host "Checking for existing B2 credentials in NinjaOne..." +Write-Host " CUSTOM_FIELD_S3_BUCKET_NAME env: '$($env:CUSTOM_FIELD_S3_BUCKET_NAME)'" +Write-Host " CUSTOM_FIELD_S3_KEY_ID env: '$($env:CUSTOM_FIELD_S3_KEY_ID)'" +Write-Host " CUSTOM_FIELD_S3_APP_KEY env: '$($env:CUSTOM_FIELD_S3_APP_KEY)'" + +$EXISTING_BUCKET = $null +$EXISTING_KEY_ID = $null +$EXISTING_APP_KEY = $null + +# Read existing values from NinjaOne device fields +$NINJA_AVAILABLE = $null -ne (Get-Command "Ninja-Property-Get" -ErrorAction SilentlyContinue) +if ($NINJA_AVAILABLE) { + try { + if ($env:CUSTOM_FIELD_S3_BUCKET_NAME) { + $EXISTING_BUCKET = Ninja-Property-Get $env:CUSTOM_FIELD_S3_BUCKET_NAME 2>$null + Write-Host " Bucket value: '$EXISTING_BUCKET'" + } + if ($env:CUSTOM_FIELD_S3_KEY_ID) { + $EXISTING_KEY_ID = Ninja-Property-Get $env:CUSTOM_FIELD_S3_KEY_ID 2>$null + Write-Host " Key ID value: $(if ($EXISTING_KEY_ID) { 'set' } else { 'empty' })" + } + if ($env:CUSTOM_FIELD_S3_APP_KEY) { + $EXISTING_APP_KEY = Ninja-Property-Get $env:CUSTOM_FIELD_S3_APP_KEY 2>$null + Write-Host " App Key value: $(if ($EXISTING_APP_KEY) { 'set' } else { 'empty' })" + } + } catch { + Write-Warning " Ninja-Property-Get failed: $_" + } +} else { + Write-Host " Ninja-Property-Get not available (not running in NinjaRMM)." +} + +$SKIP_B2_CREATION = $false +if ($EXISTING_BUCKET -and $EXISTING_KEY_ID -and $EXISTING_APP_KEY) { + Write-Host "Existing B2 bucket and keys found in RMM:" + Write-Host " Bucket: $EXISTING_BUCKET" + Write-Host " Key ID: $($EXISTING_KEY_ID.Substring(0, [Math]::Min(8, $EXISTING_KEY_ID.Length)))..." + Write-Host " Skipping B2 bucket/key creation. Will create Veeam repo only." + Write-Host "" + $BUCKET_NAME = $EXISTING_BUCKET + $SCOPED_KEY_ID = $EXISTING_KEY_ID + $SCOPED_APP_KEY = $EXISTING_APP_KEY + # Extract short ID from existing bucket name for folder creation + if ($BUCKET_NAME -match '-([a-z0-9]{8})-veeam$') { + $BUCKET_SHORT_ID = $Matches[1] + } + $SKIP_B2_CREATION = $true +} + +$SCOPED_KEY_ID_OUT = $null +$SCOPED_APP_KEY_OUT = $null + +if (-not $SKIP_B2_CREATION) { + # ============================================================ + # B2 API: AUTH WITH ADMIN KEY + # ============================================================ + + Write-Host "" + Write-Host "Authenticating to B2 with admin key..." + + $B2_SECURE_KEY = ConvertTo-SecureString $env:B2_ADMIN_APP_KEY -AsPlainText -Force + $B2_CREDENTIAL = [PSCredential]::new($env:B2_ADMIN_KEY_ID, $B2_SECURE_KEY) + + $B2_AUTH = $null + $B2_API_VER = "v2" + foreach ($VER in @("v2", "v4")) { + try { + $B2_AUTH = Invoke-RestMethod -Uri "https://api.backblazeb2.com/b2api/$VER/b2_authorize_account" ` + -Method GET ` + -Authentication Basic ` + -Credential $B2_CREDENTIAL ` + -ErrorAction Stop + $B2_API_VER = $VER + break + } catch { } + } + if (-not $B2_AUTH) { + Write-Error "B2 authentication failed. Check B2_ADMIN_KEY_ID and B2_ADMIN_APP_KEY." + Stop-Transcript + exit 1 + } + + $B2_API_URL = $B2_AUTH.apiUrl + if (-not $B2_API_URL) { $B2_API_URL = $B2_AUTH.apiInfo.storageApi.apiUrl } + $B2_AUTH_TOKEN = $B2_AUTH.authorizationToken + $B2_ACCOUNT_ID = $B2_AUTH.accountId + + Write-Host " [OK] Account: $B2_ACCOUNT_ID (api: $B2_API_VER)" + + # ============================================================ + # CREATE B2 BUCKET + # ============================================================ + + Write-Host "" + Write-Host "Creating B2 bucket: $BUCKET_NAME" + + try { + $CREATE_BODY = @{ + accountId = $B2_ACCOUNT_ID + bucketName = $BUCKET_NAME + bucketType = "allPrivate" + fileLockEnabled = $true + } | ConvertTo-Json + + $B2_BUCKET = Invoke-B2Api -Uri "$B2_API_URL/b2api/$B2_API_VER/b2_create_bucket" ` + -AuthToken $B2_AUTH_TOKEN -Body $CREATE_BODY + + Write-Host " [OK] Bucket created: $($B2_BUCKET.bucketName) (ID: $($B2_BUCKET.bucketId))" + } catch { + Write-Error "B2 bucket creation failed: $_" + Stop-Transcript + exit 1 + } + + # Set lifecycle rule to purge hidden (old) file versions. + # Without this, B2 keeps ALL versions forever and storage balloons. + # daysFromHidingToDeleting = immutability + 1 day buffer so locked files + # are never deleted before retention expires. + # NOTE: Do NOT set bucket-level defaultRetention here. Veeam manages + # immutability/retention itself via per-object locks. Setting a bucket + # default causes "default retention is not supported" errors in Veeam. + $LIFECYCLE_PURGE_DAYS = $IMMUTABILITY_DAYS + 1 + + try { + $UPDATE_BODY = @{ + accountId = $B2_ACCOUNT_ID + bucketId = $B2_BUCKET.bucketId + defaultServerSideEncryption = @{ + mode = "SSE-B2" + algorithm = "AES256" + } + lifecycleRules = @( + @{ + daysFromHidingToDeleting = $LIFECYCLE_PURGE_DAYS + daysFromUploadingToHiding = $null + fileNamePrefix = "" + } + ) + } | ConvertTo-Json -Depth 5 + + Invoke-B2Api -Uri "$B2_API_URL/b2api/$B2_API_VER/b2_update_bucket" ` + -AuthToken $B2_AUTH_TOKEN -Body $UPDATE_BODY | Out-Null + + Write-Host " [OK] Server-side encryption: SSE-B2 (AES256)" + Write-Host " [OK] Lifecycle rule: delete hidden versions after $LIFECYCLE_PURGE_DAYS days" + } catch { + Write-Warning " Failed to set encryption/lifecycle: $_" + } + + # ============================================================ + # CREATE SCOPED APPLICATION KEY (bucket-only access) + # ============================================================ + + Write-Host "" + Write-Host "Creating scoped application key for bucket: $BUCKET_NAME" + + try { + $KEY_BODY = @{ + accountId = $B2_ACCOUNT_ID + capabilities = @( + "listBuckets" + "listAllBucketNames" + "readBuckets" + "listFiles" + "readFiles" + "writeFiles" + "deleteFiles" + "readBucketEncryption" + "writeBucketEncryption" + "readBucketRetentions" + "writeBucketRetentions" + "readFileRetentions" + "writeFileRetentions" + "readFileLegalHolds" + "writeFileLegalHolds" + "bypassGovernance" + ) + keyName = $BUCKET_NAME + bucketId = $B2_BUCKET.bucketId + } | ConvertTo-Json + + $KEY_RESPONSE = Invoke-B2Api -Uri "$B2_API_URL/b2api/$B2_API_VER/b2_create_key" ` + -AuthToken $B2_AUTH_TOKEN -Body $KEY_BODY + + $SCOPED_KEY_ID = $KEY_RESPONSE.applicationKeyId + $SCOPED_APP_KEY = $KEY_RESPONSE.applicationKey + + Write-Host " [OK] Scoped key created: $($SCOPED_KEY_ID.Substring(0, [Math]::Min(8, $SCOPED_KEY_ID.Length)))..." + Write-Host " Bucket restriction: $($B2_BUCKET.bucketName) ($($B2_BUCKET.bucketId))" + } catch { + Write-Error "Failed to create scoped application key: $_" + Write-Host " Bucket ($BUCKET_NAME) was created but no scoped key exists." + Stop-Transcript + exit 1 + } + + $SCOPED_KEY_ID_OUT = $SCOPED_KEY_ID + $SCOPED_APP_KEY_OUT = $SCOPED_APP_KEY + + # Save to NinjaOne immediately so credentials aren't lost if Veeam fails + Write-Host "" + Write-Host "Saving B2 credentials to NinjaOne..." + Set-NinjaField $env:CUSTOM_FIELD_S3_BUCKET_NAME $BUCKET_NAME + Set-NinjaField $env:CUSTOM_FIELD_S3_KEY_ID $SCOPED_KEY_ID_OUT -Secret + Set-NinjaField $env:CUSTOM_FIELD_S3_APP_KEY $SCOPED_APP_KEY_OUT -Secret + + # Let the scoped key propagate before Veeam tries to use it + Write-Host " Waiting 5 seconds for key propagation..." + Start-Sleep -Seconds 5 +} else { + $SCOPED_KEY_ID_OUT = $SCOPED_KEY_ID + $SCOPED_APP_KEY_OUT = $SCOPED_APP_KEY +} + +# ============================================================ +# CREATE VEEAM S3 REPOSITORY +# ============================================================ + +Write-Host "" +Write-Host "Creating Veeam S3 repository: $BUCKET_NAME" + +try { + # Suppress all confirmation prompts + $ConfirmPreference = 'None' + + # Pre-trust the B2 endpoint SSL certificate so Veeam doesn't prompt + Write-Host " Pre-trusting SSL certificate for $env:B2_ENDPOINT..." + try { + $ENDPOINT_URI = [System.Uri]$env:B2_ENDPOINT + $TCP = [System.Net.Sockets.TcpClient]::new($ENDPOINT_URI.Host, 443) + $SSL = [System.Net.Security.SslStream]::new($TCP.GetStream(), $false, { $true }) + $SSL.AuthenticateAsClient($ENDPOINT_URI.Host) + $CERT = $SSL.RemoteCertificate + $SSL.Dispose() + $TCP.Dispose() + + if ($CERT) { + # Import the cert to Trusted Root if not already there + $X509 = [System.Security.Cryptography.X509Certificates.X509Certificate2]::new($CERT) + $STORE = [System.Security.Cryptography.X509Certificates.X509Store]::new( + [System.Security.Cryptography.X509Certificates.StoreName]::Root, + [System.Security.Cryptography.X509Certificates.StoreLocation]::LocalMachine + ) + $STORE.Open([System.Security.Cryptography.X509Certificates.OpenFlags]::ReadWrite) + $STORE.Add($X509) + $STORE.Close() + Write-Host " [OK] Certificate trusted: $($X509.Subject)" + } + } catch { + Write-Warning " Could not pre-trust certificate: $_" + } + + # Add the SCOPED B2 credentials to Veeam (not the admin key) + $VEEAM_ACCOUNT = Add-VBRAmazonAccount ` + -AccessKey $SCOPED_KEY_ID_OUT ` + -SecretKey $SCOPED_APP_KEY_OUT ` + -Description "$env:DESCRIPTION $BUCKET_NAME" + + Write-Host " [OK] Veeam account added." + + # Connect to the S3-compatible service + $VEEAM_CONNECTION = Connect-VBRAmazonS3CompatibleService ` + -Account $VEEAM_ACCOUNT ` + -CustomRegionId $env:B2_REGION ` + -ServicePoint $env:B2_ENDPOINT + + Write-Host " [OK] Connected to S3 endpoint." + + # Get the bucket + $VEEAM_BUCKET = Get-VBRAmazonS3Bucket -Connection $VEEAM_CONNECTION -Name $BUCKET_NAME + Write-Host " [OK] Bucket found in Veeam." + + # Create a folder inside the bucket (use the short ID as folder name) + $VEEAM_FOLDER = New-VBRAmazonS3Folder -Name $BUCKET_SHORT_ID -Connection $VEEAM_CONNECTION -Bucket $VEEAM_BUCKET + Write-Host " [OK] Folder created: $BUCKET_SHORT_ID" + + # Create the repository (name = bucket name) + # -Confirm:$false suppresses ShouldProcess prompts + # -EnableBucketAutoProvision:$false prevents hang on S3-compatible endpoints + # (default changed to $true in Veeam 12.3.1, causes hang on non-AWS S3) + $REPO_PARAMS = @{ + AmazonS3Folder = $VEEAM_FOLDER + Connection = $VEEAM_CONNECTION + Name = $BUCKET_NAME + EnableBackupImmutability = $true + ImmutabilityPeriod = $IMMUTABILITY_DAYS + EnableBucketAutoProvision = $false + Description = "$env:DESCRIPTION $BUCKET_NAME" + } + + $VEEAM_REPO = Add-VBRAmazonS3CompatibleRepository @REPO_PARAMS + + Write-Host " [OK] Veeam repository created: $($VEEAM_REPO.Name)" +} catch { + Write-Error "Veeam repository creation failed: $_" + Write-Host " The B2 bucket ($BUCKET_NAME) was created but the Veeam repo was not." + Write-Host " You may need to add it manually in the Veeam console." + Stop-Transcript + exit 1 +} + +# ============================================================ +# STORE RESULT +# ============================================================ + +Write-Host "" +Write-Host "=== Complete ===" +Write-Host " Bucket: $BUCKET_NAME" +Write-Host " Repository: $BUCKET_NAME" +Write-Host " Folder: $BUCKET_SHORT_ID" +Write-Host " Scoped key: $($SCOPED_KEY_ID_OUT.Substring(0, [Math]::Min(8, $SCOPED_KEY_ID_OUT.Length)))..." +Write-Host " Immutability: $IMMUTABILITY_DAYS days" +Write-Host "" + + +Write-Host "" +Write-Host "=== Script complete ===" + +Stop-Transcript diff --git a/bdr-veeam/veeam-configure-local-backup-jobs.ps1 b/bdr-veeam/veeam-configure-local-backup-jobs.ps1 new file mode 100644 index 0000000..d49e153 --- /dev/null +++ b/bdr-veeam/veeam-configure-local-backup-jobs.ps1 @@ -0,0 +1,242 @@ +## Configures all local backup jobs (agent/VM) with a standard schedule: +## Mon-Fri: 6 AM - 9 PM (blocked overnight for S3 copy window) +## Sat-Sun: No local backups (S3 copy runs all day) +## +## This complements veeam-configure-s3-copy-job.ps1 which runs: +## Mon-Fri: 10 PM - 6 AM +## Sat-Sun: All day +## +## $env:DESCRIPTION - Ticket # or initials for audit trail +## $env:RMM - Set to 1 when running from RMM platform +## $env:RMM_SCRIPT_PATH - Script path provided by RMM (used for log location) + +# ============================================================ +# PS7 BOOTSTRAP +# ============================================================ +if ($PSVersionTable.PSVersion.Major -lt 7) { + # Always prefer 64-bit PS7. Veeam's native SQLite DLL is x64 only. + $PWSH_PATH = "$env:ProgramFiles\PowerShell\7\pwsh.exe" + if (-not (Test-Path $PWSH_PATH)) { + $PWSH_CANDIDATE = Get-Command pwsh.exe -ErrorAction SilentlyContinue + $PWSH_PATH = if ($PWSH_CANDIDATE) { $PWSH_CANDIDATE.Source } else { $null } + } + if (Test-Path $PWSH_PATH) { + Write-Host "Re-launching in PowerShell 7..." + & $PWSH_PATH -NoProfile -ExecutionPolicy Bypass -File $MyInvocation.MyCommand.Path + exit $LASTEXITCODE + } +} + +# Fix PS7.4+ / Veeam SQLite conflict. +if ($PSVersionTable.PSVersion.Major -ge 7) { + $VEEAM_BACKUP_DIR = "$env:ProgramFiles\Veeam\Backup and Replication\Backup" + $VEEAM_RUNTIMES = "$env:ProgramFiles\Veeam\Backup and Replication\Backup\runtimes\win-x64\native" + foreach ($DIR in @($VEEAM_RUNTIMES, $VEEAM_BACKUP_DIR)) { + if ((Test-Path $DIR) -and $env:PATH -notlike "*$DIR*") { + $env:PATH = "$DIR;$env:PATH" + } + } + if (Test-Path $VEEAM_BACKUP_DIR) { + $null = [System.AppDomain]::CurrentDomain.add_AssemblyResolve({ + param($sender, $args) + if (-not $args.Name) { return $null } + $ASSEMBLY_NAME = [System.Reflection.AssemblyName]::new($args.Name) + $VEEAM_DLL = Join-Path $VEEAM_BACKUP_DIR "$($ASSEMBLY_NAME.Name).dll" + if (Test-Path $VEEAM_DLL) { + return [System.Reflection.Assembly]::LoadFrom($VEEAM_DLL) + } + return $null + }) + } +} + +# ============================================================ +# INPUT HANDLING +# ============================================================ + +$SCRIPT_LOG_NAME = "veeam-configure-local-backup-schedule.log" +$ConfirmPreference = 'None' + +if ($env:RMM -ne "1") { + if (-not $env:DESCRIPTION) { $env:DESCRIPTION = Read-Host "Ticket # or initials" } + $LOG_PATH = "$env:WINDIR\logs\$SCRIPT_LOG_NAME" +} else { + if (-not $env:DESCRIPTION) { $env:DESCRIPTION = "No Description" } + if ($env:RMM_SCRIPT_PATH) { + $LOG_DIR = "$env:RMM_SCRIPT_PATH\logs" + if (-not (Test-Path $LOG_DIR)) { New-Item -ItemType Directory -Path $LOG_DIR -Force | Out-Null } + $LOG_PATH = "$LOG_DIR\$SCRIPT_LOG_NAME" + } else { + $LOG_PATH = "$env:WINDIR\logs\$SCRIPT_LOG_NAME" + } +} + +Start-Transcript -Path $LOG_PATH + +Write-Host "=== Veeam Local Backup Schedule Configuration ===" +Write-Host "Description: $env:DESCRIPTION" +Write-Host "Schedule: Mon-Fri 5 AM - 10 PM" +Write-Host " Sat-Sun disabled" +Write-Host "" + +# ============================================================ +# LOAD VEEAM MODULE +# ============================================================ + +Write-Host "Loading Veeam PowerShell module..." +$MY_MODULE_PATH = "$env:ProgramFiles\Veeam\Backup and Replication\Console\" +$env:PSModulePath = $env:PSModulePath + "$([System.IO.Path]::PathSeparator)$MY_MODULE_PATH" + +if ($VBR_MODULES = Get-Module -ListAvailable -Name Veeam.Backup.PowerShell) { + try { + $VBR_MODULES | Import-Module -WarningAction SilentlyContinue + Write-Host " [OK] Veeam module loaded." + } catch { + Stop-Transcript + throw "Failed to load Veeam modules: $_" + } +} else { + Stop-Transcript + throw "Veeam.Backup.PowerShell module not found." +} + +# ============================================================ +# GET LOCAL BACKUP JOBS +# ============================================================ + +Write-Host "" +Write-Host "Collecting local backup jobs..." + +$LOCAL_JOBS = @() + +# Get regular backup jobs +try { + $VBR_JOBS = @(Get-VBRJob -WarningAction SilentlyContinue | Where-Object { + ($_.JobType -eq 'Backup' -or $_.JobType -eq 'EpAgentBackup' -or + $_.JobType -eq 'EpAgentPolicy' -or $_.JobType -eq 'EpAgentManagement') -and + -not $_.IsBackupCopy + }) + if ($VBR_JOBS.Count -gt 0) { + Write-Host " Get-VBRJob: $($VBR_JOBS.Count) local backup jobs" + $LOCAL_JOBS += $VBR_JOBS + } +} catch { + Write-Warning " Get-VBRJob failed: $_" +} + +# Get computer/agent backup jobs +$COMPUTER_JOBS = @() +try { + $COMPUTER_JOBS = @(Get-VBRComputerBackupJob -ErrorAction SilentlyContinue) + if ($COMPUTER_JOBS.Count -gt 0) { + Write-Host " Get-VBRComputerBackupJob: $($COMPUTER_JOBS.Count) agent jobs" + } +} catch { + Write-Warning " Get-VBRComputerBackupJob failed: $_" +} + +if ($LOCAL_JOBS.Count -eq 0 -and $COMPUTER_JOBS.Count -eq 0) { + Write-Host " No local backup jobs found." + Stop-Transcript + exit 0 +} + +# ============================================================ +# BUILD BACKUP WINDOW +# ============================================================ + +# Backup window string: 168 chars (24h x 7 days starting Sunday) +# 1 = allowed to run, 0 = blocked +# Sun: blocked, Mon-Fri: 6 AM - 9 PM, Sat: blocked +# 9 PM stop -> 1 hr buffer -> 10 PM S3 copy starts +$WINDOW = "" +for ($DAY = 0; $DAY -lt 7; $DAY++) { + for ($HOUR = 0; $HOUR -lt 24; $HOUR++) { + if ($DAY -eq 0 -or $DAY -eq 6) { + # Sunday (0) and Saturday (6): blocked all day + $WINDOW += "0" + } else { + # Mon-Fri: 6 AM (06) through 9 PM (21:59), blocked 10 PM - 5:59 AM + if ($HOUR -ge 6 -and $HOUR -le 21) { + $WINDOW += "1" + } else { + $WINDOW += "0" + } + } + } +} + +# ============================================================ +# CONFIGURE VBR JOBS (Get-VBRJob types) +# ============================================================ + +if ($LOCAL_JOBS.Count -gt 0) { + Write-Host "" + Write-Host "Configuring VBR job schedules..." + + foreach ($JOB in $LOCAL_JOBS) { + Write-Host " $($JOB.Name) ($($JOB.JobType))..." + + try { + # Get current job options + $OPTIONS = $JOB.GetOptions() + + # Enable backup window + $OPTIONS.BackupTargetOptions.TransformToSyntethicDays = [Veeam.Backup.Common.EDayOfWeek]::None + $OPTIONS.JobOptions.BackupWindowEnabled = $true + $OPTIONS.JobOptions.BackupWindow = $WINDOW + + # Apply + Set-VBRJobOptions -Job $JOB -Options $OPTIONS + Write-Host " [OK] Backup window applied." + } catch { + # Try Set-VBRJobSchedule as fallback + try { + Set-VBRJobSchedule -Job $JOB ` + -DailyOptions (New-VBRDailyOptions -DayOfWeek Monday,Tuesday,Wednesday,Thursday,Friday -Period 1) ` + -At "05:00" + Write-Host " [OK] Schedule set (daily Mon-Fri at 5 AM)." + } catch { + Write-Warning " Failed: $_" + } + } + } +} + +# ============================================================ +# CONFIGURE COMPUTER BACKUP JOBS +# ============================================================ + +if ($COMPUTER_JOBS.Count -gt 0) { + Write-Host "" + Write-Host "Configuring agent/computer backup job schedules..." + + foreach ($JOB in $COMPUTER_JOBS) { + Write-Host " $($JOB.Name)..." + + try { + Set-VBRComputerBackupJob -Job $JOB ` + -EnableSchedule ` + -BackupWindowEnabled ` + -BackupWindowOptions $WINDOW + Write-Host " [OK] Backup window applied." + } catch { + Write-Warning " Failed to set backup window: $_" + # Try just setting the schedule days + try { + Set-VBRComputerBackupJob -Job $JOB ` + -EnableSchedule ` + -DailySchedule ` + -DailyType Weekdays + Write-Host " [OK] Fallback: set to weekdays only." + } catch { + Write-Warning " Fallback also failed: $_" + } + } + } +} + +Write-Host "" +Write-Host "=== Script complete ===" + +Stop-Transcript diff --git a/bdr-veeam/veeam-configure-s3-copy-job.ps1 b/bdr-veeam/veeam-configure-s3-copy-job.ps1 new file mode 100644 index 0000000..8596e4b --- /dev/null +++ b/bdr-veeam/veeam-configure-s3-copy-job.ps1 @@ -0,0 +1,396 @@ +## Ensures a single S3 backup copy job exists targeting the S3 repository, +## with ALL backup jobs as sources. If the copy job exists, adds any missing +## source jobs. If it doesn't exist, creates it. +## +## Uses immediate/simple mode (copies latest restore point when source job +## finishes, not full history). This is the "pruning" mode. +## +## $env:CUSTOM_FIELD_S3_BUCKET_NAME - NinjaOne field name for the S3 bucket/repo name +## $env:CUSTOM_FIELD_CLOUD_RETENTION - NinjaOne org-level field name for cloud backup retention days (versions to keep) (default: 30) +## $env:DESCRIPTION - Ticket # or initials for audit trail +## $env:RMM - Set to 1 when running from RMM platform +## $env:RMM_SCRIPT_PATH - Script path provided by RMM (used for log location) + +# ============================================================ +# PS7 BOOTSTRAP +# ============================================================ +if ($PSVersionTable.PSVersion.Major -lt 7) { + # Always prefer 64-bit PS7. Veeam's native SQLite DLL is x64 only. + $PWSH_PATH = "$env:ProgramFiles\PowerShell\7\pwsh.exe" + if (-not (Test-Path $PWSH_PATH)) { + $PWSH_CANDIDATE = Get-Command pwsh.exe -ErrorAction SilentlyContinue + $PWSH_PATH = if ($PWSH_CANDIDATE) { $PWSH_CANDIDATE.Source } else { $null } + } + if (Test-Path $PWSH_PATH) { + Write-Host "Re-launching in PowerShell 7..." + & $PWSH_PATH -NoProfile -ExecutionPolicy Bypass -File $MyInvocation.MyCommand.Path + exit $LASTEXITCODE + } +} + +# Fix PS7.4+ / Veeam SQLite conflict. +if ($PSVersionTable.PSVersion.Major -ge 7) { + $VEEAM_BACKUP_DIR = "$env:ProgramFiles\Veeam\Backup and Replication\Backup" + $VEEAM_RUNTIMES = "$env:ProgramFiles\Veeam\Backup and Replication\Backup\runtimes\win-x64\native" + foreach ($DIR in @($VEEAM_RUNTIMES, $VEEAM_BACKUP_DIR)) { + if ((Test-Path $DIR) -and $env:PATH -notlike "*$DIR*") { + $env:PATH = "$DIR;$env:PATH" + } + } + if (Test-Path $VEEAM_BACKUP_DIR) { + $null = [System.AppDomain]::CurrentDomain.add_AssemblyResolve({ + param($sender, $args) + if (-not $args.Name) { return $null } + $ASSEMBLY_NAME = [System.Reflection.AssemblyName]::new($args.Name) + $VEEAM_DLL = Join-Path $VEEAM_BACKUP_DIR "$($ASSEMBLY_NAME.Name).dll" + if (Test-Path $VEEAM_DLL) { + return [System.Reflection.Assembly]::LoadFrom($VEEAM_DLL) + } + return $null + }) + } +} + +# ============================================================ +# INPUT HANDLING +# ============================================================ + +$SCRIPT_LOG_NAME = "veeam-configure-s3-copy-job.log" +$ConfirmPreference = 'None' + +if ($env:RMM -ne "1") { + if (-not $env:DESCRIPTION) { $env:DESCRIPTION = Read-Host "Ticket # or initials" } + $LOG_PATH = "$env:WINDIR\logs\$SCRIPT_LOG_NAME" +} else { + if (-not $env:DESCRIPTION) { $env:DESCRIPTION = "No Description" } + if ($env:RMM_SCRIPT_PATH) { + $LOG_DIR = "$env:RMM_SCRIPT_PATH\logs" + if (-not (Test-Path $LOG_DIR)) { New-Item -ItemType Directory -Path $LOG_DIR -Force | Out-Null } + $LOG_PATH = "$LOG_DIR\$SCRIPT_LOG_NAME" + } else { + $LOG_PATH = "$env:WINDIR\logs\$SCRIPT_LOG_NAME" + } +} + +# Cloud Retention: read from org-level NinjaOne field, default 30 +$RETENTION_DAYS = 30 +if ($env:CUSTOM_FIELD_CLOUD_RETENTION) { + try { + $RPO_VALUE = Ninja-Property-Get $env:CUSTOM_FIELD_CLOUD_RETENTION 2>$null + if ($RPO_VALUE) { $RETENTION_DAYS = [int]$RPO_VALUE } + } catch { } +} + +Start-Transcript -Path $LOG_PATH + +Write-Host "=== Veeam S3 Copy Job Configuration ===" +Write-Host "Description: $env:DESCRIPTION" +Write-Host "Retention: $RETENTION_DAYS days" +Write-Host "" + +# ============================================================ +# LOAD VEEAM MODULE +# ============================================================ + +Write-Host "Loading Veeam PowerShell module..." +$MY_MODULE_PATH = "$env:ProgramFiles\Veeam\Backup and Replication\Console\" +$env:PSModulePath = $env:PSModulePath + "$([System.IO.Path]::PathSeparator)$MY_MODULE_PATH" + +if ($VBR_MODULES = Get-Module -ListAvailable -Name Veeam.Backup.PowerShell) { + try { + $VBR_MODULES | Import-Module -WarningAction SilentlyContinue + Write-Host " [OK] Veeam module loaded." + } catch { + Stop-Transcript + throw "Failed to load Veeam modules: $_" + } +} else { + Stop-Transcript + throw "Veeam.Backup.PowerShell module not found." +} + +# ============================================================ +# FIND THE S3 REPOSITORY +# ============================================================ + +Write-Host "" +Write-Host "Finding S3 repository..." + +$S3_REPO_NAME = $null +if ($env:CUSTOM_FIELD_S3_BUCKET_NAME) { + try { + $S3_REPO_NAME = Ninja-Property-Get $env:CUSTOM_FIELD_S3_BUCKET_NAME 2>$null + } catch { } +} + +# Find the repo in Veeam by name +$S3_REPO = $null +if ($S3_REPO_NAME) { + $S3_REPO = Get-VBRBackupRepository -Name $S3_REPO_NAME -ErrorAction SilentlyContinue +} + +# If not found by NinjaOne field, look for any S3-compatible repo +if (-not $S3_REPO) { + Write-Host " S3 repo not found by NinjaOne field. Searching for S3-compatible repositories..." + $ALL_REPOS = Get-VBRBackupRepository + $S3_REPOS = $ALL_REPOS | Where-Object { $_.Type -like "AmazonS3*" -or $_.Type -eq "S3Compatible" -or $_.Type -match "S3" } + + if ($S3_REPOS.Count -eq 1) { + $S3_REPO = $S3_REPOS[0] + } elseif ($S3_REPOS.Count -gt 1) { + # Also check object storage repos + try { + $OBJ_REPOS = Get-VBRObjectStorageRepository -ErrorAction Stop + if ($OBJ_REPOS.Count -eq 1) { + $S3_REPO_NAME = $OBJ_REPOS[0].Name + $S3_REPO = Get-VBRBackupRepository -Name $S3_REPO_NAME -ErrorAction SilentlyContinue + } + } catch { } + } + + if (-not $S3_REPO) { + Write-Error "No S3 repository found. Run veeam-create-s3-repo.ps1 first." + Stop-Transcript + exit 1 + } +} + +Write-Host " [OK] S3 repository: $($S3_REPO.Name)" + +# ============================================================ +# GET ALL SOURCE BACKUP JOBS +# ============================================================ + +Write-Host "" +Write-Host "Collecting source backup jobs..." + +$SOURCE_JOBS = @() + +# Get regular backup jobs (VM, etc.) +try { + $VM_JOBS = @(Get-VBRJob -WarningAction SilentlyContinue | Where-Object { + $_.JobType -eq 'Backup' -or $_.JobType -eq 'EpAgentBackup' -or + $_.JobType -eq 'EpAgentPolicy' -or $_.JobType -eq 'EpAgentManagement' + }) + if ($VM_JOBS.Count -gt 0) { + Write-Host " Get-VBRJob: $($VM_JOBS.Count) backup jobs" + foreach ($J in $VM_JOBS) { Write-Host " $($J.Name) ($($J.JobType))" } + $SOURCE_JOBS += $VM_JOBS + } +} catch { + Write-Warning " Get-VBRJob failed: $_" +} + +# Get computer/agent backup jobs +try { + $AGENT_JOBS = @(Get-VBRComputerBackupJob -ErrorAction SilentlyContinue) + if ($AGENT_JOBS.Count -gt 0) { + Write-Host " Get-VBRComputerBackupJob: $($AGENT_JOBS.Count) agent jobs" + foreach ($J in $AGENT_JOBS) { Write-Host " $($J.Name)" } + $SOURCE_JOBS += $AGENT_JOBS + } +} catch { + Write-Warning " Get-VBRComputerBackupJob failed: $_" +} + +# Filter out any backup copy jobs from the source list (don't copy a copy) +$SOURCE_JOBS = @($SOURCE_JOBS | Where-Object { + $_.JobType -ne 'SimpleBackupCopyPolicy' -and + $_.JobType -ne 'BackupSync' -and + $_.JobType -ne 'SimpleBackupCopyWorker' -and + -not $_.IsBackupCopy +}) + +if ($SOURCE_JOBS.Count -eq 0) { + Write-Error "No source backup jobs found to copy." + Stop-Transcript + exit 1 +} + +Write-Host " Total source jobs: $($SOURCE_JOBS.Count)" + +# ============================================================ +# CHECK FOR EXISTING S3 COPY JOB +# ============================================================ + +Write-Host "" +Write-Host "Checking for existing S3 copy job..." + +$EXISTING_COPY_JOB = $null +try { + $ALL_COPY_JOBS = @(Get-VBRBackupCopyJob -ErrorAction Stop) + foreach ($CJ in $ALL_COPY_JOBS) { + if ($null -ne $CJ.TargetRepository -and $CJ.TargetRepository.Name -eq $S3_REPO.Name) { + $EXISTING_COPY_JOB = $CJ + break + } + } +} catch { + Write-Warning " Get-VBRBackupCopyJob failed: $_" +} + +if ($EXISTING_COPY_JOB) { + # ============================================================ + # UPDATE EXISTING COPY JOB - add missing source jobs + # ============================================================ + + Write-Host " [OK] Found existing copy job: $($EXISTING_COPY_JOB.Name)" + Write-Host "" + + # Get currently linked source job IDs + $LINKED_IDS = @() + try { + if ($EXISTING_COPY_JOB.LinkedJobIds) { + $LINKED_IDS = @($EXISTING_COPY_JOB.LinkedJobIds) + } + } catch { } + + # Also check via BackupJob property + if ($LINKED_IDS.Count -eq 0) { + try { + if ($EXISTING_COPY_JOB.BackupJob) { + $LINKED_IDS = @($EXISTING_COPY_JOB.BackupJob | ForEach-Object { $_.Id }) + } + } catch { } + } + + Write-Host " Currently linked: $($LINKED_IDS.Count) jobs" + foreach ($LID in $LINKED_IDS) { + $LINKED_NAME = ($SOURCE_JOBS | Where-Object { $_.Id -eq $LID }).Name + if (-not $LINKED_NAME) { $LINKED_NAME = $LID } + Write-Host " $LINKED_NAME" + } + + # Find jobs NOT yet linked + $MISSING_JOBS = @($SOURCE_JOBS | Where-Object { $LINKED_IDS -notcontains $_.Id }) + + if ($MISSING_JOBS.Count -eq 0) { + Write-Host "" + Write-Host " All backup jobs are already linked." + } else { + Write-Host "" + Write-Host " Missing from copy job ($($MISSING_JOBS.Count)):" + foreach ($MJ in $MISSING_JOBS) { Write-Host " $($MJ.Name) ($($MJ.JobType))" } + + Write-Host "" + Write-Host " Updating copy job with all source jobs..." + try { + Set-VBRBackupCopyJob -Job $EXISTING_COPY_JOB -BackupJob $SOURCE_JOBS + Write-Host " [OK] Copy job updated with $($SOURCE_JOBS.Count) source jobs." + } catch { + Write-Warning " Set-VBRBackupCopyJob failed: $_" + } + } + + $COPY_JOB = $EXISTING_COPY_JOB + + # Rename to match our naming pattern if needed + $REPO_SHORT = $S3_REPO.Name + if ("S3 Copy - $REPO_SHORT".Length -gt 50) { + $REPO_SHORT = $REPO_SHORT.Substring(0, 50 - 10) + } + $EXPECTED_NAME = "S3 Copy - $REPO_SHORT" + if ($COPY_JOB.Name -ne $EXPECTED_NAME) { + try { + Write-Host " Renaming '$($COPY_JOB.Name)' -> '$EXPECTED_NAME'..." + Set-VBRBackupCopyJob -Job $COPY_JOB -Name $EXPECTED_NAME + Write-Host " [OK] Renamed." + } catch { + Write-Warning " Failed to rename: $_" + } + } + +} else { + # ============================================================ + # CREATE NEW COPY JOB + # ============================================================ + + Write-Host " No existing S3 copy job found. Creating one..." + Write-Host "" + + $REPO_SHORT = $S3_REPO.Name + if ("S3 Copy - $REPO_SHORT".Length -gt 50) { + $REPO_SHORT = $REPO_SHORT.Substring(0, 50 - 10) + } + $COPY_JOB_NAME = "S3 Copy - $REPO_SHORT" + + Write-Host " Job name: $COPY_JOB_NAME" + Write-Host " Target repo: $($S3_REPO.Name)" + Write-Host " Source jobs: $($SOURCE_JOBS.Count)" + Write-Host " Retention: $RETENTION_DAYS days" + Write-Host "" + + try { + $COPY_JOB = Add-VBRBackupCopyJob ` + -Name $COPY_JOB_NAME ` + -Description "$env:DESCRIPTION" ` + -BackupJob $SOURCE_JOBS ` + -TargetRepository $S3_REPO ` + -DirectOperation ` + -Mode Periodic ` + -RetentionType RestoreDays ` + -RetentionNumber $RETENTION_DAYS + + Write-Host " [OK] Copy job created: $($COPY_JOB.Name)" + } catch { + Write-Error "Failed to create copy job: $_" + Stop-Transcript + exit 1 + } +} + +# ============================================================ +# APPLY SETTINGS (runs for both new and existing jobs) +# ============================================================ + +$ConfirmPreference = 'None' + +# Schedule: Mon-Fri 10 PM - 5 AM, Sat-Sun all day +try { + Write-Host "" + Write-Host "Applying schedule..." + $WEEKNIGHT_WINDOW = New-VBRBackupWindowOptions -FromDay Monday -FromHour 22 -ToDay Friday -ToHour 5 + $PERIOD_OPTS = New-VBRPeriodicallyOptions -PeriodicallyKind Hours -FullPeriod 1 -PeriodicallySchedule $WEEKNIGHT_WINDOW + $SCHEDULE = New-VBRServerScheduleOptions -Type Periodically -PeriodicallyOptions $PERIOD_OPTS + Set-VBRBackupCopyJob -Job $COPY_JOB -ScheduleOptions $SCHEDULE + Write-Host " [OK] Schedule: Mon-Fri 10 PM - 5 AM, Sat-Sun all day." +} catch { + Write-Warning " Failed to set schedule: $_" +} + +# Encryption +try { + Write-Host "" + Write-Host "Applying encryption..." + $ENCRYPTION_KEY = Get-VBREncryptionKey | Select-Object -First 1 + + if ($ENCRYPTION_KEY) { + Write-Host " Using key: $($ENCRYPTION_KEY.Id)" + $STORAGE_OPTS = New-VBRBackupCopyJobStorageOptions ` + -CompressionLevel Auto ` + -StorageOptimizationType Automatic ` + -EnableEncryption ` + -EncryptionKey $ENCRYPTION_KEY + Set-VBRBackupCopyJob -Job $COPY_JOB -StorageOptions $STORAGE_OPTS + Write-Host " [OK] Encryption enabled." + } else { + Write-Warning " No encryption key found. Configure encryption manually." + } +} catch { + Write-Warning " Failed to set encryption: $_" +} + +# Enable +try { + Write-Host "" + Write-Host "Enabling copy job..." + Enable-VBRBackupCopyJob -Job $COPY_JOB + Write-Host " [OK] Copy job enabled." +} catch { + Write-Warning " Failed to enable: $_" +} + +Write-Host "" +Write-Host "=== Script complete ===" + +Stop-Transcript diff --git a/bdr-veeam/veeam-inventory.ps1 b/bdr-veeam/veeam-inventory.ps1 new file mode 100644 index 0000000..65c2f99 --- /dev/null +++ b/bdr-veeam/veeam-inventory.ps1 @@ -0,0 +1,684 @@ +## PLEASE SET THE FOLLOWING ENVIRONMENT VARIABLES IN YOUR RMM BEFORE RUNNING +## $env:CUSTOM_FIELD_S3_BUCKET_NAME - Text field: last used S3 bucket name +## $env:CUSTOM_FIELD_S3_BUCKET_SIZE - Text field: last used S3 bucket size (human readable) +## $env:CUSTOM_FIELD_S3_INVENTORY - WYSIWYG field: HTML table of all S3 repos +## $env:CUSTOM_FIELD_ORPHANS_FOUND - Integer field: 1 if orphaned backups found, 0 if not +## $env:CUSTOM_FIELD_ORPHANED_BACKUPS - WYSIWYG field: HTML table of orphaned/stale backup data (all repos) +## $env:CUSTOM_FIELD_FAILED_BACKUP - Checkbox field: checked if any backup job's last run failed +## $env:CUSTOM_FIELD_FAILED_BACKUPS - WYSIWYG field: HTML table of jobs whose last run failed/warned +## $env:CUSTOM_FIELD_S3_COPY_MISSING - Checkbox field: checked if S3 copy job is missing or incomplete +## $env:ORPHAN_DAYS_THRESHOLD - Days since last backup to consider orphaned (default: 30) +## $env:DESCRIPTION - Ticket # or initials for audit trail +## $env:RMM - Set to 1 when running from RMM platform +## $env:RMM_SCRIPT_PATH - Script path provided by RMM (used for log location) + +# ============================================================ +# PS7 BOOTSTRAP +# Veeam.Backup.PowerShell in Veeam 12.x requires PowerShell 7+. +# If running under PS5, re-launch this script in pwsh.exe. +# ============================================================ +if ($PSVersionTable.PSVersion.Major -lt 7) { + # Always prefer 64-bit PS7. Veeam's native SQLite DLL is x64 only. + $PWSH_PATH = "$env:ProgramFiles\PowerShell\7\pwsh.exe" + if (-not (Test-Path $PWSH_PATH)) { + $PWSH_CANDIDATE = Get-Command pwsh.exe -ErrorAction SilentlyContinue + $PWSH_PATH = if ($PWSH_CANDIDATE) { $PWSH_CANDIDATE.Source } else { $null } + } + if (Test-Path $PWSH_PATH) { + Write-Host "PowerShell $($PSVersionTable.PSVersion) detected. Re-launching in PowerShell 7 at: $PWSH_PATH" + $PS_ARGS = @('-NonInteractive', '-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', $MyInvocation.MyCommand.Path) + & $PWSH_PATH @PS_ARGS + exit $LASTEXITCODE + } else { + Write-Warning "PowerShell 7 (pwsh.exe) not found. Attempting to continue in PS$($PSVersionTable.PSVersion.Major), but Veeam module load may fail." + } +} + +# Fix PS7.4+ / Veeam SQLite conflict. +if ($PSVersionTable.PSVersion.Major -ge 7) { + $VEEAM_BACKUP_DIR = "$env:ProgramFiles\Veeam\Backup and Replication\Backup" + $VEEAM_RUNTIMES = "$env:ProgramFiles\Veeam\Backup and Replication\Backup\runtimes\win-x64\native" + foreach ($DIR in @($VEEAM_RUNTIMES, $VEEAM_BACKUP_DIR)) { + if ((Test-Path $DIR) -and $env:PATH -notlike "*$DIR*") { + $env:PATH = "$DIR;$env:PATH" + } + } + if (Test-Path $VEEAM_BACKUP_DIR) { + $null = [System.AppDomain]::CurrentDomain.add_AssemblyResolve({ + param($sender, $args) + if (-not $args.Name) { return $null } + $ASSEMBLY_NAME = [System.Reflection.AssemblyName]::new($args.Name) + $VEEAM_DLL = Join-Path $VEEAM_BACKUP_DIR "$($ASSEMBLY_NAME.Name).dll" + if (Test-Path $VEEAM_DLL) { + return [System.Reflection.Assembly]::LoadFrom($VEEAM_DLL) + } + return $null + }) + } +} + +# ============================================================ +# HELPER FUNCTIONS +# ============================================================ + +function Format-SizeBytes { + param([long]$Bytes) + if ($Bytes -ge 1TB) { return "{0:N2} TB" -f ($Bytes / 1TB) } + if ($Bytes -ge 1GB) { return "{0:N2} GB" -f ($Bytes / 1GB) } + if ($Bytes -ge 1MB) { return "{0:N2} MB" -f ($Bytes / 1MB) } + if ($Bytes -ge 1KB) { return "{0:N2} KB" -f ($Bytes / 1KB) } + return "$Bytes B" +} + +function ConvertTo-SafeHtml { + param([string]$Text) + if (-not $Text) { return "" } + return $Text.Replace("&", "&").Replace("<", "<").Replace(">", ">").Replace('"', """) +} + +function Build-HtmlTable { + param( + [string[]]$Headers, + [System.Collections.Generic.List[string[]]]$Rows, + [string]$ScanTimestamp, + [string]$EmptyMessage = "No data found." + ) + + $HEADER_CELLS = "" + foreach ($H in $Headers) { + $HEADER_CELLS += "
Last scanned: $ScanTimestamp
+$SUMMARY_LINE
+