#Requires -Version 2.0 [CmdletBinding()] Param ( [Bool]$EnableDebug = $True, [String]$ResticDir = "$Env:ProgramFiles\restic", [String]$ResticFile = "restic.exe", [String]$ResticURL = "https://github.com/restic/restic", [String]$BackupScriptFile = "backup.ps1", [String]$UtilsURL = "https://rest-server.pootis.network/util", [String]$TaskFile = "tasks.json", [Bool]$Force32Bit = $False, # enable this to download 32-bit restic binary [Switch]$BackupMode = $False, # run in unattended backup mode [Switch]$EnableShims = $False # force all ps 2.0+ shims ) Set-StrictMode -Version 1 try { Remove-TypeData System.Array -ErrorAction SilentlyContinue } catch {} try { [Net.ServicePointManager]::SecurityProtocol = [Enum]::ToObject([Net.SecurityProtocolType], 3072) } catch {} $ResticFullPath = Join-Path $ResticDir -ChildPath $ResticFile $BackupScriptFullPath = Join-Path $ResticDir -ChildPath $BackupScriptFile $TaskFullPath = Join-Path $ResticDir -ChildPath $TaskFile $SchTaskKeyword = "__SchTask" $SchTaskName = "Restic Backup" $SchTaskShims = $false $HasHyperV = $null $Cfg = @{} $DefaultRestServer = "https://rest-server.pootis.network" $ScriptVersion = "1.0.2" $ScriptAuthor = "dave@pootis.network" function Debug($Msg) { if ($EnableDebug) { Write-Host -ForegroundColor DarkCyan $Msg } } function Info($Msg) { Write-Host -ForegroundColor Cyan $Msg } function Warn($Msg) { Write-Host -ForegroundColor Yellow $Msg } function Success($Msg) { Write-Host -ForegroundColor Green $Msg } function Error($Msg) { Write-Host -ForegroundColor Red $Msg } #region shims if (($PSVersionTable.PSVersion.Major -lt 3) -or ($EnableShims)) { $PSScriptRoot = Split-Path -Parent -Path $MyInvocation.MyCommand.Definition function ConvertFrom-JObject($obj) { if ($obj -is [Newtonsoft.Json.Linq.JArray]) { $a = @() foreach ($entry in $obj.GetEnumerator()) { $a += @(ConvertFrom-JObject $entry) } return $a } elseif ($obj -is [Newtonsoft.Json.Linq.JObject]) { $h = @{} foreach ($kvp in $obj.GetEnumerator()) { $val = ConvertFrom-JObject $kvp.value if ($kvp.value -is [Newtonsoft.Json.Linq.JArray]) { $val = @($val) } $h += @{ "$($kvp.key)" = $val } } return $h } elseif ($obj -is [Newtonsoft.Json.Linq.JValue]) { return $obj.Value } else { return $obj } } function EscapeJson { param( [String] $String) # removed: #-replace '/', '\/' ` # This is returned $String -replace '\\', '\\' -replace '\n', '\n' ` -replace '\u0008', '\b' -replace '\u000C', '\f' -replace '\r', '\r' ` -replace '\t', '\t' -replace '"', '\"' } # Meant to be used as the "end value". Adding coercion of strings that match numerical formats # supported by JSON as an optional, non-default feature (could actually be useful and save a lot of # calculated properties with casts before passing..). # If it's a number (or the parameter -CoerceNumberStrings is passed and it # can be "coerced" into one), it'll be returned as a string containing the number. # If it's not a number, it'll be surrounded by double quotes as is the JSON requirement. function GetNumberOrString { param( $InputObject) if ($InputObject -is [System.Byte] -or $InputObject -is [System.Int32] -or ` ($env:PROCESSOR_ARCHITECTURE -imatch '^(?:amd64|ia64)$' -and $InputObject -is [System.Int64]) -or ` $InputObject -is [System.Decimal] -or ` ($InputObject -is [System.Double] -and -not [System.Double]::IsNaN($InputObject) -and -not [System.Double]::IsInfinity($InputObject)) -or ` $InputObject -is [System.Single] -or $InputObject -is [long] -or ` ($Script:CoerceNumberStrings -and $InputObject -match $Script:NumberRegex)) { Write-Verbose -Message "Got a number as end value." "$InputObject" } else { Write-Verbose -Message "Got a string (or 'NaN') as end value." """$(EscapeJson -String $InputObject)""" } } function ConvertToJsonInternal { param( $InputObject, # no type for a reason [Int32] $WhiteSpacePad = 0) [String] $Json = "" $Keys = @() $IsObj = $false Write-Verbose -Message "WhiteSpacePad: $WhiteSpacePad." if ($null -eq $InputObject) { Write-Verbose -Message "Got 'null' in `$InputObject in inner function" $null } elseif ($InputObject -is [Bool] -and $InputObject -eq $true) { Write-Verbose -Message "Got 'true' in `$InputObject in inner function" $true } elseif ($InputObject -is [Bool] -and $InputObject -eq $false) { Write-Verbose -Message "Got 'false' in `$InputObject in inner function" $false } elseif ($InputObject -is [DateTime] -and $Script:DateTimeAsISO8601) { Write-Verbose -Message "Got a DateTime and will format it as ISO 8601." """$($InputObject.ToString('yyyy\-MM\-ddTHH\:mm\:ss'))""" } elseif ($InputObject -is [HashTable]) { $Keys = @($InputObject.Keys) $IsObj = $true Write-Verbose -Message "Input object is a hash table (keys: $($Keys -join ', '))." } elseif ($InputObject.GetType().FullName -eq "System.Management.Automation.PSCustomObject") { $Keys = @(Get-Member -InputObject $InputObject -MemberType NoteProperty | Select-Object -ExpandProperty Name) $IsObj = $true Write-Verbose -Message "Input object is a custom PowerShell object (properties: $($Keys -join ', '))." } elseif ($InputObject.GetType().Name -match '\[\]|Array') { Write-Verbose -Message "Input object appears to be of a collection/array type. Building JSON for array input object." $Json += "[`n" + (($InputObject | ForEach-Object { if ($null -eq $_) { Write-Verbose -Message "Got null inside array." " " * ((4 * ($WhiteSpacePad / 4)) + 4) + "null" } elseif ($_ -is [Bool] -and $_ -eq $true) { Write-Verbose -Message "Got 'true' inside array." " " * ((4 * ($WhiteSpacePad / 4)) + 4) + "true" } elseif ($_ -is [Bool] -and $_ -eq $false) { Write-Verbose -Message "Got 'false' inside array." " " * ((4 * ($WhiteSpacePad / 4)) + 4) + "false" } elseif ($_ -is [DateTime] -and $Script:DateTimeAsISO8601) { Write-Verbose -Message "Got a DateTime and will format it as ISO 8601." " " * ((4 * ($WhiteSpacePad / 4)) + 4) + """$($_.ToString('yyyy\-MM\-ddTHH\:mm\:ss'))""" } elseif ($_ -is [HashTable] -or $_.GetType().FullName -eq "System.Management.Automation.PSCustomObject" -or $_.GetType().Name -match '\[\]|Array') { Write-Verbose -Message "Found array, hash table or custom PowerShell object inside array." " " * ((4 * ($WhiteSpacePad / 4)) + 4) + (ConvertToJsonInternal -InputObject $_ -WhiteSpacePad ($WhiteSpacePad + 4)) -replace '\s*,\s*$' } else { Write-Verbose -Message "Got a number or string inside array." $TempJsonString = GetNumberOrString -InputObject $_ " " * ((4 * ($WhiteSpacePad / 4)) + 4) + $TempJsonString } }) -join ",`n") + "`n$(" " * (4 * ($WhiteSpacePad / 4)))],`n" } else { Write-Verbose -Message "Input object is a single element (treated as string/number)." GetNumberOrString -InputObject $InputObject } if ($Keys.Count -or $IsObj) { Write-Verbose -Message "Building JSON for hash table or custom PowerShell object." $Json += "{`n" foreach ($Key in $Keys) { # -is [PSCustomObject]) { # this was buggy with calculated properties, the value was thought to be PSCustomObject if ($null -eq $InputObject.$Key) { Write-Verbose -Message "Got null as `$InputObject.`$Key in inner hash or PS object." $Json += " " * ((4 * ($WhiteSpacePad / 4)) + 4) + """$Key"": null,`n" } elseif ($InputObject.$Key -is [Bool] -and $InputObject.$Key -eq $true) { Write-Verbose -Message "Got 'true' in `$InputObject.`$Key in inner hash or PS object." $Json += " " * ((4 * ($WhiteSpacePad / 4)) + 4) + """$Key"": true,`n" } elseif ($InputObject.$Key -is [Bool] -and $InputObject.$Key -eq $false) { Write-Verbose -Message "Got 'false' in `$InputObject.`$Key in inner hash or PS object." $Json += " " * ((4 * ($WhiteSpacePad / 4)) + 4) + """$Key"": false,`n" } elseif ($InputObject.$Key -is [DateTime] -and $Script:DateTimeAsISO8601) { Write-Verbose -Message "Got a DateTime and will format it as ISO 8601." $Json += " " * ((4 * ($WhiteSpacePad / 4)) + 4) + """$Key"": ""$($InputObject.$Key.ToString('yyyy\-MM\-ddTHH\:mm\:ss'))"",`n" } elseif ($InputObject.$Key -is [HashTable] -or $InputObject.$Key.GetType().FullName -eq "System.Management.Automation.PSCustomObject") { Write-Verbose -Message "Input object's value for key '$Key' is a hash table or custom PowerShell object." $Json += " " * ($WhiteSpacePad + 4) + """$Key"":`n$(" " * ($WhiteSpacePad + 4))" $Json += ConvertToJsonInternal -InputObject $InputObject.$Key -WhiteSpacePad ($WhiteSpacePad + 4) } elseif ($InputObject.$Key.GetType().Name -match '\[\]|Array') { Write-Verbose -Message "Input object's value for key '$Key' has a type that appears to be a collection/array." Write-Verbose -Message "Building JSON for ${Key}'s array value." $Json += " " * ($WhiteSpacePad + 4) + """$Key"":`n$(" " * ((4 * ($WhiteSpacePad / 4)) + 4))[`n" + (($InputObject.$Key | ForEach-Object { if ($null -eq $_) { Write-Verbose -Message "Got null inside array inside inside array." " " * ((4 * ($WhiteSpacePad / 4)) + 8) + "null" } elseif ($_ -is [Bool] -and $_ -eq $true) { Write-Verbose -Message "Got 'true' inside array inside inside array." " " * ((4 * ($WhiteSpacePad / 4)) + 8) + "true" } elseif ($_ -is [Bool] -and $_ -eq $false) { Write-Verbose -Message "Got 'false' inside array inside inside array." " " * ((4 * ($WhiteSpacePad / 4)) + 8) + "false" } elseif ($_ -is [DateTime] -and $Script:DateTimeAsISO8601) { Write-Verbose -Message "Got a DateTime and will format it as ISO 8601." " " * ((4 * ($WhiteSpacePad / 4)) + 8) + """$($_.ToString('yyyy\-MM\-ddTHH\:mm\:ss'))""" } elseif ($_ -is [HashTable] -or $_.GetType().FullName -eq "System.Management.Automation.PSCustomObject" ` -or $_.GetType().Name -match '\[\]|Array') { Write-Verbose -Message "Found array, hash table or custom PowerShell object inside inside array." " " * ((4 * ($WhiteSpacePad / 4)) + 8) + (ConvertToJsonInternal -InputObject $_ -WhiteSpacePad ($WhiteSpacePad + 8)) -replace '\s*,\s*$' } else { Write-Verbose -Message "Got a string or number inside inside array." $TempJsonString = GetNumberOrString -InputObject $_ " " * ((4 * ($WhiteSpacePad / 4)) + 8) + $TempJsonString } }) -join ",`n") + "`n$(" " * (4 * ($WhiteSpacePad / 4) + 4 ))],`n" } else { Write-Verbose -Message "Got a string inside inside hashtable or PSObject." # '\\(?!["/bfnrt]|u[0-9a-f]{4})' $TempJsonString = GetNumberOrString -InputObject $InputObject.$Key $Json += " " * ((4 * ($WhiteSpacePad / 4)) + 4) + """$Key"": $TempJsonString,`n" } } $Json = $Json -replace '\s*,$' # remove trailing comma that'll break syntax $Json += "`n" + " " * $WhiteSpacePad + "},`n" } $Json } function ConvertTo-STJson { [CmdletBinding()] #[OutputType([Void], [Bool], [String])] Param( [AllowNull()] [Parameter(Mandatory = $True, ValueFromPipeline = $True, ValueFromPipelineByPropertyName = $True)] $InputObject, [Switch] $Compress, [Switch] $CoerceNumberStrings = $False, [Switch] $DateTimeAsISO8601 = $False) Begin { $JsonOutput = "" $Collection = @() # Not optimal, but the easiest now. [Bool] $Script:CoerceNumberStrings = $CoerceNumberStrings [Bool] $Script:DateTimeAsISO8601 = $DateTimeAsISO8601 [String] $Script:NumberRegex = '^-?\d+(?:(?:\.\d+)?(?:e[+\-]?\d+)?)?$' #$Script:NumberAndValueRegex = '^-?\d+(?:(?:\.\d+)?(?:e[+\-]?\d+)?)?$|^(?:true|false|null)$' } Process { # Hacking on pipeline support ... if ($_) { Write-Verbose -Message "Adding object to `$Collection. Type of object: $($_.GetType().FullName)." $Collection += $_ } } End { if ($Collection.Count) { Write-Verbose -Message "Collection count: $($Collection.Count), type of first object: $($Collection[0].GetType().FullName)." $JsonOutput = ConvertToJsonInternal -InputObject ($Collection | ForEach-Object { $_ }) } else { $JsonOutput = ConvertToJsonInternal -InputObject $InputObject } if ($null -eq $JsonOutput) { Write-Verbose -Message "Returning `$null." return $null # becomes an empty string :/ } elseif ($JsonOutput -is [Bool] -and $JsonOutput -eq $true) { Write-Verbose -Message "Returning `$true." [Bool] $true # doesn't preserve bool type :/ but works for comparisons against $true } elseif ($JsonOutput -is [Bool] -and $JsonOutput -eq $false) { Write-Verbose -Message "Returning `$false." [Bool] $false # doesn't preserve bool type :/ but works for comparisons against $false } elseif ($Compress) { Write-Verbose -Message "Compress specified." ( ($JsonOutput -split "\n" | Where-Object { $_ -match '\S' }) -join "`n" ` -replace '^\s*|\s*,\s*$' -replace '\ *\]\ *$', ']' ) -replace ( # these next lines compress ... '(?m)^\s*("(?:\\"|[^"])+"): ((?:"(?:\\"|[^"])+")|(?:null|true|false|(?:' + ` $Script:NumberRegex.Trim('^$') + ` ')))\s*(?,)?\s*$'), "`${1}:`${2}`${Comma}`n" ` -replace '(?m)^\s*|\s*\z|[\r\n]+' } else { ($JsonOutput -split "\n" | Where-Object { $_ -match '\S' }) -join "`n" ` -replace '^\s*|\s*,\s*$' -replace '\ *\]\ *$', ']' } } } function ConvertTo-Json { [CmdletBinding()] Param([Parameter(ValueFromPipeline = $true)]$Item, $Depth, [Switch]$Compress) return ($Item | ConvertTo-STJson -Compress:$Compress) } function ConvertFrom-Json { [CmdletBinding()] Param([Parameter(ValueFromPipeline = $true)]$Item) $Obj = [Newtonsoft.Json.JsonConvert]::DeserializeObject($Item, [Newtonsoft.Json.Linq.JObject]); return (ConvertFrom-JObject $Obj) } $JsonDllPath = Join-Path $ResticDir 'Newtonsoft.Json.dll' if (!(Test-Path -PathType Leaf $JsonDllPath)) { Error "JSON helper DLL is missing: `"${JsonDllPath}`"" } Add-Type -Path $JsonDllPath Info "Using PS <= 2.0 shims" } if (($PSVersionTable.PSVersion.Major -lt 4) -or ($EnableShims)) { function Get-ScheduledTaskPS30 { $Service = New-Object -ComObject "Schedule.Service" $Service.Connect() $RootFolder = $Service.GetFolder("\") try { return ($RootFolder.GetTask("\" + $SchTaskName)) } catch { return $null } } function Create-ScheduledTaskPS30($Config) { $Service = New-Object -ComObject "Schedule.Service" $Service.Connect() $RootFolder = $Service.GetFolder("\") $TaskDefinition = $Service.NewTask(0) $Action = $TaskDefinition.Actions.Create(0) $Action.Path = "powershell.exe" $Action.Arguments = "-ExecutionPolicy Unrestricted -File `"$BackupScriptFullPath`" -BackupMode" $Action.WorkingDirectory = $ResticDir $TaskDefinition.Principal.LogonType = 5 $TaskDefinition.Principal.RunLevel = 1 $TaskDefinition.Principal.UserId = "S-1-5-18" $TaskDefinition.Settings.Enabled = $true $TaskDefinition.Settings.DisallowStartIfOnBatteries = $false $TaskDefinition.Settings.StopIfGoingOnBatteries = $false $TaskDefinition.Settings.Compatibility = 2 $TaskDefinition.Settings.ExecutionTimeLimit = [System.Xml.XmlConvert]::ToString((New-TimeSpan -Minutes $Config.Limit)) $TaskDefinition.Settings.MultipleInstances = 2 $TaskDefinition.Settings.Priority = 8 $TaskDefinition.Settings.StartWhenAvailable = $true $Trigger = $TaskDefinition.Triggers.Create(2) $Trigger.DaysInterval = $Config.DaysInterval $Trigger.RandomDelay = [System.Xml.XmlConvert]::ToString((New-TimeSpan -Minutes $Config.Delay)) $Date = Get-Date $Config.Start $Trigger.StartBoundary = $Date | Get-Date -Format yyyy-MM-ddTHH:ss:ms $TaskDefinition.RegistrationInfo.Description = "Start a scheduled Restic backup job" $RootFolder.RegisterTaskDefinition($SchTaskName, $TaskDefinition, 6, $null, $null, 5) } function Unregister-ScheduledTaskPS30 { $Service = New-Object -ComObject "Schedule.Service" $Service.Connect() $RootFolder = $Service.GetFolder("\") try { $RootFolder.DeleteTask("\" + $SchTaskName, 0) } catch { Warn $_ } } function Stop-ScheduledTaskPS30 { $Task = Get-ScheduledTaskPS30 if ($Task -ne $null) { try { $Task.Stop(0) } catch { } } } function Get-FileHash($Path, $Algorithm) { $HashAlgorithm = [System.Security.Cryptography.HashAlgorithm]::Create($Algorithm) $Hash = [System.BitConverter]::ToString($HashAlgorithm.ComputeHash([System.IO.File]::ReadAllBytes($Path))) return @{ Algorithm = $Algorithm Path = $Path Hash = $Hash.Replace('-', '') } } $SchTaskShims = $true Info "Using PS <= 3.0 shims" } #endregion shims #region utils function Get-PSCommandPath { try { if ($PSCommandPath -ne $null) { return $PSCommandPath } else { return $global:MyInvocation.MyCommand.Path } } catch { return $global:MyInvocation.MyCommand.Path } } function Merge-HashTable { Param ( [hashtable]$Default, [hashtable]$Uppend ) $default1 = $default.Clone() foreach ($key in $uppend.Keys) { if ($default1.ContainsKey($key)) { $default1.Remove($key) } } return $default1 + $uppend; } function ConvertPSObjectToHashtable { Param ( [Parameter(ValueFromPipeline = $true)]$InputObject ) Process { if ($InputObject -is [System.Collections.Hashtable]) { return $InputObject } if ($null -eq $InputObject) { return $null } if ($InputObject -is [System.Collections.IEnumerable] -and $InputObject -isnot [string]) { $Collection = @( foreach ($object in $InputObject) { ConvertPSObjectToHashtable $object } ) , $Collection } elseif ($InputObject -is [psobject]) { $hash = @{} foreach ($property in $InputObject.PSObject.Properties) { $hash[$property.Name] = ConvertPSObjectToHashtable $property.Value } $hash } else { $InputObject } } } function Register-EnvVars($EnvDict, $ProcessInfo) { if ($EnvDict -ne $null) { foreach ($EnvVar in $EnvDict.GetEnumerator()) { if (($ProcessInfo.EnvironmentVariables -eq $null) -or !($ProcessInfo.EnvironmentVariables.ContainsKey($EnvVar.Key))) { Debug "Registering envvar $($EnvVar.Key)" $ProcessInfo.EnvironmentVariables.Add($EnvVar.Key, $EnvVar.Value) } } } } function Execute-Command($FileName, $Arguments, $WorkDir, $EnvDict) { try { $pinfo = New-Object System.Diagnostics.ProcessStartInfo $pinfo.FileName = $FileName $pinfo.Arguments = $Arguments $pinfo.WorkingDirectory = $WorkDir $pinfo.RedirectStandardError = $true $pinfo.RedirectStandardOutput = $true $pinfo.UseShellExecute = $false $pinfo.CreateNoWindow = $true Register-EnvVars -EnvDict $EnvDict -ProcessInfo $pinfo $p = New-Object System.Diagnostics.Process $p.StartInfo = $pinfo $OutEvent = Register-ObjectEvent -Action { Debug $Event.SourceEventArgs.Data } -InputObject $p -EventName OutputDataReceived $ErrEvent = Register-ObjectEvent -Action { Debug $Event.SourceEventArgs.Data } -InputObject $p -EventName ErrorDataReceived $p.Start() | Out-Null $p.BeginOutputReadLine() $p.BeginErrorReadLine() do { Start-Sleep -Seconds 1 } while (!$p.HasExited) $res = [PSCustomObject]@{ ExitCode = $p.ExitCode } Unregister-Event -SourceIdentifier $OutEvent.Name Unregister-Event -SourceIdentifier $ErrEvent.Name return $res } catch { Error "Failed to start process `"$FileName`": $_" return $null } } function Execute-CommandPipe($Cmd1, $Cmd2, $EnvDict) { try { $pinfo1 = New-Object System.Diagnostics.ProcessStartInfo $pinfo1.FileName = $Cmd1.FileName $pinfo1.Arguments = $Cmd1.Arguments $pinfo1.WorkingDirectory = $Cmd1.WorkDir $pinfo1.RedirectStandardOutput = $true $pinfo1.UseShellExecute = $false $pinfo1.CreateNoWindow = $true $pinfo2 = New-Object System.Diagnostics.ProcessStartInfo $pinfo2.FileName = $Cmd2.FileName $pinfo2.Arguments = $Cmd2.Arguments $pinfo2.WorkingDirectory = $Cmd2.WorkDir $pinfo2.RedirectStandardError = $true $pinfo2.RedirectStandardOutput = $true $pinfo2.RedirectStandardInput = $true $pinfo2.UseShellExecute = $false $pinfo2.CreateNoWindow = $true Register-EnvVars -EnvDict $EnvDict -ProcessInfo $pinfo2 Debug "Starting process pipe: [ $($Cmd1.FileName) $($Cmd1.Arguments) ] | [ $($Cmd2.FileName) $($Cmd2.Arguments) ]" $p1 = New-Object System.Diagnostics.Process $p1.StartInfo = $pinfo1 $p2 = New-Object System.Diagnostics.Process $p2.StartInfo = $pinfo2 $OutEvent = Register-ObjectEvent -Action { Debug $Event.SourceEventArgs.Data } -InputObject $p2 -EventName OutputDataReceived $ErrEvent = Register-ObjectEvent -Action { Debug $Event.SourceEventArgs.Data } -InputObject $p2 -EventName ErrorDataReceived $p2.Start() | Out-Null $p1.Start() | Out-Null $p2.BeginOutputReadLine() $p2.BeginErrorReadLine() $br = New-Object System.IO.BinaryReader($p1.StandardOutput.BaseStream) $bw = New-Object System.IO.BinaryWriter($p2.StandardInput.BaseStream) $buf = New-Object byte[] 8192 while ((!$p1.HasExited) -and (!$p2.HasExited)) { $p1.Refresh() $p2.Refresh() $count = $br.Read($buf, 0, $buf.Length) if ($count -eq 0) { $br.Close() break } $bw.Write($buf, 0, $count) $bw.Flush() } try { $p1.Kill() } catch {} $bw.Close() do { Start-Sleep -Seconds 1 } while (!$p2.HasExited) $res = [PSCustomObject]@{ ExitCode = $p2.ExitCode } Unregister-Event -SourceIdentifier $OutEvent.Name Unregister-Event -SourceIdentifier $ErrEvent.Name return $res } catch { Error "Failed to start process `"$FileName`": $_" return $null } } function Is-ValidJobName($Name) { return (($Name -ne $null) -and ($Name -is [string]) -and !($Name -eq $SchTaskKeyword) ` -and !($Name.StartsWith("__"))) } function Count-Jobs() { $NumJobs = 0 foreach ($Job in ($global:Cfg).GetEnumerator()) { if (Is-ValidJobName -Name $Job.Name) { $NumJobs += 1 } } return $NumJobs } function Is-JobExists($Name) { foreach ($Job in ($global:Cfg).GetEnumerator()) { if ((Is-ValidJobName -Name $Job.Name) -and ($Job.Name -eq $Name)) { return $true } } return $false } function Get-JobType($Job) { if (($Job.Items -ne $null) -and ($Job.Items -is [array]) -and ($Job.Items.Length -gt 0)) { return "file" } elseif (($Job.Stdin -ne $null) -and ($Job.Stdin.Command -ne $null)) { return "stdin" } elseif (($Job.HyperV -ne $null) -and ($Job.HyperV -is [array]) -and ($Job.HyperV.Length -gt 0)) { return "Hyper-V" } else { return "empty" } } function Show-Menu($Title, $Menu, $Parm) { if (($Title -is [string]) -and !([string]::IsNullOrEmpty($Title))) { Write-Host -ForegroundColor Gray ("/" + "-" * ($Title.Length + 4) + "\") Write-Host -ForegroundColor Gray "| " -NoNewline Write-Host -ForegroundColor Magenta $Title -NoNewline Write-Host -ForegroundColor Gray " |" Write-Host -ForegroundColor Gray ("\" + "-" * ($Title.Length + 4) + "/") } $Menu | % { Write-Host -ForegroundColor Blue "[" -NoNewline Write-Host -ForegroundColor Green $_.Code -NoNewline Write-Host -ForegroundColor Blue "] " -NoNewline if ($_.Postfix -eq $null) { Write-Host -ForegroundColor White $_.Name } else { Write-Host -ForegroundColor White $_.Name -NoNewline Write-Host -ForegroundColor White " (" -NoNewline Write-Host -ForegroundColor Magenta $_.Postfix -NoNewline Write-Host -ForegroundColor White ")" } } $StrCode = Read-Host -Prompt "Choose a menu item" if (($StrCode -is [string]) -and [string]::IsNullOrEmpty($StrCode)) { return } try { [int]$Code = [convert]::ToInt32($StrCode, 10) } catch { Warn "Invalid code" return } foreach ($Item in $Menu) { if ($Item.Code -eq $Code) { if ($Item.Callback -eq $null) { Error "Menu code $Code has no callback" } else { $Item.Callback.Invoke($Parm) } return } } Warn "Unknown code $Code" } #endregion #region installation function SetupResticDir($Dir) { if (!(Test-Path -PathType Container $Dir)) { Info "Directory `"$Dir`" does not exist, creating" try { New-Item -ItemType Directory -Path $Dir -ErrorAction Stop | Out-Null } catch { Error "Failed to create directory `"$Dir`": $_" return $false } } Debug "Validating ACL on `"$Dir`"" $OldACL = Get-Acl $Dir -ErrorAction Stop if (!$OldACL) { Error "Failed to get directory ACL or ACL is empty" return $false } $NewACL = New-Object System.Security.AccessControl.DirectorySecurity $AR = New-Object System.Security.AccessControl.FileSystemAccessRule((New-Object System.Security.Principal.SecurityIdentifier("S-1-5-32-544")), ` "FullControl", "ContainerInherit,ObjectInherit", "None", "Allow"); $NewACL.SetAccessRule($AR) $AR = New-Object System.Security.AccessControl.FileSystemAccessRule((New-Object System.Security.Principal.SecurityIdentifier("S-1-5-18")), ` "FullControl", "ContainerInherit,ObjectInherit", "None", "Allow"); $NewACL.SetAccessRule($AR) $NewACL.SetOwner((New-Object System.Security.Principal.SecurityIdentifier("S-1-5-32-544"))) $NewACL.SetAccessRuleProtection($true, $false) $Cmp = Compare-Object -ReferenceObject ($OldACL.AccessToString -split '\n') -DifferenceObject ($NewACL.AccessToString -split '\n') if (!$Cmp) { Debug "OK: ACL is correct" } else { Warn "Current and proposed ACL records are different, changing ACL on directory `"$Dir`"" try { $NewACL | Set-Acl $Dir -ErrorAction Stop } catch { Error "Failed to change ACL on directory `"$Dir`": $_" return $false } Info "OK: ACL changed" } return $true } function GetLatestVersion($URL) { try { $Request = [System.Net.WebRequest]::Create($URL + "/releases/latest") $Response = $Request.GetResponse() $RealTagURL = $Response.ResponseUri.OriginalString return $RealTagURL.split('/')[-1].Trim('v') } catch { Warn "Failed to get latest Restic version: $_" return $null } } function Is64Bit() { if ($Force32Bit) { return $false } else { return (Test-Path "Env:ProgramFiles(x86)") } } function Unpack-ZIPArchive($Src, $Dest) { if (Get-Command "Expand-Archive" -ErrorAction SilentlyContinue) { $SavedProgressPreference = $ProgressPreference $ProgressPreference = "SilentlyContinue" Expand-Archive -DestinationPath $Dest -Force -LiteralPath $Src | Out-Null $ProgressPreference = $SavedProgressPreference } else { $Shell = New-Object -ComObject Shell.Application $Zip = $Shell.Namespace($Src) foreach ($Item in $Zip.Items()) { $Shell.Namespace($Dest).CopyHere($Item) } } } function DownloadRestic($BinFile) { $Version = $null if (Test-Path -PathType Leaf $BinFile) { Debug "Restic binary file already exists as `"$BinFile`", verifying version" try { $Version = & $BinFile version $Version = $Version.split(' ')[1] Info "Currently installed Restic version: $Version" } catch { Warn "Failed to get currently installed Restic version: $_" $Version = $null } } $LatestVersion = GetLatestVersion -URL $ResticURL if ($LatestVersion -eq $null) { return $false } Info "Latest Restic version from GitHub: $LatestVersion" if ($Version -ne $null -and $Version -eq $LatestVersion) { Debug "Latest Restic version is already installed" } else { $Arch = "386" if (Is64Bit) { $Arch = "amd64" } $DownloadURL = $ResticURL + "/releases/download/v$($LatestVersion)/restic_$($LatestVersion)_windows_$($Arch).zip" $TmpFile = Join-Path $ResticDir -ChildPath "restic.zip" try { Info "Downloading Restic from `"$DownloadURL`" to `"$TmpFile`"..." try { (New-Object System.Net.WebClient).DownloadFile($DownloadURL, $TmpFile) } catch { Error "Failed to download Restic from URL `"$DownloadURL`": $_" return $false } Debug "OK: Restic archive downloaded to temp file `"$TmpFile`", unpacking..." try { Unpack-ZIPArchive -Src $TmpFile -Dest $ResticDir } catch { Error "Failed to unpack Restic archive in `"$TmpFile`": $_" return $false } # Ensure the task is stopped before replacing Restic binary Stop-SchTask Debug "OK: renaming downloaded file..." try { Remove-Item -Path $ResticFullPath -ErrorAction SilentlyContinue | Out-Null try { Rename-Item -LiteralPath (Join-Path $ResticDir -ChildPath "restic_$($LatestVersion)_windows_$($Arch).exe") -NewName "restic.exe" -ErrorAction Stop | Out-Null } catch { Rename-Item -Path (Join-Path $ResticDir -ChildPath "restic_$($LatestVersion)_windows_$($Arch).exe") -NewName "restic.exe" -ErrorAction Stop | Out-Null } } catch { Error "Failed to rename Restic file: $_" return $false } if ($Version -eq $null) { Info "OK: installed Restic $LatestVersion" } else { Info "OK: Restic updated from $Version to $LatestVersion" } } finally { Debug "Cleaning up downloads" $TmpFile | Remove-Item -ErrorAction SilentlyContinue } } return $true } function SaveBackupScript($Path) { $OldHash = $null if (Test-Path -PathType Leaf $Path) { Debug "Backup script already exists in `"$Path`"" $OldHash = Get-FileHash $Path -Algorithm "SHA256" } if ((Get-PSCommandPath) -eq $null) { Warn "Skipping script update: cannot determine source script location" return $true } $TmpFile = "./tmp-file" try { $TmpFile = New-TemporaryFile } catch {} try { Copy-Item -Path (Get-PSCommandPath) -Destination $TmpFile -Force -ErrorAction Stop | Out-Null Debug "OK: script copy saved to `"$TmpFile`"" $NewHash = Get-FileHash $TmpFile -Algorithm "SHA256" if (($OldHash -ne $null) -and ($NewHash.Hash -eq $OldHash.Hash)) { Debug "Latest backup script version is already installed" } else { if ($OldHash -eq $null) { Info "Saving backup script to `"$Path`"" } else { Info "Updating backup script in `"$Path`"" } try { Copy-Item $TmpFile -Destination $Path -Force -ErrorAction Stop | Out-Null } catch { Error "Failed to save backup script to `"$Path`": $_" return $false } Info "OK: new backup script saved" } } finally { Debug "Cleaning up temp files" $TmpFile | Remove-Item -ErrorAction SilentlyContinue } return $true } #endregion #region exclusions function Download-ExclusionListFile($Name) { $ExclFullPath = Join-Path $ResticDir -ChildPath $Name if (!(Test-Path -PathType Leaf $ExclFullPath)) { $DownloadURL = $UtilsURL + "/" + $Name Info "Downloading common exclusion list `"$Name`" from `"$DownloadURL`"..." try { (New-Object System.Net.WebClient).DownloadFile($DownloadURL, $ExclFullPath) Info "OK: exclusion list downloaded" } catch { Error "Failed to download exclusion list to `"$ExclFullPath`": $_" return $false } } return $true } function Delete-ExclusionListFile($Name) { $ExclFullPath = Join-Path $ResticDir -ChildPath $Name if (Test-Path -PathType Leaf $ExclFullPath) { Remove-Item $ExclFullPath -Force | Out-Null Info "Removed exclusion list `"$ExclFullPath`"" } } function Process-ExclusionListForJob($Job) { if (($Job -ne $null) -and (Is-ValidJobName -Name $Job.Name) -and ($Job.CommonExclusions -ne $null) -and ($Job.CommonExclusions -is [array])) { foreach ($Excl in $Job.CommonExclusions) { Debug "Processing exclusion list `"$Excl`" in job `"$($Job.Name)`"" $ExclFullPath = Join-Path $ResticDir -ChildPath $Excl if (!(Test-Path -PathType Leaf $ExclFullPath)) { if (Download-ExclusionListFile -Name $Excl) { Info "OK: exclusion list `"$Excl`" downloaded" return $true } } } } return $false } function Process-ExclusionLists($Path) { Read-Config Debug "Downloading exclusion lists" $Total = 0 foreach ($Item in ($global:Cfg).GetEnumerator()) { if (!(Is-ValidJobName -Name $Item.Name)) { continue } Debug "Processing job `"$($Item.Name)`"" if ($Item.Value -ne $null) { if (Process-ExclusionListForJob -Job $Item.Value) { $Total += 1 } } } Debug "Finished downloading exclusion lists ($Total files downloaded)" return $true } #endregion #region config function Read-Config() { Debug "Reading config" $Path = $TaskFullPath if (!(Test-Path -PathType Leaf $Path)) { Debug "Config does not exist" $global:Cfg = @{} return } $JSON = $null try { $JSON = Get-Content $Path | ConvertFrom-Json -ErrorAction Stop } catch { Warn "Failed to read config: $_" } if ($JSON -eq $null) { $global:Cfg = @{} } else { $Converted = ConvertPSObjectToHashtable $JSON $global:Cfg = $Converted } } function Write-Config() { Debug "Writing config" $Path = $TaskFullPath if (!(Test-Path -PathType Leaf $Path)) { Warn "Config does not exist, creating" } try { $global:Cfg | ConvertTo-Json -Depth 10 -Compress | Out-File $Path -Encoding utf8 -Width 10000 } catch { Error "Failed to write config: $_" } } #endregion #region schtask function Get-SchTaskConfig() { Read-Config $IdealCfg = @{ Delay = "30"; Limit = "$(18*60)"; Start = "3AM"; DaysInterval = "1"; } if ($global:Cfg.$SchTaskKeyword -eq $null) { $global:Cfg.$SchTaskKeyword = @{} } $Merged = Merge-HashTable -Default $IdealCfg -Uppend $global:Cfg.$SchTaskKeyword return $Merged } function Get-SchTask { if ($SchTaskShims) { return (Get-ScheduledTaskPS30) } else { return (Get-ScheduledTask -TaskName $SchTaskName -ErrorAction SilentlyContinue) } } function Create-SchTask($Config) { Debug "Creating scheduled task" try { if ($SchTaskShims) { Create-ScheduledTaskPS30 -Config $Config } else { $Action = New-ScheduledTaskAction -Execute "powershell.exe" -Argument "-ExecutionPolicy Unrestricted -File `"$BackupScriptFullPath`" -BackupMode" -WorkingDirectory $ResticDir $Principal = New-ScheduledTaskPrincipal -LogonType ServiceAccount -ProcessTokenSidType Default -RunLevel Highest -UserId "S-1-5-18" $SettingsSet = New-ScheduledTaskSettingsSet -AllowStartIfOnBatteries -Compatibility Win7 -DontStopIfGoingOnBatteries -ExecutionTimeLimit (New-TimeSpan -Minutes $Config.Limit) -MultipleInstances IgnoreNew ` -Priority 8 -StartWhenAvailable $Trigger = New-ScheduledTaskTrigger -At $Config.Start -Daily -DaysInterval $Config.DaysInterval -RandomDelay (New-TimeSpan -Minutes $Config.Delay) Register-ScheduledTask -Action $Action -Description "Start a scheduled Restic backup job" -Force -Principal $Principal -Settings $SettingsSet -TaskName $SchTaskName -Trigger $Trigger | Out-Null } return $true } catch { Error "Failed to create new scheduled task: $_" return $false } } function Validate-SchTask($Task, $Config) { Debug "Removing old scheduled task and setting up new task" # actual validation is not yet implemented Remove-SchTask | Out-Null Setup-SchTask | Out-Null return $true } function Setup-SchTask() { Debug "Setting up scheduled task" $SchCfg = Get-SchTaskConfig $Task = Get-SchTask if ($Task -eq $null) { return (Create-SchTask -Config $SchCfg) } else { return (Validate-SchTask -Task $Task -Config $SchCfg) } } function Remove-SchTask() { $Task = Get-SchTask if ($Task -eq $null) { Info "Restic scheduled task is missing" } else { Stop-SchTask Debug "Unregistering task" if ($SchTaskShims) { Unregister-ScheduledTaskPS30 } else { $Task | Unregister-ScheduledTask -Confirm:$false } } } function Stop-SchTask() { $Task = Get-SchTask if ($Task -ne $null) { Debug "Stopping all task instances" if ($SchTaskShims) { Stop-ScheduledTaskPS30 } else { $Task | Stop-ScheduledTask } } } function Cb-ChangeTaskSettings() { function Cb-Delay($SchCfg) { $SchCfg.Delay = Prompt-Param "Enter random delay" } function Cb-Limit($SchCfg) { $SchCfg.Limit = Prompt-Param "Enter execution limit" } function Cb-Start($SchCfg) { $SchCfg.Start = Prompt-Param "Enter start time" } function Cb-DayInterval($SchCfg) { $SchCfg.DaysInterval = Prompt-Param "Enter new day interval" } function Cb-Back() { Set-Variable -Scope 2 -Name "ShouldExit" -Value $true } $SchCfg = Get-SchTaskConfig $ShouldExit = $false while (!$ShouldExit) { Show-Menu -Title "Scheduled task configuration" -Parm $SchCfg -Menu @( @{Name = "Change random delay (now: $(Format-Value $SchCfg.Delay) minutes)"; Code = 1; Callback = ${function:Cb-Delay} } @{Name = "Change execution limit (now: $(Format-Value $SchCfg.Limit) minutes)"; Code = 2; Callback = ${function:Cb-Limit} }, @{Name = "Change start time (now: $(Format-Value $SchCfg.Start))"; Code = 3; Callback = ${function:Cb-Start} }, @{Name = "Change day interval (now: $(Format-Value $SchCfg.DaysInterval))"; Code = 4; Callback = ${function:Cb-DayInterval} }, @{Name = "Back to main menu"; Code = 0; Callback = ${function:Cb-Back} } ) } $global:Cfg.$SchTaskKeyword = $SchCfg Write-Config | Out-Null Remove-SchTask | Out-Null Setup-SchTask | Out-Null } #endregion # ----- CB: install/uninstall ----- function Cb-Install-Restic() { if (!(SetupResticDir $ResticDir) -or !(DownloadRestic $ResticFullPath) -or ` !(SaveBackupScript $BackupScriptFullPath) -or !(Process-ExclusionLists $ResticDir) -or ` !(Setup-SchTask)) { return } } function Cb-Uninstall-Restic() { $Confirm = Prompt-Param ("This will uninstall Restic and remove all configured backup tasks.`n" + ` "Backed up files will not be removed.`n" + ` "Type Y to continue") if (($Confirm -ne "Y") -and ($Confirm -ne "y")) { Info "Uninstallation aborted" return } Info "Uninstalling Restic..." Remove-SchTask if (!(Test-Path -PathType Container $ResticDir)) { Info "Restic directory is missing" } else { Debug "Removing Restic directory..." try { Remove-Item $ResticDir -Recurse -Force -ErrorAction Stop | Out-Null } catch { Warn "Failed to remove Restic directory: $_" return } } Info "OK: Restic uninstalled" } function Format-Value($Item, $Prefix, $Postfix, $DefPostfix) { $S = "not defined" if ($Item -ne $null) { if ($Item -is [bool]) { if ($Item -eq $true) { $S = "enabled" } elseif ($Item -eq $false) { $S = "disabled" } else { $S = "unknown value" } } else { $S = "$Item" } if ($DefPostfix -ne $null) { $S += $DefPostfix } } if ($Prefix -ne $null) { $S = $Prefix + $S } if ($Postfix -ne $null) { $S += $Postfix } return $S } function Show-Value($Item, $Prefix, $Postfix) { if ($Item -ne $null) { Info (Format-Value -Item $Item -Prefix $Prefix -Postfix $Postfix) } } function Show-Array($Name, $Array, [switch]$SkipEmpty, $Padding = 0) { if ($Array -eq $null) { if (!$SkipEmpty) { Info "$($Name): empty" } } else { Info "$($Name):" $I = 1 foreach ($Item in $Array) { Info "$(" " * $Padding)- #$($I): $Item" $I += 1 } } } function Get-RestServerURL($Conn) { if ($Conn -eq $null) { return "(no URL defined)" } else { return "srv=$($Conn.Server); user=$($Conn.Username); pass=$($Conn.Password); repo=$($Conn.Repo); repo_pass=$($Conn.RepoPassword)" } } function Prompt-Param($Prompt, $Mandatory = $false) { while ($true) { $S = Read-Host -Prompt $Prompt if ([string]::IsNullOrEmpty($S.Trim())) { if ($Mandatory) { Warn "Empty string" } else { return $null } } else { return $S.Trim() } } } function Prompt-Index($Collection) { if (($Collection -eq $null) -or ($Collection.Length -eq 0)) { Warn "Collection is empty" return $null } $Idx = Prompt-Param "Enter index of item to edit" if ($Idx -ne $null) { try { [int]$Code = [convert]::ToInt32($Idx, 10) } catch { Warn "Invalid item index" return $null } if (($Code -le 0) -or ($Code -gt $Collection.Length)) { Warn "Item index is out of bounds" } else { return $Code } } return $null } function Add-Array($Obj, $Name, $AName, $Item) { if ($Item -ne $null) { if (($Obj.$AName -ne $null) -and ($Obj.$AName -icontains $Item)) { Warn "$Name `"$Item`" already exists" } else { if ($Obj.$AName -eq $null) { $Obj.$AName = @($Item) } else { $Obj.$AName += $Item } Info "OK: added `"$Item`"" } } } function Edit-Array($Obj, $AName, $Callback) { $Idx = Prompt-Index $Obj.$AName if ($Idx -ne $null) { $Old = $Obj.$AName[$Idx - 1] $New = Prompt-Param "Enter new value" -Mandatory $True if ($Callback -ne $null) { if (!$Callback.Invoke($Old, $New)) { Debug "Aborting change" return } } $Obj.$AName[$Idx - 1] = $New Info "OK: changed #$($Idx) to `"$($Obj.$AName[$Idx - 1])`"" } } function Delete-Array($Obj, $AName) { $Idx = Prompt-Index $Obj.$AName if ($Idx -ne $null) { if ($Obj.$AName.Length -eq 1) { $Obj.$AName = $null } else { $Obj.$AName = $Obj.$AName | ? { $_ -ne $Obj.$AName[$Idx - 1] } if ($Obj.$AName -isnot [array]) { $Obj.$AName = @($Obj.$AName) } } Info "OK: deleted #$($Idx)" } } function Get-ArrayLength($Array) { if ($Array -is [array]) { return $Array.Length } else { return 0 } } function Remove-KeyFromCfg($Name) { $global:Cfg.Remove($Name) } function Toggle-Bool($Object, $Name) { if ($Object.$Name -eq $null) { $Object.$Name = $true } elseif ($Object.$Name -eq $true) { $Object.$Name = $false } else { $Object.$Name = $null } } function Describe-NextBool($Value) { if ($Value -eq $null) { return "Enable" } elseif ($Value -eq $true) { return "Disable" } else { return "Undefine" } } function Cb-Show-Jobs() { Read-Config $Idx = 1 foreach ($Item in ($global:Cfg).GetEnumerator()) { if (($Item.Name -ne $SchTaskKeyword) -and ($Item.Value -ne $null)) { $Job = $Item.Value Write-Host -ForegroundColor Cyan -NoNewline "--> Job #$($Idx): " Write-Host -ForegroundColor Green -NoNewline $Item.Name Write-Host -ForegroundColor Cyan -NoNewline " (type: " Write-Host -ForegroundColor Yellow -NoNewline "$(Get-JobType $Job)" Write-Host -ForegroundColor Cyan ") <--" Info "Connection parameters:" Show-Value $Job.Connection.Server -Prefix " > Server: " Show-Value $Job.Connection.Username -Prefix " > Username: " Show-Value $Job.Connection.Password -Prefix " > Password: " Show-Value $Job.Connection.Repo -Prefix " > Repo: " Show-Value $Job.Connection.RepoPassword -Prefix " > Repo password: " Show-Array -Name "Common exclusions" -Array $Job.CommonExclusions -SkipEmpty -Padding 1 Show-Array -Name "Custom exclusions" -Array $Job.CustomExclusions -SkipEmpty -Padding 1 Show-Array -Name "Backup items" -Array $Job.Items -SkipEmpty -Padding 1 if (($Job.Stdin -ne $null) -and ($Job.Stdin.Count -gt 0)) { Info "Stdin:" Show-Value $Job.Stdin.Command -Prefix " > Command: " Show-Value $Job.Stdin.Arguments -Prefix " > Arguments: " Show-Value $Job.Stdin.WorkDir -Prefix " > Workdir: " Show-Value $Job.Stdin.FileName -Prefix " > Filename: " } if (($Job.HyperVDefaults -ne $null) -and ($Job.HyperVDefaults.Count -gt 0)) { Info "Hyper-V defaults:" Show-Value $Job.HyperVDefaults.AllVMs -Prefix " > Backup all VMs: " Show-Value $Job.HyperVDefaults.AllDisks -Prefix " > Backup all disks: " Show-Value $Job.HyperVDefaults.Config -Prefix " > Backup config: " Show-Value $Job.HyperVDefaults.Snapshot -Prefix " > Snapshot fallback: " } if (($Job.HyperV -ne $null) -and ($Job.HyperV -is [array]) -and ($Job.HyperV.Length -gt 0)) { Info "Hyper-V VMs:" HV-ShowAll -Job $Job -Padding 1 } if (($Job.Options -ne $null) -and ($Job.Options.Count -gt 0)) { Info "Options:" Show-Value $Job.Options.Snapshot -Prefix " > Snapshot: " Show-Value $Job.Options.LimitRx -Prefix " > Download limit: " -Postfix " KiB" Show-Value $Job.Options.LimitTx -Prefix " > Upload limit: " -Postfix " KiB" Show-Value $Job.Options.MaxFileSize -Prefix " > Max file size: " -Postfix " KiB" } Info "" $Idx += 1 } } if ($Idx -eq 1) { Info "No backup jobs defined" } } function Change-JobStdin($Job) { function Cb-Command($Job) { $Job.Stdin.Command = Prompt-Param "Enter stdin command" } function Cb-Arguments($Job) { $Job.Stdin.Arguments = Prompt-Param "Enter stdin command arguments" } function Cb-WorkDir($Job) { $Job.Stdin.WorkDir = Prompt-Param "Enter stdin command workdir" } function Cb-FileName($Job) { $Job.Stdin.FileName = Prompt-Param "Enter resulting file name for stdin" } function Cb-Back() { Set-Variable -Scope 2 -Name "ShouldExit" -Value $true } $ShouldExit = $false while (!$ShouldExit) { Show-Menu -Title "Job `"$($Job.Name)`": Configuration -> Stdin" -Parm $Job -Menu @( @{Name = "Stdin command (now: $(Format-Value $Job.Stdin.Command))"; Code = 1; Callback = ${function:Cb-Command} } @{Name = "Stdin arguments (now: $(Format-Value $Job.Stdin.Arguments))"; Code = 2; Callback = ${function:Cb-Arguments} } @{Name = "Stdin workdir (now: $(Format-Value $Job.Stdin.WorkDir))"; Code = 3; Callback = ${function:Cb-WorkDir} } @{Name = "Stdin file name (now: $(Format-Value $Job.Stdin.FileName))"; Code = 4; Callback = ${function:Cb-FileName} } @{Name = "Back to job configuration"; Code = 0; Callback = ${function:Cb-Back} } ) } } function Change-JobConn($Job) { function Cb-Server($Job) { $Job.Connection.Server = Prompt-Param "Enter server URL" } function Cb-Username($Job) { $Job.Connection.Username = Prompt-Param "Enter username" } function Cb-Password($Job) { $Job.Connection.Password = Prompt-Param "Enter password" } function Cb-Repo($Job) { $Job.Connection.Repo = Prompt-Param "Enter repository name" } function Cb-RepoPassword($Job) { $Job.Connection.RepoPassword = Prompt-Param "Enter repository password" } function Cb-Back() { Set-Variable -Scope 2 -Name "ShouldExit" -Value $true } $ShouldExit = $false while (!$ShouldExit) { Show-Menu -Title "Job `"$($Job.Name)`": Configuration -> Connection" -Parm $Job -Menu @( @{Name = "Server URL (now: $(Format-Value $Job.Connection.Server))"; Code = 1; Callback = ${function:Cb-Server} } @{Name = "Username (now: $(Format-Value $Job.Connection.Username))"; Code = 2; Callback = ${function:Cb-Username} } @{Name = "Password (now: $(Format-Value $Job.Connection.Password))"; Code = 3; Callback = ${function:Cb-Password} } @{Name = "Repository (now: $(Format-Value $Job.Connection.Repo))"; Code = 4; Callback = ${function:Cb-Repo} } @{Name = "Repository password (optional, now: $(Format-Value $Job.Connection.RepoPassword))"; Code = 5; Callback = ${function:Cb-RepoPassword} } @{Name = "Back to job configuration"; Code = 0; Callback = ${function:Cb-Back} } ) } } function Change-JobOptions($Job) { function Cb-Snapshot($Job) { Toggle-Bool -Object $Job.Options -Name "Snapshot" } function Cb-LimitRx($Job) { $Job.Options.LimitRx = Prompt-Param "Enter new download limit (in KiB)" } function Cb-LimitTx($Job) { $Job.Options.LimitTx = Prompt-Param "Enter new upload limit (in KiB)" } function Cb-MaxFileSize($Job) { $Job.Options.MaxFileSize = Prompt-Param "Enter new max file size (in KiB)" } function Cb-Back() { Set-Variable -Scope 2 -Name "ShouldExit" -Value $true } $ShouldExit = $false while (!$ShouldExit) { Show-Menu -Title "Job `"$($Job.Name)`": Configuration -> Options" -Parm $Job -Menu @( @{Name = "$(Describe-NextBool $Job.Options.Snapshot) snapshot support (now: $(Format-Value $Job.Options.Snapshot))"; Code = 1; Callback = ${function:Cb-Snapshot} } @{Name = "Set download limit (now: $(Format-Value $Job.Options.LimitRx -DefPostfix ' KiB'))"; Code = 2; Callback = ${function:Cb-LimitRx} } @{Name = "Set upload limit (now: $(Format-Value $Job.Options.LimitTx -DefPostfix ' KiB'))"; Code = 3; Callback = ${function:Cb-LimitTx} } @{Name = "Set max file size (now: $(Format-Value $Job.Options.MaxFileSize -DefPostfix ' KiB'))"; Code = 4; Callback = ${function:Cb-MaxFileSize} } @{Name = "Back to job configuration"; Code = 0; Callback = ${function:Cb-Back} } ) } } function Change-JobItems($Job) { function Cb-Show($Job) { Show-Array -Name "Backup items" -Array $Job.Items } function Cb-Add($Job) { Add-Array -Obj $Job -Name "Backup item" -AName "Items" -Item (Prompt-Param "Path to folder or file name") } function Cb-Edit($Job) { Edit-Array -Obj $Job -AName "Items" } function Cb-Delete($Job) { Delete-Array -Obj $Job -AName "Items" } function Cb-Back() { Set-Variable -Scope 2 -Name "ShouldExit" -Value $true } $ShouldExit = $false while (!$ShouldExit) { Show-Menu -Title "Job `"$($Job.Name)`": Configuration -> Items" -Parm $Job -Menu @( @{Name = "Show backup items"; Code = 1; Callback = ${function:Cb-Show}; Postfix = (Get-ArrayLength -Array $Job.Items) }, @{Name = "Add new backup item"; Code = 2; Callback = ${function:Cb-Add} }, @{Name = "Edit backup item"; Code = 3; Callback = ${function:Cb-Edit} }, @{Name = "Delete backup item"; Code = 4; Callback = ${function:Cb-Delete} }, @{Name = "Back to job configuration"; Code = 0; Callback = ${function:Cb-Back} } ) } } function Change-JobExcl($Job) { function Cb-Show($Job) { Show-Array -Name "Common exclusions" -Array $Job.CommonExclusions Show-Array -Name "Custom exclusions" -Array $Job.CustomExclusions } function Cb-AddCommon($Job) { $Excl = Prompt-Param "Exclusion file name" if ($Excl -ne "") { if (!(Download-ExclusionListFile -Name $Excl)) { Warn "Failed to download exclusion list - item will not be added" } else { Add-Array -Obj $Job -Name "Common exclusion" -AName "CommonExclusions" -Item $Excl } } } function Cb-EditCommon($Job) { function Cb-BeforeEdit($Old, $New) { if (!(Download-ExclusionListFile -Name $New)) { Warn "Failed to download exclusion list - item will not be changed" return $false } else { Delete-ExclusionListFile -Name $Old return $true } } Edit-Array -Obj $Job -AName "CommonExclusions" -Callback ${function:Cb-BeforeEdit} } function Cb-DeleteCommon($Job) { Delete-Array -Obj $Job -AName "CommonExclusions" } function Cb-AddCustom($Job) { Add-Array -Obj $Job -Name "Custom exclusion" -AName "CustomExclusions" -Item (Prompt-Param "Exclusion file name") } function Cb-EditCustom($Job) { Edit-Array -Obj $Job -AName "CustomExclusions" } function Cb-DeleteCustom($Job) { Delete-Array -Obj $Job -AName "CustomExclusions" } function Cb-Back() { Set-Variable -Scope 2 -Name "ShouldExit" -Value $true } $ShouldExit = $false while (!$ShouldExit) { Show-Menu -Title "Job `"$($Job.Name)`": Configuration -> Exclusions" -Parm $Job -Menu @( @{Name = "Show exclusions"; Code = 1; Callback = ${function:Cb-Show}; Postfix = ((Get-ArrayLength -Array $Job.CommonExclusions) + (Get-ArrayLength -Array $Job.CustomExclusions)) }, @{Name = "Add common exclusions"; Code = 2; Callback = ${function:Cb-AddCommon} }, @{Name = "Edit common exclusion"; Code = 3; Callback = ${function:Cb-EditCommon} }, @{Name = "Delete common exclusion"; Code = 4; Callback = ${function:Cb-DeleteCommon} }, @{Name = "Add custom exclusions"; Code = 5; Callback = ${function:Cb-AddCustom} }, @{Name = "Edit custom exclusion"; Code = 6; Callback = ${function:Cb-EditCustom} }, @{Name = "Delete custom exclusion"; Code = 7; Callback = ${function:Cb-DeleteCustom} }, @{Name = "Back to job configuration"; Code = 0; Callback = ${function:Cb-Back} } ) } } function Change-JobName($Job) { $Name = Read-JobName if ($Name -ne $null) { Debug "Renaming job `"$($Job.Name)`" to `"$Name`"" $Job.Name = $Name } } # ----- Hyper-V integration ----- function Check-HyperVAvailability([switch]$Force) { if (!$Force -and ($global:HasHyperV -ne $null)) { return $global:HasHyperV } $Avail = $false try { $FeatureInfo = Get-WindowsOptionalFeature -Online -FeatureName Microsoft-Hyper-V $Avail = (($FeatureInfo -ne $null) -and ($FeatureInfo.State -eq "Enabled")) } catch { Warn "Failed to get Hyper-V availability status: $_" } if (!$Avail) { Warn "Warning - Hyper-V is not available on this host" } $global:HasHyperV = $Avail return $Avail } function HV-ShowAll($Job, $Padding = 0) { $PadStr = (" " * $Padding) if (($Job.HyperV -eq $null) -or ($Job.HyperV -isnot [array]) -or ($Job.HyperV.Length -eq 0)) { Info "$($PadStr)No Hyper-V VMs defined" } else { $Idx = 1 foreach ($VM in $Job.HyperV) { Write-Host -ForegroundColor Cyan -NoNewline "$($PadStr)VM #$($Idx): " Write-Host -ForegroundColor Green $VM.Name Show-Value $VM.AllDisks -Prefix "$($PadStr) > Back up all disks: " Show-Value $VM.Config -Prefix "$($PadStr) > Back up VM configuration: " if (!(($VM.Disks -eq $null) -or ($VM.Disks -isnot [array]) -or ($VM.Disks.Length -eq 0))) { Info "$($PadStr) Disks:" foreach ($Disk in $VM.Disks) { Info "$($PadStr) - $Disk" } } $Idx += 1 if ($Padding -eq 0) { Info "" } } if ($Padding -eq 0) { Info "$($PadStr)$($Idx - 1) total VMs" } } } function HV-ChangeDefaults($Job) { function Cb-AllVMs($Job) { Toggle-Bool -Object $Job.HyperVDefaults -Name "AllVMs" } function Cb-AllDisks($Job) { Toggle-Bool -Object $Job.HyperVDefaults -Name "AllDisks" } function Cb-Config($Job) { Toggle-Bool -Object $Job.HyperVDefaults -Name "Config" } function Cb-Snapshot($Job) { Toggle-Bool -Object $Job.HyperVDefaults -Name "Snapshot" } function Cb-Back() { Set-Variable -Scope 2 -Name "ShouldExit" -Value $true } $ShouldExit = $false while (!$ShouldExit) { Show-Menu -Title "Job `"$($Job.Name)`": Configuration -> Hyper-V -> Defaults" -Parm $Job -Menu @( @{Name = "$(Describe-NextBool $Job.HyperVDefaults.AllVMs) backing up of all VMs (now: $(Format-Value $Job.HyperVDefaults.AllVMs))"; Code = 1; Callback = ${function:Cb-AllVMs} } @{Name = "$(Describe-NextBool $Job.HyperVDefaults.AllDisks) backing up of all VM disks (now: $(Format-Value $Job.HyperVDefaults.AllDisks))"; Code = 2; Callback = ${function:Cb-AllDisks} } @{Name = "$(Describe-NextBool $Job.HyperVDefaults.Config) backing up of VM configuration (now: $(Format-Value $Job.HyperVDefaults.Config))"; Code = 3; Callback = ${function:Cb-Config} } @{Name = "$(Describe-NextBool $Job.HyperVDefaults.Snapshot) VSS fallback for VM backups (now: $(Format-Value $Job.HyperVDefaults.Snapshot))"; Code = 4; Callback = ${function:Cb-Snapshot} } @{Name = "Back to Hyper-V configuration"; Code = 0; Callback = ${function:Cb-Back} } ) } } function HV-EnterVMEditMode($Job, $VM) { function Cb-AllDisks($VM) { Toggle-Bool -Object $VM -Name "AllDisks" } function Cb-Config($VM) { Toggle-Bool -Object $VM -Name "Config" } function Cb-ShowDisks($VM) { Show-Array -Name "Disks" -Array $VM.Disks } function Cb-AddDisk($VM) { Add-Array -Obj $VM -Name "Disk" -AName "Disks" -Item (Prompt-Param "Disk name") } function Cb-EditDisk($VM) { Edit-Array -Obj $VM -AName "Disks" } function Cb-DeleteDisk($VM) { Delete-Array -Obj $VM -AName "Disks" } function Cb-Back() { Set-Variable -Scope 2 -Name "ShouldExit" -Value $true } $ShouldExit = $false while (!$ShouldExit) { Show-Menu -Title "Job `"$($Job.Name)`": Configuration -> Hyper-V -> VM `"$($VM.Name)`"" -Parm $VM -Menu @( @{Name = "$(Describe-NextBool $VM.AllDisks) backing up of all disks (now: $(Format-Value $VM.AllDisks))"; Code = 1; Callback = ${function:Cb-AllDisks} } @{Name = "$(Describe-NextBool $VM.Config) backing up of configuration (now: $(Format-Value $VM.Config))"; Code = 2; Callback = ${function:Cb-Config} } @{Name = "Show VM disks"; Code = 3; Callback = ${function:Cb-ShowDisks}; Postfix = (Get-ArrayLength -Array $VM.Disks) } @{Name = "Add VM disk"; Code = 4; Callback = ${function:Cb-AddDisk} } @{Name = "Edit VM disk"; Code = 5; Callback = ${function:Cb-EditDisk} } @{Name = "Delete VM disk"; Code = 6; Callback = ${function:Cb-DeleteDisk} } @{Name = "Back to Hyper-V configuration"; Code = 0; Callback = ${function:Cb-Back} } ) } } function HV-VMExists($Job, $Name) { return (($Job.HyperV | ? { $_.Name -eq $Name }) -ne $null) } function HV-VMNotExists($Job, $Name) { return (($Job.HyperV | ? { $_.Name -eq $Name }) -eq $null) } function HV-AddVM($Job) { $Name = Prompt-Param "Enter VM name" -Mandatory $True if (HV-VMExists -Job $Job -Name $Name) { Warn "Hyper-V VM `"$Name`" already exists" return } $VM = @{Name = $Name } HV-EnterVMEditMode -Job $Job -VM $VM $Job.HyperV += $VM } function HV-EditVM($Job) { $Name = Prompt-Param "Enter VM name" -Mandatory $True if (HV-VMNotExists -Job $Job -Name $Name) { Warn "Hyper-V VM `"$Name`" does not exist" return } $VM = ($Job.HyperV | ? { $_.Name -eq $Name }) HV-EnterVMEditMode -Job $Job -VM $VM } function HV-DeleteVM($Job) { $Name = Prompt-Param "Enter VM name" -Mandatory $True if (HV-VMNotExists -Job $Job -Name $Name) { Warn "Hyper-V VM `"$Name`" does not exist" return } $Job.HyperV = $Job.HyperV | ? { $_.Name -ne $Name } if ($Job.HyperV -isnot [array]) { $Job.HyperV = @($Job.HyperV) } Info "VM `"$Name`" deleted from job `"$($Job.Name)`"" } function HV-RenameVM($Job) { $Name = Prompt-Param "Enter VM name" -Mandatory $True if (HV-VMNotExists -Job $Job -Name $Name) { Warn "Hyper-V VM `"$Name`" does not exist" return } $NewName = Prompt-Param "Enter new VM name" -Mandatory $True if (HV-VMExists -Job $Job -Name $NewName) { Warn "Hyper-V VM `"$Name`" already exists" return } $VM = ($Job.HyperV | ? { $_.Name -eq $Name }) $VM.Name = $NewName Info "VM `"$Name`" renamed to `"$($VM.Name)`" in job `"$($Job.Name)`"" } function Change-JobHyperV($Job) { function Cb-Back() { Set-Variable -Scope 2 -Name "ShouldExit" -Value $true } if ($Job.HyperVDefaults -eq $null) { $Job.HyperVDefaults = @{} } if ($Job.HyperV -eq $null) { $Job.HyperV = @() } Check-HyperVAvailability | Out-Null $ShouldExit = $false while (!$ShouldExit) { Show-Menu -Title "Job `"$($Job.Name)`": Configuration -> Hyper-V" -Parm $Job -Menu @( @{Name = "Show Hyper-V VMs"; Code = 1; Callback = ${function:HV-ShowAll}; Postfix = (Get-ArrayLength -Array $Job.HyperV) }, @{Name = "Add Hyper-V VM"; Code = 2; Callback = ${function:HV-AddVM} }, @{Name = "Edit Hyper-V VM"; Code = 3; Callback = ${function:HV-EditVM} }, @{Name = "Delete Hyper-V VM"; Code = 4; Callback = ${function:HV-DeleteVM} }, @{Name = "Rename Hyper-V VM"; Code = 5; Callback = ${function:HV-RenameVM} }, @{Name = "Change Hyper-V defaults"; Code = 6; Callback = ${function:HV-ChangeDefaults} }, @{Name = "Back to job configuration"; Code = 0; Callback = ${function:Cb-Back} } ) } } function Enter-JobEditMode($Job) { function Cb-Conn($Job) { Change-JobConn $Job } function Cb-Excl($Job) { Change-JobExcl $Job } function Cb-Items($Job) { Change-JobItems $Job } function Cb-HyperV($Job) { Change-JobHyperV $Job } function Cb-Stdin($Job) { Change-JobStdin $Job } function Cb-Options($Job) { Change-JobOptions $Job } function Cb-Rename($Job) { Change-JobName $Job } function Cb-Back() { Set-Variable -Scope 2 -Name "ShouldExit" -Value $true } if ($Job.Connection -eq $null) { $Job.Connection = @{} } if ($Job.Stdin -eq $null) { $Job.Stdin = @{} } if ($Job.Options -eq $null) { $Job.Options = @{} } $ShouldExit = $false while (!$ShouldExit) { Show-Menu -Title "Job `"$($Job.Name)`": Configuration" -Parm $Job -Menu @( @{Name = "Set connection settings (now: $(Get-RestServerURL -Conn $Job.Connection))"; Code = 1; Callback = ${function:Cb-Conn} }, @{Name = "Add/remove exclusion lists"; Code = 2; Callback = ${function:Cb-Excl}; Postfix = ((Get-ArrayLength -Array $Job.CommonExclusions) + (Get-ArrayLength -Array $Job.CustomExclusions)) }, @{Name = "Add/remove backup items"; Code = 3; Callback = ${function:Cb-Items}; Postfix = (Get-ArrayLength -Array $Job.Items) }, @{Name = "Add/remove Hyper-V VMs"; Code = 4; Callback = ${function:Cb-HyperV}; Postfix = (Get-ArrayLength -Array $Job.HtperV) }, @{Name = "Set stdin parameters"; Code = 5; Callback = ${function:Cb-Stdin} }, @{Name = "Set backup options"; Code = 6; Callback = ${function:Cb-Options} } @{Name = "Rename job"; Code = 7; Callback = ${function:Cb-Rename} } @{Name = "Back to job menu"; Code = 0; Callback = ${function:Cb-Back} } ) } } function Read-JobName { $Name = Prompt-Param "Enter new job name" -Mandatory $True if (!(Is-ValidJobName -Name $Name)) { Error "Cannot add job `"$Name`": invalid or reserved name" return $null } if (Is-JobExists -Name $Name) { Error "Cannot add job `"$Name`": already exists" return $null } return $Name } function Validate-JobParams($Job) { if (($Job.Connection.Server -eq $null) -or ($Job.Connection.Username -eq $null) -or ` ($Job.Connection.Password -eq $null) -or ($Job.Connection.Repo -eq $null)) { Error "Required connection parameters are missing (server, username, password and repo must be defined)" return $false } if (($Job.Items -eq $null) -and ` (($Job.Stdin -eq $null) -or ($Job.Stdin.Command -eq $null)) -and ` (($Job.HyperV -eq $null) -or ($Job.HyperV -isnot [array]) -or ($Job.HyperV.Length -eq 0))) { Error "Job has no backup items" return $false } return $true } function Cb-Add-Job() { Info "Adding new backup job" Read-Config $Name = Read-JobName if ($Name -ne $null) { $Conn = @{Server = $DefaultRestServer } if ((Count-Jobs) -ge 1) { foreach ($Job in ($global:Cfg).GetEnumerator()) { if ((Is-ValidJobName -Name $Job.Name) -and ($Job.Value.Connection -ne $null) -and ` ($Job.Value.Connection.Username -is [string]) -and ($Job.Value.Connection.Repo -is [string]) -and ` ($Job.Value.Connection.Password -is [string])) { $Conn.Username = $Job.Value.Connection.Username $Conn.Repo = $Job.Value.Connection.Repo $Conn.Password = $Job.Value.Connection.Password break } } } $Job = @{Name = $Name; Connection = $Conn; CommonExclusions = @("exclude-common.txt"); Options = @{}; Stdin = @{} } while ($true) { Enter-JobEditMode -Job $Job if (Validate-JobParams -Job $Job) { $global:Cfg[$Job.Name] = $Job Info "Job `"$($Job.Name)`" added" Write-Config return } } } } function Cb-Edit-Job() { Read-Config $Name = Prompt-Param "Enter job name" -Mandatory $True foreach ($Job in ($global:Cfg).GetEnumerator()) { if (($Job.Name -eq $Name) -and (Is-ValidJobName -Name $Name)) { Info "Editing job `"$Name`"" while ($true) { $OldName = $Job.Name Enter-JobEditMode -Job $Job.Value if (Validate-JobParams -Job $Job.Value) { if ($OldName -ne $Job.Value.Name) { Debug "Removing outdated reference to a renamed job" Remove-KeyFromCfg $OldName } $global:Cfg[$Job.Value.Name] = $Job.Value Info "Job `"$($Job.Value.Name)`" changed" Write-Config return } } } } Warn "Job `"$Name`" does not exist" } function Cb-Delete-Job() { Read-Config $Name = Prompt-Param "Enter job name" -Mandatory $True foreach ($Job in ($global:Cfg).GetEnumerator()) { if (($Job.Name -eq $Name) -and (Is-ValidJobName -Name $Name)) { $Confirm = Prompt-Param ("Delete job `"$Name`"? Y/N") -Mandatory $True if (($Confirm -ne "Y") -and ($Confirm -ne "y")) { return } Remove-KeyFromCfg $Name Info "Job `"$Name`" deleted" Write-Config return } } Warn "Job `"$Name`" does not exist" } function Cb-Count-Jobs { Read-Config return (Count-Jobs) } function Cb-Configure-Jobs() { function Cb-Back() { Set-Variable -Scope 2 -Name "ShouldExit" -Value $true } $ShouldExit = $false while (!$ShouldExit) { Show-Menu -Title "Backup jobs" -Menu @( @{Name = "Show backup jobs"; Code = 1; Callback = ${function:Cb-Show-Jobs}; Postfix = (Cb-Count-Jobs) }, @{Name = "Add backup job"; Code = 2; Callback = ${function:Cb-Add-Job} }, @{Name = "Edit backup job"; Code = 3; Callback = ${function:Cb-Edit-Job} }, @{Name = "Delete backup job"; Code = 4; Callback = ${function:Cb-Delete-Job} }, @{Name = "Run backup job"; Code = 5; Callback = ${function:Cb-Run-Job} }, @{Name = "Run all backup jobs"; Code = 6; Callback = ${function:Cb-Run-All-Jobs} }, @{Name = "Back to main menu"; Code = 0; Callback = ${function:Cb-Back} } ) } } # ----- Util callbacks ----- function Cb-Test-Connectivity() { $URLString = Prompt-Param "Enter rest-server URL, or enter nothing to check server URLs in all tasks" Read-Config $RS = @() if ($URLString -ne $null) { $RS += $URLString } else { foreach ($Item in ($global:Cfg).GetEnumerator()) { if (($Item.Name -ne $SchTaskKeyword) -and ($Item.Value -ne $null) -and ($Item.Value.Connection -ne $null)) { $RS += $Item.Value.Connection.Server } } } if ($RS.Length -eq 0) { Warn "No rest-server URLs found" return } $RS = $RS | Select-Object -Unique if ($RS -isnot [array]) { $RS = @($RS) } $Total = 0 foreach ($Server in $RS) { try { Info "Sending request to `"$Server`"..." $URL = $Server if (!($URL.StartsWith("https://")) -and !($URL.StartsWith("http://"))) { $URL = "https://" + $URL } $Request = [System.Net.WebRequest]::Create($URL) $Request.UseDefaultCredentials = $true $Request.Method = "HEAD" try { $Response = $Request.GetResponse() } catch [System.Net.WebException] { $Response = $_.Exception.Response } $StatusCode = [int]$Response.StatusCode if ($StatusCode -eq 401) { Info "OK: got HTTP/401 response from server `"$URL`" - server is responding correctly" $Total += 1 } elseif ($StatusCode -ne 0) { Warn "Unexpected HTTP/$StatusCode response from server `"$URL`"" } else { Warn "Unexpected or no response from server `"$URL`"" } } catch { Warn "Unexpected error while checking `"$Server`": $($_.Exception)" } } Info "Finished connectivity test: $Total out of $($RS.Length) OK" } function Cb-Dump-Config() { Info "Dumping configuration" Read-Config Info "$($global:Cfg | ConvertTo-Json -Depth 10)" } function Cb-Exit-Program() { Info "Exiting" exit } #region run function Build-RestServerURL($Conn, [switch]$NoPassword) { $Password = "***" if (!$NoPassword) { $Password = $Conn.Password } $URL = ($Conn.Username + ":" + $Password + "@" + $Conn.Server + "/" + $Conn.Username + "/" + $Conn.Repo) $Idx = $Conn.Server.IndexOf('://') if ($Idx -ne -1) { $URL = ($Conn.Server.Insert($Idx + 3, $Conn.Username + ":" + $Password + "@") + "/" + $Conn.Username + "/" + $Conn.Repo) } return $URL } function Run-Job($Job) { $Parms = @() $Items = @() $Excl = @() $Conn = $Job.Connection $HasStdin = (($Job.Stdin -ne $null) -and ($Job.Stdin.Command -ne $null)) if ($Job.Name -eq $null) { Error "Missing job name" return $false } Info "Preparing job `"$($Job.Name)`"" if ($Job.Options -ne $null) { if ($Job.Options.Snapshot -eq $true) { $Parms += "--use-fs-snapshot" } if ($Job.Options.LimitRx -ne $null) { $Parms += "--limit-download" $Parms += $Job.Options.LimitRx } if ($Job.Options.LimitTx -ne $null) { $Parms += "--limit-upload" $Parms += $Job.Options.LimitTx } if ($Job.Options.MaxFileSize -ne $null) { $Parms += "--exclude-larger-than" $Parms += "$($Job.Options.MaxFileSize)K" } } if ($HasStdin) { $Parms += "--stdin" if ($Job.Stdin.FileName -ne $null) { $Parms += "--stdin-filename" $Parms += "`"$($Job.Stdin.FileName)`"" } } if (($Job.Items -ne $null) -and ($Job.Items -is [array])) { foreach ($Item in $Job.Items) { $Items += "`"$Item`"" } } if (($Job.CommonExclusions -ne $null) -and ($Job.CommonExclusions -is [array])) { foreach ($Item in $Job.CommonExclusions) { $Excl += "--iexclude-file" $Excl += "`"$Item`"" } } if (($Job.CustomExclusions -ne $null) -and ($Job.CustomExclusions -is [array])) { foreach ($Item in $Job.CustomExclusions) { $Excl += "--iexclude-file" $Excl += "`"$Item`"" } } if (($Items.Length -eq 0) -and !$HasStdin) { Error "No backup items defined for `"$($Job.Name)`"" return $false } if (($Items.Length -gt 0) -and $HasStdin) { Error "Stdin parameters cannot be defined with backup items for `"$($Job.Name)`"" return $false } if (($Conn -eq $null) -or ($Conn.Server -eq $null) -or ($Conn.Username -eq $null) -or ($Conn.Password -eq $null) -or ($Conn.Repo -eq $null)) { Error "Some connection parameters are missing for `"$($Job.Name)`"" return $false } $URL = Build-RestServerURL -Conn $Conn $EnvDict = @{ RESTIC_REPOSITORY = ("rest:" + $URL) } if ($Conn.RepoPassword -ne $null) { $EnvDict['RESTIC_PASSWORD'] = $Conn.RepoPassword } else { $EnvDict['RESTIC_PASSWORD'] = $Conn.Password } if (!(Test-Path -PathType Leaf $ResticFullPath)) { Error "Restic binary is missing for `"$($Job.Name)`"" return $false } $FullParms = (($Parms -join ' ') + ' ' + ($Excl -join ' ') + ' ' + ($Items -join ' ')).Trim() Info "Starting job `"$($Job.Name)`" on rest:$(Build-RestServerURL -Conn $Conn -NoPassword)" Info "Parameters: $FullParms" $SW = [Diagnostics.Stopwatch]::StartNew() if ($HasStdin) { $Res = Execute-CommandPipe -Cmd1 @{FileName = $Job.Stdin.Command; Arguments = $Job.Stdin.Arguments; WorkDir = $Job.Stdin.WorkDir } ` -Cmd2 @{FileName = $ResticFullPath; Arguments = "backup $FullParms"; WorkDir = $ResticDir } ` -EnvDict $EnvDict } else { $Res = Execute-Command -FileName $ResticFullPath -Arguments "backup $FullParms" -WorkDir $ResticDir -EnvDict $EnvDict } $SW.Stop() $Elapsed = "(unknown value)" try { $Elapsed = $SW.Elapsed.ToString("dd\.hh\:mm\:ss") } catch { try { $Elapsed = $SW.Elapsed.ToString() } catch {} } if (($Res.ExitCode -eq 0) -or ($Res.ExitCode -eq 3)) { Success "OK: job `"$($Job.Name)`" completed in $Elapsed" return $true } else { Error "Fail: job `"$($Job.Name)`" failed in $Elapsed" return $false } } function Cb-Run-Job() { $Name = Prompt-Param "Enter job name" -Mandatory $True Read-Config foreach ($Job in ($global:Cfg).GetEnumerator()) { if (($Job.Name -eq $Name) -and (Is-ValidJobName -Name $Name)) { Run-Job -Job $Job.Value return } } Warn "Job `"$Name`" does not exist" } function Cb-Run-All-Jobs() { Info "Starting all backup jobs" Read-Config $RunOK = 0 $RunFail = 0 foreach ($Job in ($global:Cfg).GetEnumerator()) { if (Is-ValidJobName -Name $Job.Name) { if (Run-Job -Job $Job.Value) { $RunOK += 1 } else { $RunFail += 1 } } } Info "Finished backup jobs: $RunOK completed, $RunFail failed" } #endregion function Cb-QuickSet() { Debug "Starting quick set mode" Read-Config $NumJobs = Count-Jobs if ($NumJobs -ge 1) { Info "There are $NumJobs job(s) already defined. To edit these, use the `"Configure backup jobs`" menu" } $Confirm = Prompt-Param ("Start Restic installation? Y/N") -Mandatory $True if (($Confirm -eq "Y") -or ($Confirm -eq "y")) { Cb-Install-Restic } Info "Adding a new job" $Name = Read-JobName $Username = Prompt-Param "[1/4]: Enter REST server username" -Mandatory $True $Password = Prompt-Param "[2/4]: Enter REST server password" -Mandatory $True $Repo = Prompt-Param "[3/4]: Enter REST server repository" -Mandatory $True $Items = @() while ($true) { $Item = $null if ($Items.Length -eq 0) { $Item = Prompt-Param "[4/4]: Enter directory path or filename" } else { $Item = Prompt-Param "[...]: Enter directory path or filename for item #$($Items.Length + 1), or press ENTER to end adding new items" } if ($Item -eq $null) { break } $Items += $Item } $Items = $Items | Sort-Object -Property @{Expression = { $_.Trim() } } -Unique if ($Items.Length -eq 0) { Warn "Item list is empty - nothing to back up" return } if ($Items -isnot [array]) { $Items = @($Items) } $Job = @{Name = $Name; Connection = @{ ` Server = $DefaultRestServer; Username = $Username; Password = $Password; Repo = $Repo }; ` Items = $Items; ` CommonExclusions = @("exclude-common.txt"); Options = @{Snapshot = $true }; Stdin = @{} } if (Validate-JobParams -Job $Job) { $global:Cfg[$Job.Name] = $Job Info "Job `"$($Job.Name)`" added" Write-Config } } if (!$BackupMode) { while ($true) { Show-Menu -Title "Restic installation script | $ScriptVersion | $ScriptAuthor" -Menu @( @{Name = "-> QUICK SET <-"; Code = 1; Callback = ${function:Cb-QuickSet} }, @{Name = "Install Restic"; Code = 2; Callback = ${function:Cb-Install-Restic} }, @{Name = "Configure backup jobs"; Code = 3; Callback = ${function:Cb-Configure-Jobs} }, @{Name = "Change scheduled task settings"; Code = 4; Callback = ${function:Cb-ChangeTaskSettings} }, @{Name = "Test rest-server connectivity"; Code = 5; Callback = ${function:Cb-Test-Connectivity} }, @{Name = "Dump tasks.json"; Code = 6; Callback = ${function:Cb-Dump-Config} }, @{Name = "Uninstall Restic"; Code = 7; Callback = ${function:Cb-Uninstall-Restic} }, @{Name = "Exit"; Code = 0; Callback = ${function:Cb-Exit-Program} } ) Info "" } } else { Debug "Running in unattended mode" Cb-Run-All-Jobs }