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 += " $(ConvertTo-SafeHtml $H)`n" + } + + $DATA_ROWS = "" + if ($Rows.Count -eq 0) { + $DATA_ROWS = @" + + + $EmptyMessage + + +"@ + } else { + $ROW_INDEX = 0 + foreach ($ROW in $Rows) { + $BG = if ($ROW_INDEX % 2 -eq 0) { "#ffffff" } else { "#f9fafb" } + $CELLS = "" + foreach ($CELL in $ROW) { + $CELLS += " $(ConvertTo-SafeHtml $CELL)`n" + } + $DATA_ROWS += @" + +$CELLS +"@ + $ROW_INDEX++ + } + } + + return @" +
+ + + +$HEADER_CELLS + + +$DATA_ROWS +
+

Last scanned: $ScanTimestamp

+
+"@ +} + +function Set-NinjaField { + param([string]$FieldName, [string]$Value) + if (-not $FieldName) { return } + $NINJA_CMD = Get-Command "Ninja-Property-Set" -ErrorAction SilentlyContinue + if ($NINJA_CMD) { + try { + Ninja-Property-Set $FieldName $Value + Write-Host " [OK] $FieldName updated." + } catch { + Write-Warning " Failed to write $FieldName : $_" + } + } else { + Write-Host " [SKIP] Ninja-Property-Set not available. $FieldName = $($Value.Substring(0, [Math]::Min(100, $Value.Length)))..." + } +} + +# ============================================================ +# SECTION 1: RMM VARIABLE DECLARATION +# ============================================================ + +$SCRIPT_LOG_NAME = "veeam-s3-bucket-inventory.log" + +# ============================================================ +# SECTION 2: INPUT HANDLING +# ============================================================ + +if ($env:RMM -ne "1") { + # Interactive mode + $VALID_INPUT = 0 + while ($VALID_INPUT -ne 1) { + $env:DESCRIPTION = Read-Host "Please enter the ticket # and/or your initials (used for audit trail)" + if ($env:DESCRIPTION) { + $VALID_INPUT = 1 + } else { + Write-Host "Invalid input. Please try again." + } + } + + $env:CUSTOM_FIELD_S3_BUCKET_NAME = Read-Host "NinjaOne text field for S3 bucket name (blank to skip)" + $env:CUSTOM_FIELD_S3_BUCKET_SIZE = Read-Host "NinjaOne text field for S3 bucket size (blank to skip)" + $env:CUSTOM_FIELD_S3_INVENTORY = Read-Host "NinjaOne WYSIWYG field for S3 inventory table (blank to skip)" + $env:CUSTOM_FIELD_ORPHANS_FOUND = Read-Host "NinjaOne integer field for orphans found flag (blank to skip)" + $env:CUSTOM_FIELD_ORPHANED_BACKUPS = Read-Host "NinjaOne WYSIWYG field for orphaned backups table (blank to skip)" + $env:CUSTOM_FIELD_FAILED_BACKUP = Read-Host "NinjaOne checkbox field for failed backup flag (blank to skip)" + $env:CUSTOM_FIELD_FAILED_BACKUPS = Read-Host "NinjaOne WYSIWYG field for failed backups table (blank to skip)" + + $LOG_PATH = "$env:WINDIR\logs\$SCRIPT_LOG_NAME" + +} else { + # RMM mode + 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) { + Write-Host "DESCRIPTION is null. This was most likely run automatically from the RMM with no description passed." + $env:DESCRIPTION = "No Description" + } +} + +# ============================================================ +# SECTION 3: SCRIPT LOGIC +# ============================================================ + +Start-Transcript -Path $LOG_PATH + +Write-Host "=== Veeam Inventory ===" +Write-Host "Description: $env:DESCRIPTION" +Write-Host "Log path: $LOG_PATH" +Write-Host "RMM mode: $($env:RMM -eq '1')" +Write-Host "PS version: $($PSVersionTable.PSVersion)" +Write-Host "" + +# ------------------------------------------------------------ +# Load Veeam PowerShell 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. Is the Veeam console installed on this machine?" +} + +# ------------------------------------------------------------ +# Collect data from Veeam +# ------------------------------------------------------------ +Write-Host "" +Write-Host "Querying Veeam data..." + +$SCAN_TIMESTAMP = Get-Date -Format "yyyy-MM-dd HH:mm:ss" + +# Get object storage repos +$OBJECT_STORAGE_REPOS = $null +try { + $OBJECT_STORAGE_REPOS = Get-VBRObjectStorageRepository -ErrorAction Stop + Write-Host " [OK] Found $($OBJECT_STORAGE_REPOS.Count) object storage repositories." +} catch { + Write-Warning " Get-VBRObjectStorageRepository failed: $_" +} +if (-not $OBJECT_STORAGE_REPOS) { + try { + $OBJECT_STORAGE_REPOS = Get-VBRBackupRepository | Where-Object { + $_.Type -like "AmazonS3*" -or $_.Type -eq "S3Compatible" -or $_.Type -match "S3" + } + Write-Host " [OK] Fallback found $($OBJECT_STORAGE_REPOS.Count) S3-compatible repositories." + } catch { + Write-Warning " Fallback query failed: $_" + } +} + +# Get backup copy jobs (TargetRepository has populated AmazonCompatibleOptions) +Write-Host " Querying backup copy jobs..." +$COPY_JOBS = @() +$COPY_JOB_REPOS = @{} +try { + $COPY_JOBS = @(Get-VBRBackupCopyJob -ErrorAction Stop) + foreach ($CJ in $COPY_JOBS) { + if ($null -ne $CJ.TargetRepository) { + $TR = $CJ.TargetRepository + $COPY_JOB_REPOS[$TR.Id.ToString()] = $TR + } + } + Write-Host " [OK] Found $($COPY_JOBS.Count) backup copy jobs." +} catch { + Write-Warning " Get-VBRBackupCopyJob failed: $_" +} + +# Get all backups and enumerate child backups for S3 repos +Write-Host " Enumerating backups and child backups..." +$ALL_BACKUPS = @() +try { $ALL_BACKUPS = @(Get-VBRBackup) } catch { Write-Warning " Get-VBRBackup failed: $_" } + +# Orphan threshold: child backups with no new data in this many days are stale +$ORPHAN_DAYS = 30 +if ($env:ORPHAN_DAYS_THRESHOLD) { + try { $ORPHAN_DAYS = [int]$env:ORPHAN_DAYS_THRESHOLD } catch {} +} +$ORPHAN_CUTOFF = (Get-Date).AddDays(-$ORPHAN_DAYS) +Write-Host " Orphan threshold: $ORPHAN_DAYS days (before $($ORPHAN_CUTOFF.ToString('yyyy-MM-dd')))" + +# S3 repo ID lookup for bucket name/size calculations +$SIZE_BY_REPO_ID = @{} +$S3_CHILDREN_BY_REPO_ID = @{} +$S3_REPO_IDS = @{} +foreach ($REPO in $OBJECT_STORAGE_REPOS) { + $S3_REPO_IDS[$REPO.Id.ToString()] = $true +} + +# Build set of all active job IDs (any job that currently exists in Veeam) +$ACTIVE_JOB_IDS = @{} +try { + $ALL_JOBS = @(Get-VBRJob -WarningAction SilentlyContinue) + foreach ($J in $ALL_JOBS) { $ACTIVE_JOB_IDS[$J.Id.ToString()] = $true } +} catch { } +foreach ($CJ in $COPY_JOBS) { $ACTIVE_JOB_IDS[$CJ.Id.ToString()] = $true } +try { + $COMPUTER_JOBS = @(Get-VBRComputerBackupJob -ErrorAction SilentlyContinue) + foreach ($J in $COMPUTER_JOBS) { $ACTIVE_JOB_IDS[$J.Id.ToString()] = $true } +} catch { } +Write-Host " Active jobs found: $($ACTIVE_JOB_IDS.Count)" + +# Get all repo names for display +$REPO_NAMES = @{} +try { + $ALL_REPOS = @(Get-VBRBackupRepository) + foreach ($R in $ALL_REPOS) { $REPO_NAMES[$R.Id.ToString()] = $R.Name } +} catch { } +foreach ($REPO in $OBJECT_STORAGE_REPOS) { $REPO_NAMES[$REPO.Id.ToString()] = $REPO.Name } + +# Enumerate ALL backups for orphan detection + S3 size calculation +$ALL_ORPHAN_ENTRIES = [System.Collections.Generic.List[hashtable]]::new() + +foreach ($BACKUP in $ALL_BACKUPS) { + $RID = $BACKUP.RepositoryId.ToString() + $IS_S3 = $S3_REPO_IDS.ContainsKey($RID) + $REPO_DISPLAY = if ($REPO_NAMES.ContainsKey($RID)) { $REPO_NAMES[$RID] } else { $RID.Substring(0, 8) } + + if ($IS_S3) { + if (-not $SIZE_BY_REPO_ID.ContainsKey($RID)) { $SIZE_BY_REPO_ID[$RID] = [long]0 } + if (-not $S3_CHILDREN_BY_REPO_ID.ContainsKey($RID)) { $S3_CHILDREN_BY_REPO_ID[$RID] = [System.Collections.Generic.List[hashtable]]::new() } + } + + # Check if this backup's job still exists + $JOB_EXISTS = $ACTIVE_JOB_IDS.ContainsKey($BACKUP.JobId.ToString()) + + # Expand per-VM containers into child backups, or use the backup itself + $ENTRIES_TO_CHECK = @($BACKUP) + try { + if ($BACKUP.IsTruePerVmContainer) { + $CHILDREN = $BACKUP.FindChildBackups() + if ($CHILDREN) { $ENTRIES_TO_CHECK = @($CHILDREN) } + } + } catch { } + + foreach ($B in $ENTRIES_TO_CHECK) { + $CHILD_SIZE = [long]0 + $LAST_POINT_TIME = $null + try { + $STORAGES = $B.GetAllStorages() + foreach ($ST in $STORAGES) { + $ST_SIZE = [long]0 + try { if ($ST.Stats -and $ST.Stats.BackupSize -gt 0) { $ST_SIZE = [long]$ST.Stats.BackupSize } } catch {} + if ($ST_SIZE -eq 0) { try { if ($ST.BackupSize -gt 0) { $ST_SIZE = [long]$ST.BackupSize } } catch {} } + $CHILD_SIZE += $ST_SIZE + } + $LATEST_STORAGE = $STORAGES | Sort-Object CreationTime -Descending | Select-Object -First 1 + if ($LATEST_STORAGE) { $LAST_POINT_TIME = $LATEST_STORAGE.CreationTime } + } catch { } + + # S3 size tracking + if ($IS_S3) { $SIZE_BY_REPO_ID[$RID] += $CHILD_SIZE } + + # Extract machine name + $MACHINE_NAME = $B.Name + if ($MACHINE_NAME -match '\\(.+?) - (.+)$') { + $MACHINE_NAME = $Matches[2].Trim() + } elseif ($MACHINE_NAME -match ' - (.+)$') { + $MACHINE_NAME = $Matches[1].Trim() + } + + $ENTRY = @{ + Name = $B.Name + MachineName = $MACHINE_NAME + Size = $CHILD_SIZE + SizeDisplay = if ($CHILD_SIZE -gt 0) { Format-SizeBytes -Bytes $CHILD_SIZE } else { "N/A" } + LastPoint = $LAST_POINT_TIME + LastPointStr = if ($LAST_POINT_TIME) { $LAST_POINT_TIME.ToString("yyyy-MM-dd") } else { "N/A" } + JobExists = $JOB_EXISTS + RepoName = $REPO_DISPLAY + IsS3 = $IS_S3 + } + + # Track S3 children for last-used bucket detection + if ($IS_S3) { $S3_CHILDREN_BY_REPO_ID[$RID].Add($ENTRY) } + + # Orphan detection: no active job OR stale (applies to ALL repos) + $REASON = $null + if (-not $JOB_EXISTS) { + $REASON = "No active job" + } elseif ($null -ne $LAST_POINT_TIME -and $LAST_POINT_TIME -lt $ORPHAN_CUTOFF) { + $DAYS_AGO = [int]((Get-Date) - $LAST_POINT_TIME).TotalDays + $REASON = "Stale ($DAYS_AGO days)" + } + + if ($REASON) { + $ENTRY.Reason = $REASON + $ALL_ORPHAN_ENTRIES.Add($ENTRY) + } + } +} + +# ------------------------------------------------------------ +# Build inventory rows + find the "last used" S3 bucket +# ------------------------------------------------------------ +Write-Host "" +Write-Host "Processing repositories..." + +$REPO_ROWS = [System.Collections.Generic.List[string[]]]::new() + +$LAST_USED_BUCKET = "N/A" +$LAST_USED_SIZE = "N/A" +$LAST_USED_SIZE_BYTES = [long]0 +$LAST_USED_TIME = [datetime]::MinValue + +foreach ($REPO in $OBJECT_STORAGE_REPOS) { + $REPO_ID = $REPO.Id.ToString() + Write-Host " Processing: $($REPO.Name)" + + # --- BUCKET NAME --- + $BUCKET_NAME = "N/A" + if ($COPY_JOB_REPOS.ContainsKey($REPO_ID)) { + $CJ_TR = $COPY_JOB_REPOS[$REPO_ID] + try { + $CJ_OPTS = $CJ_TR.AmazonCompatibleOptions + if ($null -eq $CJ_OPTS) { $CJ_OPTS = $CJ_TR.Options } + if ($null -ne $CJ_OPTS -and $CJ_OPTS.BucketName) { + $BUCKET_NAME = $CJ_OPTS.BucketName + } + } catch { } + } + if ($BUCKET_NAME -eq "N/A") { + try { + $OPTS = $REPO.AmazonCompatibleOptions + if ($null -eq $OPTS) { $OPTS = $REPO.AmazonOptions } + if ($null -eq $OPTS) { $OPTS = $REPO.Options } + if ($null -ne $OPTS -and $OPTS.BucketName) { $BUCKET_NAME = $OPTS.BucketName } + } catch { } + } + if ($BUCKET_NAME -eq "N/A") { + try { if ($REPO.BucketName) { $BUCKET_NAME = $REPO.BucketName } } catch {} + } + + # --- USED SPACE --- + $USED_SPACE_DISPLAY = "N/A" + $USED_SPACE_BYTES = [long]0 + try { if ($null -ne $REPO.UsedSpace -and $REPO.UsedSpace -gt 0) { $USED_SPACE_BYTES = [long]$REPO.UsedSpace; $USED_SPACE_DISPLAY = Format-SizeBytes -Bytes $USED_SPACE_BYTES } } catch {} + if ($USED_SPACE_DISPLAY -eq "N/A" -and $SIZE_BY_REPO_ID.ContainsKey($REPO_ID) -and $SIZE_BY_REPO_ID[$REPO_ID] -gt 0) { + $USED_SPACE_BYTES = $SIZE_BY_REPO_ID[$REPO_ID] + $USED_SPACE_DISPLAY = Format-SizeBytes -Bytes $USED_SPACE_BYTES + } + + # --- REPO TYPE --- + $REPO_TYPE = "N/A" + try { if ($REPO.Type) { $REPO_TYPE = $REPO.Type.ToString() } } catch {} + try { if ($REPO_TYPE -eq "N/A" -and $REPO.TypeDisplay) { $REPO_TYPE = $REPO.TypeDisplay } } catch {} + + $REPO_ROWS.Add(@($BUCKET_NAME, $REPO.Name, $REPO_TYPE, $USED_SPACE_DISPLAY)) + + # --- LAST USED tracking --- + $REPO_LATEST_TIME = [datetime]::MinValue + if ($S3_CHILDREN_BY_REPO_ID.ContainsKey($REPO_ID)) { + foreach ($CHILD in $S3_CHILDREN_BY_REPO_ID[$REPO_ID]) { + if ($null -ne $CHILD.LastPoint -and $CHILD.LastPoint -gt $REPO_LATEST_TIME) { + $REPO_LATEST_TIME = $CHILD.LastPoint + } + } + } + if ($REPO_LATEST_TIME -gt $LAST_USED_TIME) { + $LAST_USED_TIME = $REPO_LATEST_TIME + $LAST_USED_BUCKET = $BUCKET_NAME + $LAST_USED_SIZE = $USED_SPACE_DISPLAY + $LAST_USED_SIZE_BYTES = $USED_SPACE_BYTES + } +} + +# --- ORPHAN ROWS (all repos) --- +$ORPHAN_ROWS = [System.Collections.Generic.List[string[]]]::new() +$ORPHAN_COUNT = $ALL_ORPHAN_ENTRIES.Count +foreach ($ENTRY in $ALL_ORPHAN_ENTRIES) { + $ORPHAN_ROWS.Add(@($ENTRY.MachineName, $ENTRY.SizeDisplay, $ENTRY.LastPointStr, $ENTRY.Reason, $ENTRY.RepoName)) +} + +# ------------------------------------------------------------ +# Failed backup detection +# ------------------------------------------------------------ +Write-Host " Checking for jobs whose last run failed..." + +$FAILED_ROWS = [System.Collections.Generic.List[string[]]]::new() +$FAILED_COUNT = 0 + +try { + # Group all completed sessions by JobId, keep only the most recent per job + $ALL_SESSIONS = Get-VBRBackupSession -ErrorAction Stop | Where-Object { $_.IsCompleted } + $LATEST_BY_JOB = @{} + foreach ($SESS in $ALL_SESSIONS) { + $JID = $SESS.JobId.ToString() + if (-not $LATEST_BY_JOB.ContainsKey($JID) -or $SESS.EndTime -gt $LATEST_BY_JOB[$JID].EndTime) { + $LATEST_BY_JOB[$JID] = $SESS + } + } + + # Only flag jobs whose MOST RECENT session is failed/warning + # (if the job re-ran and succeeded, the failure is resolved) + foreach ($SESS in $LATEST_BY_JOB.Values) { + if ($SESS.Result -ne "Failed" -and $SESS.Result -ne "Warning") { continue } + + $JOB_NAME = $SESS.OrigJobName + if (-not $JOB_NAME) { $JOB_NAME = $SESS.JobName } + + $RESULT_STR = $SESS.Result.ToString() + $END_TIME_STR = $SESS.EndTime.ToString("yyyy-MM-dd HH:mm") + $DURATION_STR = if ($SESS.Progress -and $SESS.Progress.Duration) { $SESS.Progress.Duration.ToString("hh\:mm\:ss") } else { "N/A" } + + $FAILED_ROWS.Add(@($JOB_NAME, $RESULT_STR, $END_TIME_STR, $DURATION_STR)) + $FAILED_COUNT++ + } + Write-Host " [OK] Found $FAILED_COUNT jobs with failed/warning last run." +} catch { + Write-Warning " Get-VBRBackupSession failed: $_" +} + +# ------------------------------------------------------------ +# S3 copy job detection +# ------------------------------------------------------------ +$S3_COPY_MISSING = $false +$S3_COPY_MISSING_REASON = "" + +if ($OBJECT_STORAGE_REPOS -and $OBJECT_STORAGE_REPOS.Count -gt 0) { + Write-Host " Checking S3 backup copy job status..." + + # Get all copy jobs targeting S3 repos + $S3_COPY_JOBS = @() + foreach ($CJ in $COPY_JOBS) { + if ($null -ne $CJ.TargetRepository -and $S3_REPO_IDS.ContainsKey($CJ.TargetRepository.Id.ToString())) { + $S3_COPY_JOBS += $CJ + } + } + + if ($S3_COPY_JOBS.Count -eq 0) { + $S3_COPY_MISSING = $true + $S3_COPY_MISSING_REASON = "No S3 backup copy job exists" + Write-Host " [!] No S3 copy job found." + } else { + # Check if all backup jobs are linked to the copy job + $LINKED_IDS = @() + foreach ($CJ in $S3_COPY_JOBS) { + try { + if ($CJ.LinkedJobIds) { $LINKED_IDS += @($CJ.LinkedJobIds) } + } catch { } + try { + if ($CJ.BackupJob) { $LINKED_IDS += @($CJ.BackupJob | ForEach-Object { $_.Id }) } + } catch { } + } + $LINKED_IDS = @($LINKED_IDS | Select-Object -Unique) + + # Get all non-copy backup jobs + $ALL_SOURCE_JOBS = @($ALL_BACKUPS | Where-Object { + -not $_.IsBackupCopy -and + $_.JobType -ne 'SimpleBackupCopyPolicy' -and + $_.JobType -ne 'BackupSync' -and + $_.JobType -ne 'SimpleBackupCopyWorker' + }) + # Also check computer backup jobs + try { + $COMPUTER_JOBS = @(Get-VBRComputerBackupJob -ErrorAction SilentlyContinue) + $COMPUTER_JOB_IDS = @($COMPUTER_JOBS | ForEach-Object { $_.Id }) + } catch { $COMPUTER_JOB_IDS = @() } + + # Combine all source job IDs + $ALL_SOURCE_IDS = @() + $ALL_SOURCE_IDS += @($ALL_SOURCE_JOBS | ForEach-Object { $_.JobId } | Select-Object -Unique) + $ALL_SOURCE_IDS += $COMPUTER_JOB_IDS + $ALL_SOURCE_IDS = @($ALL_SOURCE_IDS | Select-Object -Unique) + + $UNLINKED = @($ALL_SOURCE_IDS | Where-Object { $LINKED_IDS -notcontains $_ }) + + if ($UNLINKED.Count -gt 0) { + $S3_COPY_MISSING = $true + $S3_COPY_MISSING_REASON = "$($UNLINKED.Count) backup job(s) not in S3 copy job" + Write-Host " [!] $($UNLINKED.Count) backup jobs missing from S3 copy job." + } else { + Write-Host " [OK] S3 copy job exists with all backup jobs linked." + } + } +} else { + $S3_COPY_MISSING = $true + $S3_COPY_MISSING_REASON = "No S3 repository configured" + Write-Host " [!] No S3 repository found." +} + +# If only one S3 repo exists and last-used tracking didn't match via time, use it directly +if ($LAST_USED_BUCKET -eq "N/A" -and $REPO_ROWS.Count -eq 1) { + $LAST_USED_BUCKET = $REPO_ROWS[0][0] + $LAST_USED_SIZE = $REPO_ROWS[0][3] +} + +# ------------------------------------------------------------ +# Log summary +# ------------------------------------------------------------ +Write-Host "" +Write-Host "=== Results ===" +Write-Host " Last used S3 bucket: $LAST_USED_BUCKET" +Write-Host " Last used S3 size: $LAST_USED_SIZE" +Write-Host " S3 repos found: $($REPO_ROWS.Count)" +Write-Host " Orphaned backups: $ORPHAN_COUNT" +Write-Host " Failed backups: $FAILED_COUNT" +Write-Host " S3 copy job missing: $S3_COPY_MISSING $(if ($S3_COPY_MISSING_REASON) { "($S3_COPY_MISSING_REASON)" })" +Write-Host "" + +if ($REPO_ROWS.Count -gt 0) { + Write-Host "=== Inventory ===" + foreach ($ROW in $REPO_ROWS) { + Write-Host " Bucket: $($ROW[0]) | Repo: $($ROW[1]) | Type: $($ROW[2]) | Size: $($ROW[3])" + } + Write-Host "" +} + +if ($ORPHAN_COUNT -gt 0) { + Write-Host "=== Orphaned Backups ===" + foreach ($ROW in $ORPHAN_ROWS) { + Write-Host " Machine: $($ROW[0]) | Size: $($ROW[1]) | Last: $($ROW[2]) | Reason: $($ROW[3]) | Repo: $($ROW[4])" + } + Write-Host "" +} + +if ($FAILED_COUNT -gt 0) { + Write-Host "=== Failed Backups ===" + foreach ($ROW in $FAILED_ROWS) { + Write-Host " Job: $($ROW[0]) | Result: $($ROW[1]) | Ended: $($ROW[2]) | Duration: $($ROW[3])" + } + Write-Host "" +} + +# ------------------------------------------------------------ +# Build HTML tables +# ------------------------------------------------------------ +$HTML_INVENTORY = Build-HtmlTable ` + -Headers @("Bucket Name", "Repository Name", "Type", "Used Space") ` + -Rows $REPO_ROWS ` + -ScanTimestamp $SCAN_TIMESTAMP ` + -EmptyMessage "No S3-compatible object storage repositories found." + +$HTML_ORPHANS = Build-HtmlTable ` + -Headers @("Machine", "Size", "Last Backup", "Reason", "Repository") ` + -Rows $ORPHAN_ROWS ` + -ScanTimestamp $SCAN_TIMESTAMP ` + -EmptyMessage "No orphaned backups detected." + +$HTML_FAILED = Build-HtmlTable ` + -Headers @("Job Name", "Result", "Ended", "Duration") ` + -Rows $FAILED_ROWS ` + -ScanTimestamp $SCAN_TIMESTAMP ` + -EmptyMessage "All backup jobs completed successfully on their last run." + +# ------------------------------------------------------------ +# Write to NinjaOne custom fields +# ------------------------------------------------------------ +Write-Host "Writing to NinjaOne custom fields..." + +Set-NinjaField $env:CUSTOM_FIELD_S3_BUCKET_NAME $LAST_USED_BUCKET +Set-NinjaField $env:CUSTOM_FIELD_S3_BUCKET_SIZE $LAST_USED_SIZE +Set-NinjaField $env:CUSTOM_FIELD_S3_INVENTORY $HTML_INVENTORY +Set-NinjaField $env:CUSTOM_FIELD_ORPHANS_FOUND "$([int]($ORPHAN_COUNT -gt 0))" +Set-NinjaField $env:CUSTOM_FIELD_ORPHANED_BACKUPS $HTML_ORPHANS +Set-NinjaField $env:CUSTOM_FIELD_FAILED_BACKUP "$([int]($FAILED_COUNT -gt 0))" +Set-NinjaField $env:CUSTOM_FIELD_FAILED_BACKUPS $HTML_FAILED +Set-NinjaField $env:CUSTOM_FIELD_S3_COPY_MISSING "$([int]$S3_COPY_MISSING)" + +# If no Ninja available and no fields set, dump HTML to console +if (-not (Get-Command "Ninja-Property-Set" -ErrorAction SilentlyContinue)) { + if (-not $env:CUSTOM_FIELD_S3_INVENTORY -and -not $env:CUSTOM_FIELD_ORPHANED_BACKUPS -and -not $env:CUSTOM_FIELD_FAILED_BACKUPS) { + Write-Host "" + Write-Host "=== Inventory HTML ===" + Write-Host $HTML_INVENTORY + Write-Host "" + Write-Host "=== Orphans HTML ===" + Write-Host $HTML_ORPHANS + Write-Host "" + Write-Host "=== Failed Backups HTML ===" + Write-Host $HTML_FAILED + } +} + +Write-Host "" +Write-Host "=== Script complete ===" + +Stop-Transcript diff --git a/bdr-veeam/veeam-remove-management-server.ps1 b/bdr-veeam/veeam-remove-management-server.ps1 deleted file mode 100644 index 59809ee..0000000 --- a/bdr-veeam/veeam-remove-management-server.ps1 +++ /dev/null @@ -1,41 +0,0 @@ -# Script to resolve Veeam agent message "Agent is managed by another Veeam server" - - -# Remove the certificate with the friendly name "Veeam Agent Certificate" from the LocalMachine store without asking for confirmation -Remove-Item -Path $(Get-ChildItem Cert:\LocalMachine\My | Where-Object { $_.FriendlyName -eq "Veeam Agent Certificate" }).PSPath -Confirm:$false - -# Clear the "License" value in the Veeam Agent for Microsoft Windows registry key -Set-ItemProperty -Path "HKLM:\SOFTWARE\Veeam\Veeam Agent for Microsoft Windows\ManagedMode" -Name "License" -Value "" -ErrorAction Ignore - -# Re-enable notifications for Veeam EndPoint Backup by setting the DisableNotifications value to 0 -Set-ItemProperty -Path "HKLM:\SOFTWARE\Veeam\Veeam EndPoint Backup" -Name "DisableNotifications" -Value 0 -ErrorAction Ignore - -# Remove the "SerializedConnectionParams" property from the Veeam EndPoint Backup registry key -Remove-ItemProperty -Path "HKLM:\SOFTWARE\Veeam\Veeam EndPoint Backup" -name "SerializedConnectionParams" -ErrorAction Ignore - -# Remove the "ManagedModeInstallation" property from the Veeam EndPoint Backup registry key -Remove-ItemProperty -Path "HKLM:\SOFTWARE\Veeam\Veeam EndPoint Backup" -name "ManagedModeInstallation" -ErrorAction Ignore - -# Remove the "VbrServerName" property from the Veeam EndPoint Backup registry key -Remove-ItemProperty -Path "HKLM:\SOFTWARE\Veeam\Veeam EndPoint Backup" -name "VbrServerName" -ErrorAction Ignore - -# Remove the "CatchAllOwnership" property from the Veeam EndPoint Backup registry key -Remove-ItemProperty -Path "HKLM:\SOFTWARE\Veeam\Veeam EndPoint Backup" -name "CatchAllOwnership" -ErrorAction Ignore - -# Remove the "VBRServerId" property from the Veeam EndPoint Backup registry key -Remove-ItemProperty -Path "HKLM:\SOFTWARE\Veeam\Veeam EndPoint Backup" -name "VBRServerId" -ErrorAction Ignore - -# Remove the "JobSettings" property from the Veeam EndPoint Backup registry key -Remove-ItemProperty -Path "HKLM:\SOFTWARE\Veeam\Veeam EndPoint Backup" -name "JobSettings" -ErrorAction Ignore - -# Remove the "BackupServerIPAddress" property from the Veeam EndPoint Backup registry key -Remove-ItemProperty -Path "HKLM:\SOFTWARE\Veeam\Veeam EndPoint Backup" -name "BackupServerIPAddress" -ErrorAction Ignore - -# Remove the "RMMProviderMode" property from the Veeam EndPoint Backup registry key -Remove-ItemProperty -Path "HKLM:\SOFTWARE\Veeam\Veeam EndPoint Backup" -name "RMMProviderMode" -ErrorAction Ignore - -# Remove the "ReadonlyMode" property from the Veeam EndPoint Backup registry key -Remove-ItemProperty -Path "HKLM:\SOFTWARE\Veeam\Veeam EndPoint Backup" -name "ReadonlyMode" -ErrorAction Ignore - -# Restart the Veeam Agent for Microsoft Windows service to apply changes -Restart-Service "Veeam Agent for Microsoft Windows" diff --git a/bdr-veeam/veeam-s3-copy-job-diag.ps1 b/bdr-veeam/veeam-s3-copy-job-diag.ps1 new file mode 100644 index 0000000..8fcff41 --- /dev/null +++ b/bdr-veeam/veeam-s3-copy-job-diag.ps1 @@ -0,0 +1,143 @@ +# Diagnostic: S3 copy job - encryption setup +# Run interactively on the BDR server in PS7 + +if ($PSVersionTable.PSVersion.Major -lt 7) { + $PWSH_PATH = "$env:ProgramFiles\PowerShell\7\pwsh.exe" + if (Test-Path $PWSH_PATH) { + & $PWSH_PATH -NoProfile -ExecutionPolicy Bypass -File $MyInvocation.MyCommand.Path + exit $LASTEXITCODE + } +} + +if ($PSVersionTable.PSVersion.Major -ge 7) { + $VBD = "$env:ProgramFiles\Veeam\Backup and Replication\Backup" + $VBR = "$VBD\runtimes\win-x64\native" + foreach ($D in @($VBR, $VBD)) { if ((Test-Path $D) -and $env:PATH -notlike "*$D*") { $env:PATH = "$D;$env:PATH" } } + if (Test-Path $VBD) { + $null = [System.AppDomain]::CurrentDomain.add_AssemblyResolve({ + param($s, $a); if (-not $a.Name) { return $null } + $N = [System.Reflection.AssemblyName]::new($a.Name) + $F = Join-Path $VBD "$($N.Name).dll" + if (Test-Path $F) { return [System.Reflection.Assembly]::LoadFrom($F) }; return $null + }) + } +} + +$ConfirmPreference = 'None' +$MY_MODULE_PATH = "$env:ProgramFiles\Veeam\Backup and Replication\Console\" +$env:PSModulePath = $env:PSModulePath + "$([System.IO.Path]::PathSeparator)$MY_MODULE_PATH" +Get-Module -ListAvailable -Name Veeam.Backup.PowerShell | Import-Module -WarningAction SilentlyContinue + +Write-Host "==========================================" +Write-Host "1: New-VBRBackupCopyJobStorageOptions - all params + enum values" +Write-Host "==========================================" +try { + $CMD = Get-Command New-VBRBackupCopyJobStorageOptions -ErrorAction Stop + foreach ($KEY in ($CMD.Parameters.Keys | Sort-Object)) { + $P = $CMD.Parameters[$KEY] + $LINE = " $KEY ($($P.ParameterType.Name))" + if ($P.ParameterType.IsEnum) { + $LINE += " = $([Enum]::GetNames($P.ParameterType) -join ', ')" + } + if ($P.Attributes | Where-Object { $_ -is [System.Management.Automation.ParameterAttribute] -and $_.Mandatory }) { + $LINE += " [MANDATORY]" + } + Write-Host $LINE + } +} catch { Write-Host " FAILED: $_" } + +Write-Host "" +Write-Host "==========================================" +Write-Host "2: Get encryption key" +Write-Host "==========================================" +$KEY = $null +try { + $KEY = Get-VBREncryptionKey | Select-Object -First 1 + Write-Host " Key: $($KEY.Id) - $($KEY.Description)" +} catch { Write-Host " FAILED: $_" } + +Write-Host "" +Write-Host "==========================================" +Write-Host "3: Try New-VBRBackupCopyJobStorageOptions (minimal)" +Write-Host "==========================================" +try { + Write-Host " Trying with just -EnableEncryption -EncryptionKey..." + $OPTS = New-VBRBackupCopyJobStorageOptions -EnableEncryption -EncryptionKey $KEY + Write-Host " [OK] $($OPTS.GetType().FullName)" + $OPTS | Get-Member -MemberType Property | ForEach-Object { + $PN = $_.Name; try { Write-Host " $PN = $($OPTS.$PN)" } catch {} + } +} catch { Write-Host " FAILED: $_" } + +Write-Host "" +Write-Host "==========================================" +Write-Host "4: Try with all params" +Write-Host "==========================================" + +# Get CompressionLevel enum values +Write-Host " CompressionLevel values:" +try { + $T = [Veeam.Backup.PowerShell.Infos.VBRBackupCopyJobCompressionLevel] + [Enum]::GetNames($T) | ForEach-Object { Write-Host " $_" } +} catch { Write-Host " Could not get enum" } + +Write-Host " StorageOptimizationType values:" +try { + $T = [Veeam.Backup.PowerShell.Infos.VBRBackupCopyJobStorageOptimizationType] + [Enum]::GetNames($T) | ForEach-Object { Write-Host " $_" } +} catch { Write-Host " Could not get enum" } + +Write-Host "" +try { + Write-Host " Trying with all params..." + $OPTS = New-VBRBackupCopyJobStorageOptions ` + -CompressionLevel Auto ` + -StorageOptimizationType Local ` + -EnableDataDeduplication ` + -EnableEncryption ` + -EncryptionKey $KEY + Write-Host " [OK]" +} catch { + Write-Host " FAILED: $_" + Write-Host "" + Write-Host " Trying different CompressionLevel values..." + foreach ($CL in @("Auto", "None", "Optimal", "High", "Extreme", "0", "1", "4", "5", "6", "9")) { + try { + $OPTS = New-VBRBackupCopyJobStorageOptions -CompressionLevel $CL -EnableEncryption -EncryptionKey $KEY + Write-Host " [OK] CompressionLevel=$CL" + break + } catch { Write-Host " [$CL] FAILED" } + } +} + +Write-Host "" +Write-Host "==========================================" +Write-Host "5: Get existing copy job and try Set-VBRBackupCopyJob" +Write-Host "==========================================" +try { + $CJ = Get-VBRBackupCopyJob | Select-Object -First 1 + if ($CJ) { + Write-Host " Copy job: $($CJ.Name)" + Write-Host " Current StorageOptions:" + if ($CJ.StorageOptions) { + $CJ.StorageOptions | Get-Member -MemberType Property | ForEach-Object { + $PN = $_.Name; try { Write-Host " $PN = $($CJ.StorageOptions.$PN)" } catch {} + } + } + Write-Host "" + Write-Host " Trying Set-VBRBackupCopyJob -StorageOptions..." + if ($OPTS) { + Set-VBRBackupCopyJob -Job $CJ -StorageOptions $OPTS + Write-Host " [OK] Encryption set!" + } else { + Write-Host " No StorageOptions object available from previous steps." + } + } else { + Write-Host " No copy job found." + } +} catch { Write-Host " FAILED: $_" } + +Write-Host "" +Write-Host "==========================================" +Write-Host "Done" +Write-Host "==========================================" diff --git a/iaas-backblaze/b2-bucket-audit.ps1 b/iaas-backblaze/b2-bucket-audit.ps1 new file mode 100644 index 0000000..bf2c517 --- /dev/null +++ b/iaas-backblaze/b2-bucket-audit.ps1 @@ -0,0 +1,364 @@ +## B2 Bucket Audit - List all Backblaze B2 buckets with storage sizes +## and flag unused ones. Run centrally (not per-device). +## +## Bucket sizes come from Backblaze's daily usage reports stored in +## the b2-reports-{accountId} bucket (generated automatically by B2). +## +## $env:B2_KEY_ID - Backblaze B2 application key ID +## $env:B2_APP_KEY - Backblaze B2 application key +## $env:ACTIVE_BUCKETS_CSV - Comma-separated list of bucket names in active use +## (or path to a .txt/.csv file with one bucket per line) +## $env:CUSTOM_FIELD_B2_AUDIT - NinjaOne WYSIWYG field for the bucket audit table +## $env:DESCRIPTION - Ticket # or initials for audit trail +## $env:RMM - Set to 1 when running from RMM platform + +# ============================================================ +# HELPER FUNCTIONS +# ============================================================ + +function ConvertTo-SafeHtml { + param([string]$Text) + if (-not $Text) { return "" } + return $Text.Replace("&", "&").Replace("<", "<").Replace(">", ">").Replace('"', """) +} + +function Set-NinjaField { + param([string]$FieldName, [string]$Value) + if (-not $FieldName) { return } + $NINJA_CMD = Get-Command "Ninja-Property-Set" -ErrorAction SilentlyContinue + if ($NINJA_CMD) { + try { + Ninja-Property-Set $FieldName $Value + Write-Host " [OK] $FieldName updated." + } catch { + Write-Warning " Failed to write $FieldName : $_" + } + } else { + Write-Host " [SKIP] Ninja-Property-Set not available." + } +} + +# ============================================================ +# INPUT HANDLING +# ============================================================ + +if ($env:RMM -ne "1") { + if (-not $env:B2_KEY_ID) { + $env:B2_KEY_ID = Read-Host "Backblaze B2 application key ID" + } + if (-not $env:B2_APP_KEY) { + $env:B2_APP_KEY = Read-Host "Backblaze B2 application key" + } + if (-not $env:ACTIVE_BUCKETS_CSV) { + $env:ACTIVE_BUCKETS_CSV = Read-Host "Active bucket names (comma-separated, or path to file, or blank to show all)" + } + if (-not $env:CUSTOM_FIELD_B2_AUDIT) { + $env:CUSTOM_FIELD_B2_AUDIT = Read-Host "NinjaOne WYSIWYG field for audit table (blank to skip)" + } +} + +if (-not $env:B2_KEY_ID -or -not $env:B2_APP_KEY) { + Write-Error "B2_KEY_ID and B2_APP_KEY are required." + exit 1 +} + +# Parse active bucket list +$ACTIVE_BUCKETS = @{} +if ($env:ACTIVE_BUCKETS_CSV) { + $RAW = $env:ACTIVE_BUCKETS_CSV.Trim() + if (Test-Path $RAW -ErrorAction SilentlyContinue) { + $LINES = Get-Content $RAW | Where-Object { $_.Trim() -ne "" } + foreach ($L in $LINES) { + $NAME = ($L -split ',')[0].Trim().Trim('"') + if ($NAME -and $NAME -ne "BucketName" -and $NAME -ne "bucket_name") { + $ACTIVE_BUCKETS[$NAME.ToLower()] = $true + } + } + } else { + foreach ($NAME in ($RAW -split ',')) { + $TRIMMED = $NAME.Trim() + if ($TRIMMED) { $ACTIVE_BUCKETS[$TRIMMED.ToLower()] = $true } + } + } +} +Write-Host "Active buckets in known-good list: $($ACTIVE_BUCKETS.Count)" + +# ============================================================ +# B2 API - Try v2 first (most reliable), fall back to v4 +# ============================================================ + +Write-Host "" +Write-Host "=== B2 Bucket Audit ===" +Write-Host "" + +Write-Host "Authenticating to Backblaze B2..." +$B2_SECURE_KEY = ConvertTo-SecureString $env:B2_APP_KEY -AsPlainText -Force +$B2_CREDENTIAL = [PSCredential]::new($env:B2_KEY_ID, $B2_SECURE_KEY) + +$AUTH = $null +foreach ($API_VER in @("v2", "v4")) { + try { + $AUTH = Invoke-RestMethod -Uri "https://api.backblazeb2.com/b2api/$API_VER/b2_authorize_account" ` + -Method GET ` + -Authentication Basic ` + -Credential $B2_CREDENTIAL ` + -ErrorAction Stop + Write-Host " [OK] Authenticated via $API_VER" + break + } catch { + Write-Host " [$API_VER] Failed: $($_.Exception.Message)" + } +} + +if (-not $AUTH) { + Write-Error "B2 authentication failed on all API versions. Check B2_KEY_ID and B2_APP_KEY." + exit 1 +} + +$API_URL = $AUTH.apiUrl +if (-not $API_URL) { $API_URL = $AUTH.apiInfo.storageApi.apiUrl } +$AUTH_TOKEN = $AUTH.authorizationToken +$ACCOUNT_ID = $AUTH.accountId +$DOWNLOAD_URL = $AUTH.downloadUrl +if (-not $DOWNLOAD_URL) { $DOWNLOAD_URL = $AUTH.apiInfo.storageApi.downloadUrl } + +# Determine which API version worked for subsequent calls +$B2_VER = "v2" +if ($API_URL -match '/b2api/(v\d+)') { $B2_VER = $Matches[1] } + +Write-Host " Account: $ACCOUNT_ID" +Write-Host " API URL: $API_URL" +Write-Host " Download URL: $DOWNLOAD_URL" + +# ============================================================ +# List all buckets +# ============================================================ +Write-Host "" +Write-Host "Listing buckets..." +try { + $BUCKET_RESPONSE = Invoke-RestMethod -Uri "$API_URL/b2api/$B2_VER/b2_list_buckets" ` + -Method POST ` + -Headers @{ Authorization = $AUTH_TOKEN } ` + -ContentType "application/json" ` + -Body (@{ accountId = $ACCOUNT_ID } | ConvertTo-Json) ` + -ErrorAction Stop + + $BUCKETS = $BUCKET_RESPONSE.buckets + Write-Host " [OK] Found $($BUCKETS.Count) buckets." +} catch { + Write-Error "Failed to list B2 buckets: $_" + exit 1 +} + +# Build bucket ID -> name lookup +$BUCKET_ID_TO_NAME = @{} +foreach ($B in $BUCKETS) { + $BUCKET_ID_TO_NAME[$B.bucketId] = $B.bucketName +} + +# ============================================================ +# Pull daily usage report for bucket sizes +# ============================================================ +Write-Host "" +Write-Host "Fetching daily usage report from b2-reports-$ACCOUNT_ID..." +$BUCKET_SIZES = @{} # bucketId -> stored_gb + +$REPORTS_BUCKET_NAME = "b2-reports-$ACCOUNT_ID" +# Find the reports bucket ID +$REPORTS_BUCKET_ID = $null +foreach ($B in $BUCKETS) { + if ($B.bucketName -eq $REPORTS_BUCKET_NAME) { + $REPORTS_BUCKET_ID = $B.bucketId + break + } +} + +if ($REPORTS_BUCKET_ID) { + try { + # List recent files in the reports bucket to find the latest daily report + $FILES_RESPONSE = Invoke-RestMethod -Uri "$API_URL/b2api/$B2_VER/b2_list_file_names" ` + -Method POST ` + -Headers @{ Authorization = $AUTH_TOKEN } ` + -ContentType "application/json" ` + -Body (@{ + bucketId = $REPORTS_BUCKET_ID + maxFileCount = 100 + } | ConvertTo-Json) ` + -ErrorAction Stop + + # Find the most recent audit CSV + $REPORT_FILE = $FILES_RESPONSE.files | + Where-Object { $_.fileName -match "audit" -and $_.fileName -match "\.csv$" } | + Sort-Object fileName -Descending | + Select-Object -First 1 + + if ($REPORT_FILE) { + Write-Host " Found report: $($REPORT_FILE.fileName)" + + # Download the CSV + $CSV_URL = "$DOWNLOAD_URL/file/$REPORTS_BUCKET_NAME/$($REPORT_FILE.fileName)" + $CSV_CONTENT = Invoke-RestMethod -Uri $CSV_URL ` + -Method GET ` + -Headers @{ Authorization = $AUTH_TOKEN } ` + -ErrorAction Stop + + # Parse CSV - columns vary but we need bucketId and stored bytes/GB + $CSV_LINES = $CSV_CONTENT -split "`n" | Where-Object { $_.Trim() -ne "" } + if ($CSV_LINES.Count -gt 1) { + $HEADERS = ($CSV_LINES[0] -split ',') | ForEach-Object { $_.Trim().Trim('"') } + + # Find column indices + $IDX_BUCKET_ID = [array]::IndexOf($HEADERS, "bucketId") + if ($IDX_BUCKET_ID -eq -1) { $IDX_BUCKET_ID = [array]::IndexOf($HEADERS, "bucket_id") } + $IDX_STORED = -1 + foreach ($COL in @("storageByteCount", "stored_bytes", "storedBytes", "bytesStored")) { + $IDX_STORED = [array]::IndexOf($HEADERS, $COL) + if ($IDX_STORED -ne -1) { break } + } + + if ($IDX_BUCKET_ID -ne -1 -and $IDX_STORED -ne -1) { + Write-Host " Parsing report (bucketId col=$IDX_BUCKET_ID, bytes col=$IDX_STORED)..." + for ($I = 1; $I -lt $CSV_LINES.Count; $I++) { + $FIELDS = ($CSV_LINES[$I] -split ',') | ForEach-Object { $_.Trim().Trim('"') } + if ($FIELDS.Count -gt [Math]::Max($IDX_BUCKET_ID, $IDX_STORED)) { + $BID = $FIELDS[$IDX_BUCKET_ID] + $BYTES = [long]0 + try { $BYTES = [long]$FIELDS[$IDX_STORED] } catch {} + if ($BID) { $BUCKET_SIZES[$BID] = $BYTES } + } + } + Write-Host " [OK] Parsed sizes for $($BUCKET_SIZES.Count) buckets." + } else { + Write-Warning " Could not find expected columns in report. Headers: $($HEADERS -join ', ')" + Write-Host " Dumping first 3 lines for debugging:" + for ($I = 0; $I -lt [Math]::Min(3, $CSV_LINES.Count); $I++) { + Write-Host " $($CSV_LINES[$I])" + } + } + } + } else { + Write-Warning " No audit CSV found in reports bucket." + Write-Host " Files found:" + foreach ($F in $FILES_RESPONSE.files | Select-Object -First 10) { + Write-Host " $($F.fileName)" + } + } + } catch { + Write-Warning " Failed to fetch usage report: $_" + } +} else { + Write-Warning " Reports bucket ($REPORTS_BUCKET_NAME) not found. Size data unavailable." +} + +# ============================================================ +# Build output table +# ============================================================ +$SCAN_TIMESTAMP = Get-Date -Format "yyyy-MM-dd HH:mm:ss" +$TABLE_ROWS = "" +$ROW_INDEX = 0 +$UNUSED_COUNT = 0 +$USED_COUNT = 0 + +# Sort: unused first, then alphabetical +$SORTED_BUCKETS = $BUCKETS | Where-Object { $_.bucketName -ne $REPORTS_BUCKET_NAME } | Sort-Object @{ + Expression = { $ACTIVE_BUCKETS.ContainsKey($_.bucketName.ToLower()) } +}, bucketName + +foreach ($BUCKET in $SORTED_BUCKETS) { + $BNAME = $BUCKET.bucketName + $BTYPE = $BUCKET.bucketType + $BID = $BUCKET.bucketId + + # Size from daily report + $SIZE_DISPLAY = "N/A" + if ($BUCKET_SIZES.ContainsKey($BID)) { + $BYTES = $BUCKET_SIZES[$BID] + if ($BYTES -ge 1TB) { $SIZE_DISPLAY = "{0:N2} TB" -f ($BYTES / 1TB) } + elseif ($BYTES -ge 1GB) { $SIZE_DISPLAY = "{0:N2} GB" -f ($BYTES / 1GB) } + elseif ($BYTES -ge 1MB) { $SIZE_DISPLAY = "{0:N2} MB" -f ($BYTES / 1MB) } + elseif ($BYTES -gt 0) { $SIZE_DISPLAY = "{0:N2} KB" -f ($BYTES / 1KB) } + else { $SIZE_DISPLAY = "0 B" } + } + + # Status + if ($ACTIVE_BUCKETS.Count -gt 0) { + $IS_USED = $ACTIVE_BUCKETS.ContainsKey($BNAME.ToLower()) + } else { + $IS_USED = $null + } + + if ($IS_USED -eq $false) { + $STATUS = "Unused" + $ROW_BG = "#fef2f2" + $ROW_COLOR = "color:#991b1b;font-weight:600;" + $UNUSED_COUNT++ + } elseif ($IS_USED -eq $true) { + $STATUS = "In Use" + $ROW_BG = if ($ROW_INDEX % 2 -eq 0) { "#ffffff" } else { "#f9fafb" } + $ROW_COLOR = "" + $USED_COUNT++ + } else { + $STATUS = "" + $ROW_BG = if ($ROW_INDEX % 2 -eq 0) { "#ffffff" } else { "#f9fafb" } + $ROW_COLOR = "" + } + + $MARKER = if ($IS_USED -eq $false) { "[!]" } elseif ($IS_USED) { "[OK]" } else { " " } + Write-Host " $MARKER $BNAME | $BTYPE | $SIZE_DISPLAY | $STATUS" + + $TABLE_ROWS += @" + + $(ConvertTo-SafeHtml $BNAME) + $(ConvertTo-SafeHtml $BTYPE) + $(ConvertTo-SafeHtml $SIZE_DISPLAY) + $(ConvertTo-SafeHtml $STATUS) + +"@ + $ROW_INDEX++ +} + +# Summary +Write-Host "" +Write-Host "=== Summary ===" +Write-Host " Total buckets: $($SORTED_BUCKETS.Count)" +if ($ACTIVE_BUCKETS.Count -gt 0) { + Write-Host " In use: $USED_COUNT" + Write-Host " Unused: $UNUSED_COUNT" +} + +# Build HTML +$SUMMARY_LINE = "Last scanned: $SCAN_TIMESTAMP | Total: $($SORTED_BUCKETS.Count)" +if ($ACTIVE_BUCKETS.Count -gt 0) { + $SUMMARY_LINE += " | In Use: $USED_COUNT | Unused: $UNUSED_COUNT" +} + +$HTML = @" +
+ + + + + + + + + + +$TABLE_ROWS +
Bucket NameTypeSizeStatus
+

