๐Ÿ“œ A script to simplify Restic deployment on Windows workstations and servers
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
restic-script/Restic-Script.ps1

2520 lines
84 KiB

#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*(?<Comma>,)?\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
}