MSYS2 only copies /etc/skel into a home directory that does not exist yet when the user logs in. The cleanup always creates the home first, so the default files never arrived. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
182 lines
9.5 KiB
PowerShell
182 lines
9.5 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 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
|
|
# 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
|
|
}
|
|
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"
|
|
}
|
|
|
|
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 -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
|