$SUMMARY_LINE

+
+"@ + +# Write to NinjaOne +Set-NinjaField $env:CUSTOM_FIELD_B2_AUDIT $HTML + +# Dump if no Ninja +if (-not (Get-Command "Ninja-Property-Set" -ErrorAction SilentlyContinue) -and -not $env:CUSTOM_FIELD_B2_AUDIT) { + Write-Host "" + Write-Host "=== HTML Output ===" + Write-Host $HTML +} + +Write-Host "" +Write-Host "=== Script complete ===" diff --git a/iaas-backblaze/b2-fix-lifecycle.ps1 b/iaas-backblaze/b2-fix-lifecycle.ps1 new file mode 100644 index 0000000..c2c73bc --- /dev/null +++ b/iaas-backblaze/b2-fix-lifecycle.ps1 @@ -0,0 +1,106 @@ +## Apply lifecycle rule to existing B2 buckets to purge hidden (old) file versions. +## Fixes the storage bloat where B2 keeps all versions forever. +## +## $env:B2_ADMIN_KEY_ID - Master B2 application key ID +## $env:B2_ADMIN_APP_KEY - Master B2 application key +## $env:BUCKET_NAME - Target bucket name (or "ALL" to apply to all *-veeam buckets) +## $env:PURGE_DAYS - Days after hiding before deletion (default: 15) + +function Invoke-B2Api { + 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 +} + +if (-not $env:B2_ADMIN_KEY_ID) { $env:B2_ADMIN_KEY_ID = Read-Host "B2 admin key ID" } +if (-not $env:B2_ADMIN_APP_KEY) { $env:B2_ADMIN_APP_KEY = Read-Host "B2 admin app key" } +if (-not $env:BUCKET_NAME) { $env:BUCKET_NAME = Read-Host "Bucket name (or ALL for all *-veeam buckets)" } + +$PURGE_DAYS = 15 +if ($env:PURGE_DAYS) { try { $PURGE_DAYS = [int]$env:PURGE_DAYS } catch {} } + +# Auth +$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 +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 "Auth failed."; 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 "Authenticated: $B2_ACCOUNT_ID" + +# List buckets +$BUCKET_RESPONSE = Invoke-B2Api -Uri "$B2_API_URL/b2api/$B2_API_VER/b2_list_buckets" ` + -AuthToken $B2_AUTH_TOKEN -Body (@{ accountId = $B2_ACCOUNT_ID } | ConvertTo-Json) + +$BUCKETS = $BUCKET_RESPONSE.buckets + +if ($env:BUCKET_NAME -eq "ALL") { + $TARGET_BUCKETS = $BUCKETS | Where-Object { $_.bucketName -like "*-veeam" } +} else { + $TARGET_BUCKETS = $BUCKETS | Where-Object { $_.bucketName -eq $env:BUCKET_NAME } +} + +Write-Host "Targeting $($TARGET_BUCKETS.Count) bucket(s)`n" + +foreach ($BUCKET in $TARGET_BUCKETS) { + Write-Host "--- $($BUCKET.bucketName) ---" + + # Show current lifecycle rules + $CURRENT_RULES = $BUCKET.lifecycleRules + if ($CURRENT_RULES -and $CURRENT_RULES.Count -gt 0) { + Write-Host " Current lifecycle rules:" + foreach ($R in $CURRENT_RULES) { + Write-Host " prefix='$($R.fileNamePrefix)' hideAfter=$($R.daysFromUploadingToHiding) deleteAfter=$($R.daysFromHidingToDeleting)" + } + } else { + Write-Host " No lifecycle rules (hidden versions kept forever!)" + } + + # Apply the fix + try { + $UPDATE_BODY = @{ + accountId = $B2_ACCOUNT_ID + bucketId = $BUCKET.bucketId + defaultServerSideEncryption = @{ + mode = "SSE-B2" + algorithm = "AES256" + } + lifecycleRules = @( + @{ + daysFromHidingToDeleting = $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 applied: delete hidden versions after $PURGE_DAYS days" + } catch { + Write-Warning " FAILED: $_" + } + Write-Host "" +} + +Write-Host "Done. Hidden file versions older than $PURGE_DAYS days will be automatically purged by B2." +Write-Host "Note: existing hidden versions won't be deleted instantly. B2 processes lifecycle rules daily." diff --git a/veeam/veeam-add-backup-repo.ps1 b/veeam/veeam-add-backup-repo.ps1 deleted file mode 100644 index 8d94110..0000000 --- a/veeam/veeam-add-backup-repo.ps1 +++ /dev/null @@ -1,130 +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) -$folderName = $timeStamp - - -if ($repositoryType -eq 1 -Or $repositoryType -eq 3){ - Write-Host "Creating S3 Repository: S3 $timeStamp" - # 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 - Add-VBRAmazonS3CompatibleRepository -AmazonS3Folder $folder -Connection $connect -Name "S3 $timeStamp" -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 $timeStamp" - # 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\$timeStamp" - $repositoryName = "Local $timeStamp" - 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){ - $backups = Get-VBRBackup - $localRepository = Get-VBRBackupRepository | Where -Property Name -like "Local*" | Select -First 1 - $spaceAvailable = - Write-Host "moving all backups to $localRepository" - $backups | ForEach-Object { - Move-VBRBackup -Repository $localRepository -Backup $_ -RunAsync - } -} - -# 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 \ No newline at end of file