# Installs the scheduled task that puts the shell server's home directory back into shape every 30 minutes: the # prompt, the home directory and the four text files. The task runs as SYSTEM, so the shell server's root user can # neither stop it nor change the copies it restores from. # # Run this in an elevated PowerShell on the server, with this script and the ../resources directory next to it: # powershell -ExecutionPolicy Bypass -File .\install_restore_task.ps1 # Remove the task and the copies again with: # powershell -ExecutionPolicy Bypass -File .\install_restore_task.ps1 -Uninstall [CmdletBinding()] param( [string]$HomeDir = 'C:\msys2\home\root', [switch]$Uninstall ) $ErrorActionPreference = 'Stop' $installDir = 'C:\ProgramData\kik-restore' $copiesDir = Join-Path $installDir 'files' $taskName = 'KiK restore shell files' # The copies the task restores from and the script it runs both live in $installDir, which only SYSTEM and the # administrators may read or write. $restoreScript = @' # Puts the shell server's home directory back into shape: the directory itself, the shell configuration that gives # the prompt, the keys that let the website in, and the four text files. Runs as SYSTEM every 30 minutes; the shell # server's root user can neither stop it nor change the copies it restores from. param([string]$HomeDir = 'C:\msys2\home\root') $log = Join-Path $PSScriptRoot 'restore.log' $copies = Join-Path $PSScriptRoot 'files\home' # The account of the shell server's root user, whose id the files in its home directory belong to. $passwd = Get-Content 'C:\msys2\etc\passwd' -ErrorAction SilentlyContinue | Where-Object { $_ -like 'root:*' } $sid = if ($passwd) { ($passwd -split ':')[4].Split(',')[-1] } function Write-Log($message) { "$(Get-Date -Format s) $message" | Out-File -FilePath $log -Append } # Removes a file, a directory or a link, but never what a link points to. function Remove-Anything($path) { $item = Get-Item -LiteralPath $path -Force if ($item.Attributes -band [IO.FileAttributes]::ReparsePoint) { if ($item.PSIsContainer) { [IO.Directory]::Delete($path) } else { [IO.File]::Delete($path) } } elseif ($item.PSIsContainer) { Remove-Item -LiteralPath $path -Recurse -Force } else { Remove-Item -LiteralPath $path -Force } } # The website logs in with a key in the authorized_keys of the user. The user may keep keys of their own, so only # the key of the website is put back. function Restore-Keys($target, $source) { $required = @(Get-Content -LiteralPath $source | Where-Object { $_.Trim() }) $blobs = $required | ForEach-Object { ($_ -split '\s+')[1] } $keys = @() if (Test-Path -LiteralPath $target) { $keys = @(Get-Content -LiteralPath $target | Where-Object { $_.Trim() -and $blobs -notcontains ($_ -split '\s+')[1] }) } [IO.File]::WriteAllText($target, (($keys + $required) -join "`n") + "`n") } # The permissions of the home directory and of the files in it that have to stay: the user may read and write them # and delete their own files, but not the home directory itself and not the files the website needs. Only SYSTEM # and the administrators own them, so the user cannot take the permissions away again. function Set-ProtectedAcl($path, $forDirectory) { if (-not $sid) { return } try { $system = New-Object System.Security.Principal.SecurityIdentifier('S-1-5-18') $admins = New-Object System.Security.Principal.SecurityIdentifier('S-1-5-32-544') $user = New-Object System.Security.Principal.SecurityIdentifier($sid) $acl = Get-Acl -LiteralPath $path $acl.SetAccessRuleProtection($true, $false) foreach ($rule in @($acl.Access)) { [void]$acl.RemoveAccessRuleSpecific($rule) } $acl.SetOwner($admins) $none = [System.Security.AccessControl.InheritanceFlags]::None $children = [System.Security.AccessControl.InheritanceFlags]::ContainerInherit -bor [System.Security.AccessControl.InheritanceFlags]::ObjectInherit $noPropagate = [System.Security.AccessControl.PropagationFlags]::None $inheritOnly = [System.Security.AccessControl.PropagationFlags]::InheritOnly $full = [System.Security.AccessControl.FileSystemRights]::FullControl $some = [System.Security.AccessControl.FileSystemRights]::ReadAndExecute -bor [System.Security.AccessControl.FileSystemRights]::Write $inherit = if ($forDirectory) { $children } else { $none } $acl.AddAccessRule((New-Object System.Security.AccessControl.FileSystemAccessRule -ArgumentList $system, $full, $inherit, $noPropagate, 'Allow')) $acl.AddAccessRule((New-Object System.Security.AccessControl.FileSystemAccessRule -ArgumentList $admins, $full, $inherit, $noPropagate, 'Allow')) $acl.AddAccessRule((New-Object System.Security.AccessControl.FileSystemAccessRule -ArgumentList $user, $some, $none, $noPropagate, 'Allow')) if ($forDirectory) { # the files in it are the user's own, they may delete them $acl.AddAccessRule((New-Object System.Security.AccessControl.FileSystemAccessRule -ArgumentList $user, $full, $children, $inheritOnly, 'Allow')) } Set-Acl -LiteralPath $path -AclObject $acl } catch { Write-Log "the permissions of $path are not set: $_" } } # The home directory is the one from /etc/passwd, which the user cannot change, but which they can delete. A new one # gets MSYS2's default files from /etc/skel, which MSYS2 itself only copies into a home that does not exist yet. function New-Home($path) { New-Item -ItemType Directory -Path $path | Out-Null Set-ProtectedAcl $path $true Get-ChildItem -LiteralPath 'C:\msys2\etc\skel' -Force | Copy-Item -Destination $path -Recurse -Force } if (-not (Test-Path -LiteralPath $HomeDir)) { New-Home $HomeDir Write-Log "created $HomeDir" } elseif ((Get-Item -LiteralPath $HomeDir -Force).Attributes -band [IO.FileAttributes]::ReparsePoint) { Remove-Anything $HomeDir New-Home $HomeDir Write-Log "replaced the link $HomeDir" } else { Set-ProtectedAcl $HomeDir $true } foreach ($copy in Get-ChildItem -LiteralPath $copies -Recurse -File -Force) { $target = Join-Path $HomeDir $copy.FullName.Substring($copies.Length).TrimStart('\') try { $dir = Split-Path -Parent $target if ((Test-Path -LiteralPath $dir) -and -not (Get-Item -LiteralPath $dir -Force).PSIsContainer) { Remove-Anything $dir } if (-not (Test-Path -LiteralPath $dir)) { New-Item -ItemType Directory -Path $dir | Out-Null } Set-ProtectedAcl $dir $true if (Test-Path -LiteralPath $target) { $item = Get-Item -LiteralPath $target -Force if ($item.PSIsContainer -or $item.Attributes -band [IO.FileAttributes]::ReparsePoint) { # anything but a file is replaced, so that the files are always the text files they should be Remove-Anything $target } else { # the user may have locked the file; taking it back lets us write it takeown /f $target /a | Out-Null } } if ($copy.Name -eq 'authorized_keys') { Restore-Keys $target $copy.FullName } else { Copy-Item -LiteralPath $copy.FullName -Destination $target -Force } Set-ProtectedAcl $target $false Write-Log "restored $($copy.Name)" } catch { Write-Log "failed $($copy.Name) : $_" } } '@ if (-not ([Security.Principal.WindowsPrincipal] [Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) { throw 'Run this in an elevated PowerShell.' } if ($Uninstall) { Unregister-ScheduledTask -TaskName $taskName -Confirm:$false -ErrorAction SilentlyContinue if (Test-Path -LiteralPath $installDir) { Remove-Item -LiteralPath $installDir -Recurse -Force } Write-Host "Removed the task and $installDir." return } $resources = Join-Path $PSScriptRoot '..\resources' if (-not (Get-ChildItem -LiteralPath $resources -Recurse -File -Force)) { throw "No files in $resources." } # The root user may have prepared the directory or the file names, so nothing that looks like a link is used. $prepared = Get-Item -LiteralPath $installDir -Force -ErrorAction SilentlyContinue if ($prepared -and $prepared.Attributes -band [IO.FileAttributes]::ReparsePoint) { throw "$installDir is a link, refusing to install." } New-Item -ItemType Directory -Force -Path $copiesDir | Out-Null # Only SYSTEM and the administrators may read or write the copies and the script. $acl = New-Object System.Security.AccessControl.DirectorySecurity $acl.SetAccessRuleProtection($true, $false) foreach ($sid in 'S-1-5-18', 'S-1-5-32-544') { $identity = New-Object System.Security.Principal.SecurityIdentifier $sid $acl.AddAccessRule((New-Object System.Security.AccessControl.FileSystemAccessRule -ArgumentList $identity, 'FullControl', 'ContainerInherit,ObjectInherit', 'None', 'Allow')) } $acl.SetOwner((New-Object System.Security.Principal.SecurityIdentifier 'S-1-5-32-544')) Set-Acl -LiteralPath $installDir -AclObject $acl # Files the root user prepared are removed, the copies are written fresh and inherit the restricted permissions. foreach ($item in Get-ChildItem -LiteralPath $copiesDir -Recurse -Force) { if ($item.Attributes -band [IO.FileAttributes]::ReparsePoint) { throw "$($item.FullName) is a link, refusing to install." } } Remove-Item -LiteralPath $copiesDir -Recurse -Force New-Item -ItemType Directory -Force -Path $copiesDir | Out-Null Copy-Item -Path (Join-Path $resources '*') -Destination $copiesDir -Recurse -Force # The Administrator logs in with the key of the people who deploy, which is in the resources. The keys that are # already there stay. $adminKeys = 'C:\Users\Administrator\.ssh\authorized_keys' $required = @(Get-Content -LiteralPath (Join-Path $resources 'admin\.ssh\authorized_keys') | Where-Object { $_.Trim() }) $keys = @() if (Test-Path -LiteralPath $adminKeys) { $keys = @(Get-Content -LiteralPath $adminKeys | Where-Object { $_.Trim() }) } if (-not ($keys | Where-Object { ($_ -split '\s+')[1] -eq ($required[0] -split '\s+')[1] })) { New-Item -ItemType Directory -Force -Path (Split-Path -Parent $adminKeys) | Out-Null [IO.File]::WriteAllText($adminKeys, (($keys + $required) -join "`n") + "`n") } [IO.File]::WriteAllText((Join-Path $installDir 'restore.ps1'), $restoreScript) $action = New-ScheduledTaskAction -Execute 'powershell.exe' -Argument "-NoProfile -NonInteractive -ExecutionPolicy Bypass -WindowStyle Hidden -File `"$installDir\restore.ps1`" -HomeDir `"$HomeDir`"" $triggers = @( New-ScheduledTaskTrigger -Once -At (Get-Date) -RepetitionInterval (New-TimeSpan -Minutes 30) -RepetitionDuration (New-TimeSpan -Days 3650) New-ScheduledTaskTrigger -AtStartup ) $settings = New-ScheduledTaskSettingsSet -StartWhenAvailable -MultipleInstances IgnoreNew -ExecutionTimeLimit (New-TimeSpan -Minutes 10) -RestartCount 3 -RestartInterval (New-TimeSpan -Minutes 5) -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries $principal = New-ScheduledTaskPrincipal -UserId 'SYSTEM' -LogonType ServiceAccount -RunLevel Highest Register-ScheduledTask -TaskName $taskName -Action $action -Trigger $triggers -Principal $principal -Settings $settings -Force | Out-Null Start-ScheduledTask -TaskName $taskName Start-Sleep -Seconds 5 Write-Host "Task '$taskName' runs as SYSTEM every 30 minutes and at startup." Get-ScheduledTaskInfo -TaskName $taskName | Format-List LastRunTime, LastTaskResult, NextRunTime Get-Content (Join-Path $installDir 'restore.log') -Tail 8 -ErrorAction SilentlyContinue icacls $installDir