kik-website/server/scripts/install_restore_task.ps1
Zebreus 22106dcd7d Restore the SSH access in the cleanup
The key that the website logs in with lives in the home directory of the
user, so a deleted home directory took it away and the site could not
connect any more. The key is now one of the copies the cleanup restores,
and it is merged into the authorized_keys of the user, so that keys they
added stay.

The installer writes the same key for the Administrator, and the deploy
script checks that it is the key the website uses.

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-25 13:35:05 +01:00

182 lines
9.3 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'
# 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() })
$blob = ($required[0] -split '\s+')[1]
$keys = @()
if (Test-Path -LiteralPath $target) {
$keys = @(Get-Content -LiteralPath $target | Where-Object { $_.Trim() -and ($_ -split '\s+')[1] -ne $blob })
}
[IO.File]::WriteAllText($target, (($keys + $required) -join "`n") + "`n")
}
# The home directory is the one from /etc/passwd, which the user cannot change, but which they can delete. The
# directories in it belong to the user, so that they can put their own keys in .ssh.
function New-Dir($path) {
New-Item -ItemType Directory -Path $path | Out-Null
# The parent directory gives the users read access only, so the owner gets write access on their own home.
if ($sid) {
icacls $path /grant "*${sid}:(OI)(CI)F" | Out-Null
icacls $path /setowner "*$sid" | Out-Null
}
}
if (-not (Test-Path -LiteralPath $HomeDir)) {
New-Dir $HomeDir
Write-Log "created $HomeDir"
} elseif ((Get-Item -LiteralPath $HomeDir -Force).Attributes -band [IO.FileAttributes]::ReparsePoint) {
Remove-Anything $HomeDir
New-Dir $HomeDir
Write-Log "replaced the link $HomeDir"
}
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 }
if ($sid) {
icacls $dir /grant "*${sid}:(OI)(CI)F" | Out-Null
icacls $dir /setowner "*$sid" | Out-Null
}
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
icacls $target /reset | Out-Null
}
}
if ($copy.Name -eq 'authorized_keys') {
Restore-Keys $target $copy.FullName
} else {
Copy-Item -LiteralPath $copy.FullName -Destination $target -Force
}
if ($sid) { icacls $target /setowner "*$sid" | Out-Null }
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 -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 same key, so that the deployment keeps working. The keys that are already
# there stay.
$adminKeys = 'C:\Users\Administrator\.ssh\authorized_keys'
$required = @(Get-Content -LiteralPath (Join-Path $resources '.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