The user could empty the authorized_keys and break the login of the website, change the shell configuration to break the prompt, and write startup files that zsh reads on every login. The shell configuration and the key are now read-only for the user, and the shell reads its configuration from ~/.zsh, so the files in the home directory are ignored. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
219 lines
12 KiB
PowerShell
219 lines
12 KiB
PowerShell
# 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 shell configuration and the key the website logs in with must not change, the four texts may be edited.
|
|
$readOnly = '.zshrc', '.zshenv', 'authorized_keys'
|
|
|
|
# The permissions of the home directory and of the files in it that have to stay: the user may read them and write
|
|
# the ones that may change, 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, $writable) {
|
|
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
|
|
$read = [System.Security.AccessControl.FileSystemRights]::ReadAndExecute
|
|
$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'))
|
|
$userRights = if ($writable) { $some } else { $read }
|
|
$acl.AddAccessRule((New-Object System.Security.AccessControl.FileSystemAccessRule -ArgumentList $user, $userRights, $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 $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 $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 $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 ($readOnly -notcontains $copy.Name)
|
|
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)
|
|
|
|
# The shell configuration used to be in the home directory, the shell reads it from ~/.zsh now.
|
|
if (Test-Path -LiteralPath (Join-Path $HomeDir '.zsh\.zshrc')) {
|
|
Remove-Item -LiteralPath (Join-Path $HomeDir '.zshrc') -Force -ErrorAction SilentlyContinue
|
|
}
|
|
|
|
$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
|