diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..17c39e8 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +local.exclude +logs +restic.exe +secrets.ps1 \ No newline at end of file diff --git a/backup.ps1 b/backup.ps1 new file mode 100644 index 0000000..8b3b437 --- /dev/null +++ b/backup.ps1 @@ -0,0 +1,311 @@ +# +# Restic Windows Backup Script +# + +# =========== start configuration =========== # + +# set restic configuration parmeters (destination, passwords, etc.) +$SecretsScript = Join-Path $PSScriptRoot "secrets.ps1" + +# backup configuration variables +$ConfigScript = Join-Path $PSScriptRoot "config.ps1" + +# =========== end configuration =========== # + +# globals for state storage +$Script:ResticStateRepositoryInitialized = $null +$Script:ResticStateLastMaintenance = $null +$Script:ResticStateLastDeepMaintenance = $null +$Script:ResticStateMaintenanceCounter = $null + +# restore backup state from disk +function Get-BackupState { + if(Test-Path $StateFile) { + Import-Clixml $StateFile | ForEach-Object{ Set-Variable -Scope Script $_.Name $_.Value } + } +} +function Set-BackupState { + Get-Variable ResticState* | Export-Clixml $StateFile +} + +# unlock the repository if need be +function Invoke-Unlock { + Param($SuccessLog, $ErrorLog) + + $locks = & $ResticExe list locks --no-lock -q 3>&1 2>> $ErrorLog + if($locks.Length -gt 0) { + # unlock the repository (assumes this machine is the only one that will ever use it) + & $ResticExe unlock 3>&1 2>> $ErrorLog | Tee-Object -Append $SuccessLog + Write-Output "[[Unlock]] Repository was locked. Unlocking. Past script failure?" | Tee-Object -Append $ErrorLog | Tee-Object -Append $SuccessLog + Start-Sleep 120 + } +} + +# run maintenance on the backup set +function Invoke-Maintenance { + Param($SuccessLog, $ErrorLog) + + # skip maintenance if it's been done recently + if(($null -ne $ResticStateLastMaintenance) -and ($null -ne $ResticStateMaintenanceCounter)) { + $Script:ResticStateMaintenanceCounter += 1 + $delta = New-TimeSpan -Start $ResticStateLastMaintenance -End $(Get-Date) + if(($delta.Days -lt $SnapshotMaintenanceDays) -and ($ResticStateMaintenanceCounter -lt $SnapshotMaintenanceInterval)) { + Write-Output "[[Maintenance]] Skipped - last maintenance $ResticStateLastMaintenance ($($delta.Days) days, $ResticStateMaintenanceCounter backups ago)" | Tee-Object -Append $SuccessLog + return + } + } + + Write-Output "[[Maintenance]] Start $(Get-Date)" | Tee-Object -Append $SuccessLog + $maintenance_success = $true + Start-Sleep 120 + + # forget snapshots based upon the retention policy + Write-Output "[[Maintenance]] Start forgetting..." | Tee-Object -Append $SuccessLog + & $ResticExe --verbose -q forget $SnapshotRetentionPolicy 3>&1 2>> $ErrorLog | Tee-Object -Append $SuccessLog + if(-not $?) { + Write-Output "[[Maintenance]] Forget operation completed with errors" | Tee-Object -Append $ErrorLog | Tee-Object -Append $SuccessLog + $maintenance_success = $false + } + + # prune (remove) data from the backup step. Running this separate from `forget` because + # `forget` only prunes when it detects removed snapshots upon invocation, not previously removed + Write-Output "[[Maintenance]] Start pruning..." | Tee-Object -Append $SuccessLog + & $ResticExe --verbose -q prune 3>&1 2>> $ErrorLog | Tee-Object -Append $SuccessLog + if(-not $?) { + Write-Output "[[Maintenance]] Prune operation completed with errors" | Tee-Object -Append $ErrorLog | Tee-Object -Append $SuccessLog + $maintenance_success = $false + } + + # check data to ensure consistency + Write-Output "[[Maintenance]] Start checking..." | Tee-Object -Append $SuccessLog + + # check to determine if we want to do a full data check or not + $data_check = @() + if($null -ne $ResticStateLastDeepMaintenance) { + $delta = New-TimeSpan -Start $ResticStateLastDeepMaintenance -End $(Get-Date) + if($delta.Days -ge $SnapshotDeepMaintenanceDays) { + Write-Output "[[Maintenance]] Performing full data check - deep '--read-data' check last ran $ResticStateLastDeepMaintenance ($($delta.Days) days ago)" | Tee-Object -Append $SuccessLog + $data_check = @("--read-data") + $Script:ResticStateLastDeepMaintenance = Get-Date + } + else { + Write-Output "[[Maintenance]] Performing fast data check - deep '--read-data' check last ran $ResticStateLastDeepMaintenance ($($delta.Days) days ago)" | Tee-Object -Append $SuccessLog + } + } + else { + # set the date, but don't do a deep check if we've never done a full data read + $Script:ResticStateLastDeepMaintenance = Get-Date + } + + & $ResticExe --verbose -q check @data_check 3>&1 2>> $ErrorLog | Tee-Object -Append $SuccessLog + if(-not $?) { + Write-Output "[[Maintenance]] Check completed with errors" | Tee-Object -Append $ErrorLog | Tee-Object -Append $SuccessLog + $maintenance_success = $false + } + + Write-Output "[[Maintenance]] End $(Get-Date)" | Tee-Object -Append $SuccessLog + + if($maintenance_success -eq $true) { + $Script:ResticStateLastMaintenance = Get-Date + $Script:ResticStateMaintenanceCounter = 0; + } +} + +# Run restic backup +function Invoke-Backup { + Param($SuccessLog, $ErrorLog) + + Write-Output "[[Backup]] Start $(Get-Date)" | Tee-Object -Append $SuccessLog + $return_value = $true + $drive_count = $BackupSources.Count + $starting_location = Get-Location + ForEach ($item in $BackupSources.GetEnumerator()) { + + # Create the Shadow Copy + $s1 = (Get-WmiObject -List Win32_ShadowCopy).Create($item.Key, "ClientAccessible") + $s2 = Get-WmiObject -Class Win32_ShadowCopy | Where-Object { $_.ID -eq $s1.ShadowID } + + $device = $s2.DeviceObject + "\" + $ShadowPath = Join-Path $item.Key 'resticVSS' + + # Create a symbolic link to the shadow copy + cmd /c mklink /d $ShadowPath "$device" 3>&1 2>> $ErrorLog | Tee-Object -Append $SuccessLog + + # Build the new list of folders + $root_path = $ShadowPath + if($drive_count -eq 1) { + $root_path = "." + Set-Location $ShadowPath + } + + $folder_list = New-Object System.Collections.Generic.List[System.Object] + ForEach ($path in $item.Value) { + $p = Join-Path $root_path $path + $folder_list.Add($p) + } + + # backup everything in the root if no folders are provided + # note this won't select items with hidden attributes (a good thing to avoid) + if (-not $folder_list) { + ForEach ($path in Get-ChildItem $ShadowPath) { + $p = Join-Path $root_path $path + $folder_list.Add($p) + } + } + + # Launch Restic + & $ResticExe --verbose -q backup $folder_list --exclude-file=$WindowsExcludeFile --exclude-file=$LocalExcludeFile 3>&1 2>> $ErrorLog | Tee-Object -Append $SuccessLog + if(-not $?) { + Write-Output "[[Backup]] Completed with errors" | Tee-Object -Append $ErrorLog | Tee-Object -Append $SuccessLog + $return_value = $false + } + + # Delete the shadow copy and remove the symbolic link + if($drive_count -eq 1) { + Set-Location $starting_location + } + $s2.Delete() + cmd /c rmdir $ShadowPath + + Write-Output "[[Backup]] End $(Get-Date)" | Tee-Object -Append $SuccessLog + } + + return $return_value +} + +function Send-Email { + Param($SuccessLog, $ErrorLog) + $password = ConvertTo-SecureString $ResticEmailPassword -AsPlainText -Force + $credentials = New-Object System.Management.Automation.PSCredential ($ResticEmailUsername, $password) + + $status = "SUCCESS" + $body = "" + if (($null -ne $SuccessLog) -and (Test-Path $SuccessLog) -and (Get-Item $SuccessLog).Length -gt 0) { + $body = $(Get-Content -Raw $SuccessLog) + } + else { + $body = "Crtical Error! Restic backup log is empty or missing. Check log file path." + $status = "ERROR" + } + $attachments = @{} + if (($null -ne $ErrorLog) -and (Test-Path $ErrorLog) -and (Get-Item $ErrorLog).Length -gt 0) { + $attachments = @{Attachments = $ErrorLog} + $status = "ERROR" + } + $subject = "$env:COMPUTERNAME Restic Backup Report [$status]" + Send-MailMessage @ResticEmailConfig -From $ResticEmailFrom -To $ResticEmailTo -Credential $credentials -Subject $subject -Body $body @attachments +} + +function Invoke-ConnectivityCheck { + Param($SuccessLog, $ErrorLog) + + # parse connection string for hostname + # TODO: handle non-s3 repositories + # Uri parser doesn't handle leading connection type info + $connection_string = $env:RESTIC_REPOSITORY -replace "s3:" + $repository_host = ([System.Uri]$connection_string).host + + # test for internet connectivity + $connections = 0 + $sleep_count = $InternetTestAttempts + while($true) { + $connections = Get-NetRoute | Where-Object DestinationPrefix -eq '0.0.0.0/0' | Get-NetIPInterface | Where-Object ConnectionState -eq 'Connected' | Measure-Object | ForEach-Object{$_.Count} + if($sleep_count -le 0) { + Write-Output "[[Internet]] Connection to repository could not be established." | Tee-Object -Append $SuccessLog | Tee-Object -Append $ErrorLog + return $false + } + if(($null -eq $connections) -or ($connections -eq 0)) { + Write-Output "[[Internet]] Waiting for internet connectivity... $sleep_count" | Tee-Object -Append $SuccessLog + Start-Sleep 30 + } + elseif(!(Test-Connection -Server $repository_host -Quiet)) { + Write-Output "[[Internet]] Waiting for connection to repository ($repository_host)... $sleep_count" | Tee-Object -Append $SuccessLog + Start-Sleep 30 + } + else { + return $true + } + $sleep_count-- + } +} + +# check previous logs +function Invoke-HistoryCheck { + Param($SuccessLog, $ErrorLog) + $logs = Get-ChildItem $LogPath -Filter '*err.txt' | %{$_.Length -gt 0} + $logs_with_success = ($logs | Where-Object {($_ -eq $false)}).Count + if($logs.Count -gt 0) { + Write-Output "[[History]] Backup success rate: $logs_with_success / $($logs.Count) ($(($logs_with_success / $logs.Count).tostring("P")))" | Tee-Object -Append $SuccessLog + } +} + +# main function +function Invoke-Main { + + # check for elevation, required for creation of shadow copy (VSS) + if (-not (New-Object Security.Principal.WindowsPrincipal([Security.Principal.WindowsIdentity]::GetCurrent())).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) + { + Write-Error "[[Backup]] Elevation required (run as administrator). Exiting." + exit + } + + # initialize secrets + . $SecretsScript + + # initialize config + . $ConfigScript + + Get-BackupState + + if(!(Test-Path $LogPath)) { + Write-Error "[[Backup]] Log file directory $LogPath does not exist. Exiting." + Send-Email + exit + } + + $error_count = 0; + $attempt_count = $GlobalRetryAttempts + while ($attempt_count -gt 0) { + # setup logfiles + $timestamp = Get-Date -Format FileDateTime + $success_log = Join-Path $LogPath ($timestamp + ".log.txt") + $error_log = Join-Path $LogPath ($timestamp + ".err.txt") + + $internet_available = Invoke-ConnectivityCheck $success_log $error_log + if($internet_available -eq $true) { + Invoke-Unlock $success_log $error_log + $backup_success = Invoke-Backup $success_log $error_log + if($backup_success) { + Invoke-Maintenance $success_log $error_log + } + + if (!(Test-Path $error_log) -or ((Get-Item $error_log).Length -eq 0)) { + # successful with no errors; end + $total_attempts = $GlobalRetryAttempts - $attempt_count + 1 + Write-Output "Succeeded after $total_attempts attempt(s)" | Tee-Object -Append $success_log + Invoke-HistoryCheck $success_log $error_log + Send-Email $success_log $error_log + break; + } + } + + Write-Warning "Errors found! Error Log: $error_log" + $error_count++ + + Write-Output "Something went wrong. Sleeping for 15 min and then retrying..." | Tee-Object -Append $success_log + if($internet_available -eq $true) { + Send-Email $success_log $error_log + } + Start-Sleep (15*60) + $attempt_count-- + } + + Set-BackupState + + # cleanup older log files + Get-ChildItem $LogPath | Where-Object {$_.CreationTime -lt $(Get-Date).AddDays(-$LogRetentionDays)} | Remove-Item + + exit $error_count +} + +Invoke-Main \ No newline at end of file diff --git a/config.ps1 b/config.ps1 new file mode 100644 index 0000000..6dc36f1 --- /dev/null +++ b/config.ps1 @@ -0,0 +1,23 @@ +# backup configuration +$InstallPath = "C:\restic" +$ResticExe = Join-Path $InstallPath "restic.exe" +$StateFile = Join-Path $InstallPath "state.xml" +$WindowsExcludeFile = Join-Path $InstallPath "windows.exclude" +$LocalExcludeFile = Join-Path $InstallPath "local.exclude" +$LogPath = Join-Path $InstallPath "logs" +$LogRetentionDays = 30 +$SnapshotRetentionPolicy = @("--keep-daily", "30", "--keep-weekly", "52", "--keep-monthly", "24", "--keep-yearly", "10") +$SnapshotMaintenanceInterval = 7 +$SnapshotMaintenanceDays = 30 +$SnapshotDeepMaintenanceDays = 90; +$InternetTestAttempts = 10 +$GlobalRetryAttempts = 4 + +# Paths to backup +$BackupSources = @{} +$BackupSources["C:\"] = @( +# 'Users' +) +#$BackupSources["D:\"] = @( +# 'Software' +#) diff --git a/install.ps1 b/install.ps1 new file mode 100644 index 0000000..5cc1c0a --- /dev/null +++ b/install.ps1 @@ -0,0 +1,44 @@ +. .\config.ps1 + + +# Create log directory if it doesn't exit +if(-not (Test-Path $LogPath)) { + New-Item -ItemType Directory -Force -Path $LogPath | Out-Null + Write-Output "[[Init]] Repository successfully initialized." +} + +# Create the local exclude file +if(-not (Test-Path $LocalExcludeFile)) { + New-Item -Type File -Path $LocalExcludeFile | Out-Null +} + +# Initialize the restic repository +& $ResticExe --verbose init +if($?) { + Write-Output "[[Init]] Repository successfully initialized." +} +else { + Write-Warning "[[Init]] Repository initialization failed. Check errors and resolve." +} + +# Scheduled Windows Task Scheduler to run the backup +$backup_task_name = "Restic Backup" +$backup_task = Get-ScheduledTask $backup_task_name -ErrorAction SilentlyContinue +if($null -eq $backup_task) { + try { + $task_action = New-ScheduledTaskAction -Execute 'powershell.exe' -Argument '-NonInteractive -NoLogo -NoProfile -Command ".\backup.ps1; exit $LASTEXITCODE"' -WorkingDirectory $InstallPath + $task_user = New-ScheduledTaskPrincipal -UserId "NT AUTHORITY\SYSTEM" -RunLevel Highest + $task_settings = New-ScheduledTaskSettingsSet -RestartCount 4 -RestartInterval (New-TimeSpan -Minutes 15) -ExecutionTimeLimit (New-TimeSpan -Days 3) -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries -DontStopOnIdleEnd -MultipleInstances IgnoreNew -IdleDuration 0 -IdleWaitTimeout 0 -StartWhenAvailable -RestartOnIdle + $task_trigger = New-ScheduledTaskTrigger -Daily -At 4:00am + Register-ScheduledTask $backup_task_name -Action $task_action -Principal $task_user -Settings $task_settings -Trigger $task_trigger | Out-Null + Write-Output "[[Scheduler]] Backup task scheduled." + } + catch { + Write-Warning "[[Scheduler]] Scheduling failed." + } +} +else { + Write-Warning "[[Scheduler]] Backup task not scheduled: there is already a task with the name '$backup_task_name'." +} + + diff --git a/windows.exclude b/windows.exclude new file mode 100644 index 0000000..b4a4c7c --- /dev/null +++ b/windows.exclude @@ -0,0 +1,60 @@ +# default excludes +# examples https://github.com/duplicati/duplicati/blob/master/Duplicati/Library/Utility/FilterGroups.cs +# note, because we're using a VSS directory, we can use that as the root dir for exclude rules (i.e. resticVSS) +resticVSS\hiberfil.sys +resticVSS\pagefile.sys +resticVSS\swapfile.sys +resticVSS\$Recycle.Bin +resticVSS\autoexec.bat +resticVSS\Config.Msi +resticVSS\Documents and Settings +resticVSS\Recycled +resticVSS\Recycler +resticVSS\System Volume Information +resticVSS\Recovery +resticVSS\Program Files +resticVSS\Program Files (x86) +resticVSS\ProgramData +resticVSS\PerfLogs +resticVSS\Windows +resticVSS\Windows.old +Microsoft\Windows\Recent +Microsoft\**\RecoveryStore* +Microsoft\**\Windows\*.edb +Microsoft\**\Windows\*.log +Microsoft\**\Windows\Cookies* +MSOCache +NTUSER* +ntuser* +UsrClass.dat + +# cloud services +Dropbox +AppData\Local\Google\Drive +Google Drive\.tmp.drivedownload +resticVSS\OneDriveTemp + +# browsers +Google\Chrome + +# AppData +AppData\Local\Microsoft +AppData\Local\Duplicati +AppData\Local\D3DSCache +AppData\Local\ConnectedDevicesPlatform +AppData\Local\Packages +AppData\Roaming\Signal +AppData\Local\ElevatedDiagnostics +AppData\Local\Microsoft\Windows\Explorer +AppData\Local\Microsoft\Windows\INetCache +AppData\Local\Microsoft\Windows\WebCache +AppData\Local\Microsoft\Windows Store +AppData\Local\restic +AppData\LocalLow\Microsoft\CryptnetUrlCache +AppData\Local\IsolatedStorage + +# misc. temporary files +Temporary Internet Files +Thumbs.db +AppData\Local\Temp +Users\Public\AccountPictures \ No newline at end of file