The cleanup job stops the server, puts the files back and starts it again. On a slow disk that took minutes, and on 2026-10-28 the job was killed while the server was down: the task of the server only starts it again when it failed, never when it was stopped, so the site was down until the box was power-cycled. The job now puts the home directory, the four texts and the permissions of the installation back while the server runs, and only stops it for the protected copy, which cannot be replaced while the server holds it open. A new task, "KiK watch the server", starts the server every two minutes when nothing listens on port 22 and the cleanup job is not running. Co-Authored-By: Claude Code <noreply@anthropic.com>
566 lines
34 KiB
PowerShell
566 lines
34 KiB
PowerShell
# Sets up the shell server so the website's root user can install packages with pacman, while the basic user
|
|
# journey - logging in with the key, the prompt, the home directory and the four text files - cannot be broken.
|
|
#
|
|
# The whole MSYS2 installation belongs to the root user: they may write everywhere and run pacman. The login path
|
|
# the journey depends on - the sshd, the login shell zsh and cat - is kept as a protected copy in C:\msys2\opt\kik
|
|
# that only SYSTEM and the administrators may write. The sshd runs as SYSTEM from that copy (the scheduled task
|
|
# "KiK sshd"), so replacing /usr/bin/sshd does not touch the running server. A second scheduled task,
|
|
# "KiK restore shell files", puts the home directory, the four texts and the protected copy back every 30 minutes.
|
|
#
|
|
# opt/kik works because its MSYS2 runtime resolves the same installation root (C:\msys2) as /usr/bin, so the login
|
|
# shell and the user's own tools share one cygwin runtime. A separate second installation cannot: two runtimes in
|
|
# one process tree break each other's file descriptors, and pacman then cannot even lock its database.
|
|
#
|
|
# 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 tasks and the protected copy again (the installation goes back to a normal cygrunsrv sshd) with:
|
|
# powershell -ExecutionPolicy Bypass -File .\install_restore_task.ps1 -Uninstall
|
|
[CmdletBinding()]
|
|
param(
|
|
[string]$HomeDir = 'C:\msys2\home\root',
|
|
[switch]$Uninstall
|
|
)
|
|
|
|
$ErrorActionPreference = 'Stop'
|
|
$msys = 'C:\msys2'
|
|
$kik = "$msys\opt\kik"
|
|
$installDir = 'C:\ProgramData\kik-restore'
|
|
$copiesDir = Join-Path $installDir 'files'
|
|
$binDir = Join-Path $installDir 'bin'
|
|
$sshdBat = Join-Path $installDir 'run-sshd.bat'
|
|
$restoreTask = 'KiK restore shell files'
|
|
$sshdTask = 'KiK sshd'
|
|
$watchTask = 'KiK watch the server'
|
|
$service = 'msys2-sshd'
|
|
|
|
# The programs the journey needs, kept out of the user's reach. sshd-session and sshd-auth are the per-connection
|
|
# helpers OpenSSH 10 re-executes; zsh is the login shell; cat reads the four texts.
|
|
$loginBinaries = "$msys\usr\bin\sshd.exe", "$msys\usr\lib\ssh\sshd-session.exe", "$msys\usr\lib\ssh\sshd-auth.exe",
|
|
"$msys\usr\bin\zsh.exe", "$msys\usr\bin\cat.exe"
|
|
|
|
# The runtime is not among them: a program and the shell that starts it have to use the same runtime, or the
|
|
# program does not run at all. The login path therefore uses the one in /usr/bin, and the user may replace it -
|
|
# pacman checks that it is the one the package says it is, and the cleanup job puts it back if it is not.
|
|
$runtimeFiles = '/usr/bin/msys-2.0.dll'
|
|
|
|
# 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 back into shape and keeps the protected login path in place. Runs as SYSTEM every 30 minutes
|
|
# and at startup; the root user can neither stop it nor change the copies it restores from.
|
|
param([string]$HomeDir = 'C:\msys2\home\root')
|
|
$msys = 'C:\msys2'
|
|
$kik = "$msys\opt\kik"
|
|
$log = Join-Path $PSScriptRoot 'restore.log'
|
|
$copies = Join-Path $PSScriptRoot 'files\home'
|
|
$binDir = Join-Path $PSScriptRoot 'bin'
|
|
|
|
# The account of the shell server's root user, whose id the files in its home directory belong to.
|
|
$passwd = Get-Content "$msys\etc\passwd" -ErrorAction SilentlyContinue | Where-Object { $_ -like 'root:*' }
|
|
$sid = if ($passwd) { ($passwd -split ':')[4].Split(',')[-1] }
|
|
|
|
$system = New-Object System.Security.Principal.SecurityIdentifier('S-1-5-18')
|
|
$admins = New-Object System.Security.Principal.SecurityIdentifier('S-1-5-32-544')
|
|
|
|
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
|
|
}
|
|
}
|
|
|
|
# A path that only SYSTEM and the administrators own. $userRights is what the root user may do: 'None' (the ssh keys
|
|
# and config), 'Read' (the account database) or 'ReadAndExecute' (the login programs it has to run).
|
|
function Set-LockedAcl($path, $forDirectory, $userRights) {
|
|
$acl = Get-Acl -LiteralPath $path
|
|
$acl.SetAccessRuleProtection($true, $false)
|
|
foreach ($rule in @($acl.Access)) { [void]$acl.RemoveAccessRuleSpecific($rule) }
|
|
$acl.SetOwner($admins)
|
|
$inherit = if ($forDirectory) { 'ContainerInherit,ObjectInherit' } else { 'None' }
|
|
$acl.AddAccessRule((New-Object System.Security.AccessControl.FileSystemAccessRule($system, 'FullControl', $inherit, 'None', 'Allow')))
|
|
$acl.AddAccessRule((New-Object System.Security.AccessControl.FileSystemAccessRule($admins, 'FullControl', $inherit, 'None', 'Allow')))
|
|
if ($userRights -ne 'None' -and $sid) {
|
|
$user = New-Object System.Security.Principal.SecurityIdentifier($sid)
|
|
$acl.AddAccessRule((New-Object System.Security.AccessControl.FileSystemAccessRule($user, $userRights, $inherit, 'None', 'Allow')))
|
|
}
|
|
Set-Acl -LiteralPath $path -AclObject $acl
|
|
}
|
|
|
|
# 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-HomeAcl($path, $forDirectory, $writable) {
|
|
if (-not $sid) { return }
|
|
try {
|
|
$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
|
|
$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, 'None', 'Allow'))
|
|
$acl.AddAccessRule((New-Object System.Security.AccessControl.FileSystemAccessRule -ArgumentList $admins, $full, $inherit, 'None', 'Allow'))
|
|
$userRights = if ($writable) { $some } else { $read }
|
|
$acl.AddAccessRule((New-Object System.Security.AccessControl.FileSystemAccessRule -ArgumentList $user, $userRights, $none, 'None', '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-HomeAcl $path $true $true
|
|
Get-ChildItem -LiteralPath "$msys\etc\skel" -Force | Copy-Item -Destination $path -Recurse -Force
|
|
}
|
|
|
|
# The server is stopped and started again when nobody is connected: it picks up the programs that are restored
|
|
# just now, and it comes back by itself if it died. It has to be stopped before the copies are made, because a
|
|
# running server holds its programs open and they cannot be replaced.
|
|
#
|
|
# Stopping the task does not take the server with it, so its processes are killed by hand and the task is waited
|
|
# for: it ignores a new instance while one is running, so a start would be dropped.
|
|
function Stop-Sshd {
|
|
Stop-ScheduledTask -TaskName 'KiK sshd' -ErrorAction SilentlyContinue
|
|
Get-CimInstance Win32_Process -Filter "Name='cmd.exe'" | Where-Object { $_.CommandLine -match 'cmd\.exe\s+/c\s+"?C:\\ProgramData\\kik-restore\\run-sshd\.bat' } |
|
|
ForEach-Object { Stop-Process -Id $_.ProcessId -Force -ErrorAction SilentlyContinue }
|
|
Get-Process -Name sshd -ErrorAction SilentlyContinue | Stop-Process -Force
|
|
# the server leaves the processes of the sessions it has served behind, and they hold the runtime and the
|
|
# protected programs open, so those cannot be replaced while they are there
|
|
Get-Process -Name 'sshd-session' -ErrorAction SilentlyContinue | Stop-Process -Force
|
|
$waited = 0
|
|
while ((Get-ScheduledTask -TaskName 'KiK sshd').State -ne 'Ready' -and $waited -lt 30) { Start-Sleep 1; $waited++ }
|
|
$waited = 0
|
|
while ((Get-NetTCPConnection -LocalPort 22 -State Listen -ErrorAction SilentlyContinue) -and $waited -lt 30) { Start-Sleep 1; $waited++ }
|
|
}
|
|
|
|
# The server is started again and waited for: a deployment or a crash must never leave the site without a shell.
|
|
# The task ignores a new instance while it thinks one is running, so when the port stays closed its processes are
|
|
# killed and it is started again.
|
|
function Start-Sshd {
|
|
for ($attempt = 1; $attempt -le 3; $attempt++) {
|
|
Start-ScheduledTask -TaskName 'KiK sshd'
|
|
$waited = 0
|
|
while (-not (Get-NetTCPConnection -LocalPort 22 -State Listen -ErrorAction SilentlyContinue) -and $waited -lt 30) { Start-Sleep 1; $waited++ }
|
|
if (Get-NetTCPConnection -LocalPort 22 -State Listen -ErrorAction SilentlyContinue) { return $true }
|
|
Write-Log "the server did not come up (attempt $attempt)"
|
|
Stop-Sshd
|
|
}
|
|
return $false
|
|
}
|
|
|
|
# Runs a program and gives up after a while: pacman can wait for the lock of its database, and the server must not
|
|
# stay down because of it. The exit code is returned, or -1 when it did not finish in time.
|
|
function Invoke-Bounded($program, $arguments, $seconds) {
|
|
$process = Start-Process -FilePath $program -ArgumentList $arguments -NoNewWindow -PassThru `
|
|
-RedirectStandardOutput (Join-Path $env:TEMP 'kik-pacman.out') -RedirectStandardError (Join-Path $env:TEMP 'kik-pacman.err')
|
|
# the handle is taken while the process runs, otherwise its exit code is not available afterwards
|
|
$null = $process.Handle
|
|
if (-not $process.WaitForExit($seconds * 1000)) {
|
|
Stop-Process -Id $process.Id -Force -ErrorAction SilentlyContinue
|
|
return -1
|
|
}
|
|
return $process.ExitCode
|
|
}
|
|
|
|
$netError = $null
|
|
$connections = @(Get-NetTCPConnection -LocalPort 22 -ErrorAction SilentlyContinue -ErrorVariable netError)
|
|
# An open session keeps the port, so the server is only restarted when there is none: it comes back by itself if it
|
|
# died, it takes over a server that was left behind, and it picks up the programs that were just restored. A query
|
|
# that fails is not a reason to disturb a session. The processes of the sessions are left behind by the server when a
|
|
# client goes away, so they say nothing about whether anybody is connected.
|
|
$busy = @($connections | Where-Object { $_.State -eq 'Established' }).Count -gt 0
|
|
$restart = -not $netError -and -not $busy
|
|
# The runtime of the installation belongs to the user's programs and is not protected: a program and the shell that
|
|
# starts it have to use the same one, or the program does not run at all. What has to be protected is that it is the
|
|
# one the package says it is, because the server runs as SYSTEM. The check takes a while, so it runs while the
|
|
# server is up - the server must not wait for it.
|
|
$runtimeBroken = $false
|
|
if ($restart) {
|
|
$runtimeBroken = (Invoke-Bounded "$msys\usr\bin\pacman.exe" @('-Qkk', 'msys2-runtime') 120) -ne 0
|
|
}
|
|
|
|
# The home directory and the four texts: the server does not hold them open, so they are put back while it runs.
|
|
# On a slow disk this takes minutes, and the site has a shell the whole time.
|
|
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-HomeAcl $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-HomeAcl $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-HomeAcl $target $false ($readOnly -notcontains $copy.Name)
|
|
# the user may have made the file read-only for themselves, the four texts are theirs to change
|
|
(Get-Item -LiteralPath $target -Force).IsReadOnly = $false
|
|
Write-Log "restored $($copy.Name)"
|
|
} catch {
|
|
Write-Log "failed $($copy.Name) : $_"
|
|
}
|
|
}
|
|
|
|
# The whole installation belongs to the user, so pacman can write it. The home directory keeps its own protected
|
|
# permissions because they are set without inheritance.
|
|
if ($sid) { icacls $msys /grant "*${sid}:(OI)(CI)F" | Out-Null }
|
|
|
|
if ($restart) {
|
|
Stop-Sshd
|
|
Remove-Item "$kik\msys-2.0.dll" -Force -ErrorAction SilentlyContinue
|
|
if ($runtimeBroken) {
|
|
# the package is put back while the server is stopped: the file cannot be replaced while it is mapped.
|
|
# The pattern is quoted, or the runtime expands it to the files of the working directory.
|
|
Invoke-Bounded "$msys\usr\bin\pacman.exe" @('-S', '--noconfirm', '--overwrite', "'*'", 'msys2-runtime') 300 | Out-Null
|
|
Write-Log 'restored the runtime of the installation'
|
|
}
|
|
|
|
try {
|
|
# The protected login path: the copy the sshd and the login shell run from. The user cannot write it, so even
|
|
# after they replace or remove their own copies in /usr/bin the server and the journey keep working. The trusted
|
|
# originals are in bin\; anything missing from opt/kik is put back. This is the only work that needs the server
|
|
# to be stopped, because a running server holds its programs open.
|
|
if (Test-Path -LiteralPath $binDir) {
|
|
if (-not (Test-Path -LiteralPath $kik)) { New-Item -ItemType Directory -Path $kik | Out-Null }
|
|
foreach ($file in Get-ChildItem -LiteralPath $binDir -File -Force | Where-Object { $_.Name -ne 'msys-2.0.dll' }) {
|
|
$target = Join-Path $kik $file.Name
|
|
try {
|
|
if (-not (Test-Path -LiteralPath $target) -or (Get-Item -LiteralPath $target -Force).Length -ne $file.Length) {
|
|
Copy-Item -LiteralPath $file.FullName -Destination $target -Force
|
|
Write-Log "restored opt/kik/$($file.Name)"
|
|
}
|
|
} catch {
|
|
# a running server holds its programs open; they are replaced in one of the next runs
|
|
Write-Log "failed opt/kik/$($file.Name) : $_"
|
|
}
|
|
}
|
|
New-Item -ItemType Directory -Force -Path "$kik\dev\shm", "$kik\dev\mqueue" | Out-Null
|
|
Set-LockedAcl $kik $true 'ReadAndExecute'
|
|
# /dev is scratch: the user may write it so POSIX shared memory works, the rest of opt/kik stays read-only
|
|
if ($sid) { icacls "$kik\dev" /grant "*${sid}:(OI)(CI)M" | Out-Null }
|
|
}
|
|
|
|
# The account database and the ssh keys and config the SYSTEM server reads: the user may read passwd (programs
|
|
# map ids through it) but change none of it, and has no business in /etc/ssh at all.
|
|
$rootShell = '/opt/kik/zsh.exe'
|
|
$pw = "$msys\etc\passwd"
|
|
$lines = Get-Content $pw
|
|
if ($lines | Where-Object { $_ -match '^root:' -and $_ -notmatch ':/opt/kik/zsh\.exe$' }) {
|
|
($lines | ForEach-Object { if ($_ -match '^root:') { $_ -replace ':[^:]*$', ":$rootShell" } else { $_ } }) | Set-Content $pw -Encoding ascii
|
|
Write-Log 'set the root login shell to the protected zsh'
|
|
}
|
|
Set-LockedAcl $pw $false 'Read'
|
|
if (Test-Path "$msys\etc\ssh") { Set-LockedAcl "$msys\etc\ssh" $true 'None' }
|
|
} catch {
|
|
Write-Log "the protected login path is not fully in place: $_"
|
|
}
|
|
|
|
if (Start-Sshd) { Write-Log 'restarted the sshd' } else { Write-Log 'the sshd does not listen on port 22' }
|
|
}
|
|
'@
|
|
|
|
# The server is watched, because the cleanup job may be killed while it has the server stopped: the task of the
|
|
# server only starts it again when it failed, never when it was stopped. The watcher is the only thing that brings
|
|
# the site back when that happens.
|
|
$watchScript = @'
|
|
# Starts the server when it is not running. The cleanup job stops it while it puts the protected files back, and it
|
|
# can be killed while the server is down - the task of the server does not start it again then.
|
|
$log = Join-Path $PSScriptRoot 'watch.log'
|
|
if (Get-NetTCPConnection -LocalPort 22 -State Listen -ErrorAction SilentlyContinue) { return }
|
|
if ((Get-ScheduledTask -TaskName 'KiK restore shell files').State -eq 'Running') {
|
|
# the cleanup job starts the server itself when it is done, but it must not hold it down for ever
|
|
$running = (Get-Date) - (Get-ScheduledTaskInfo -TaskName 'KiK restore shell files').LastRunTime
|
|
if ($running.TotalMinutes -lt 3) { return }
|
|
}
|
|
if ((Get-ScheduledTask -TaskName 'KiK sshd').State -eq 'Running') {
|
|
# the task is running but nothing listens, so it is stuck and has to be started anew
|
|
Stop-ScheduledTask -TaskName 'KiK sshd'
|
|
Start-Sleep 5
|
|
}
|
|
Start-ScheduledTask -TaskName 'KiK sshd'
|
|
"$(Get-Date -Format s) the server was not running and was started" | Out-File -FilePath $log -Append
|
|
'@
|
|
|
|
if (-not ([Security.Principal.WindowsPrincipal] [Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) {
|
|
throw 'Run this in an elevated PowerShell.'
|
|
}
|
|
|
|
$system = New-Object System.Security.Principal.SecurityIdentifier('S-1-5-18')
|
|
$admins = New-Object System.Security.Principal.SecurityIdentifier('S-1-5-32-544')
|
|
|
|
$passwd = Get-Content "$msys\etc\passwd" -ErrorAction SilentlyContinue | Where-Object { $_ -like 'root:*' }
|
|
if (-not $passwd) { throw "No root user in $msys\etc\passwd." }
|
|
$sid = ($passwd -split ':')[4].Split(',')[-1]
|
|
|
|
# A previous install may have locked opt/kik, the account database and the ssh keys. The administrators keep
|
|
# full control over them, so reset the permissions to inherited now and let this run write freely; the restore
|
|
# task locks everything again at the end.
|
|
$prevEA = $ErrorActionPreference; $ErrorActionPreference = 'SilentlyContinue'
|
|
foreach ($p in "$msys\opt\kik", "$msys\etc\ssh", "$msys\etc\passwd") {
|
|
if (Test-Path -LiteralPath $p) { icacls $p /reset /t /q | Out-Null }
|
|
}
|
|
Get-ChildItem "$msys\etc\ssh", "$msys\etc\passwd" -Force | ForEach-Object { try { $_.IsReadOnly = $false } catch {} }
|
|
$ErrorActionPreference = $prevEA
|
|
|
|
function Set-LockedAcl($path, $forDirectory, $userRights) {
|
|
$acl = Get-Acl -LiteralPath $path
|
|
$acl.SetAccessRuleProtection($true, $false)
|
|
foreach ($rule in @($acl.Access)) { [void]$acl.RemoveAccessRuleSpecific($rule) }
|
|
$acl.SetOwner($admins)
|
|
$inherit = if ($forDirectory) { 'ContainerInherit,ObjectInherit' } else { 'None' }
|
|
$acl.AddAccessRule((New-Object System.Security.AccessControl.FileSystemAccessRule($system, 'FullControl', $inherit, 'None', 'Allow')))
|
|
$acl.AddAccessRule((New-Object System.Security.AccessControl.FileSystemAccessRule($admins, 'FullControl', $inherit, 'None', 'Allow')))
|
|
if ($userRights -ne 'None') {
|
|
$user = New-Object System.Security.Principal.SecurityIdentifier($sid)
|
|
$acl.AddAccessRule((New-Object System.Security.AccessControl.FileSystemAccessRule($user, $userRights, $inherit, 'None', 'Allow')))
|
|
}
|
|
Set-Acl -LiteralPath $path -AclObject $acl
|
|
}
|
|
|
|
# The DLLs an MSYS2 program loads, as Windows paths under C:\msys2.
|
|
function Get-Libraries($program) {
|
|
$paths = @()
|
|
$posix = ($program -replace '^C:\\msys2', '') -replace '\\', '/'
|
|
foreach ($line in & "$msys\usr\bin\ldd.exe" $posix 2>$null) {
|
|
foreach ($m in [regex]::Matches($line, '/usr/[^\s]+\.dll')) { $paths += $m.Value }
|
|
}
|
|
$paths | Select-Object -Unique | Where-Object { $_ -notin $runtimeFiles } | ForEach-Object { $msys + ($_ -replace '/', '\') }
|
|
}
|
|
|
|
if ($Uninstall) {
|
|
foreach ($t in $restoreTask, $sshdTask, $watchTask) { Unregister-ScheduledTask -TaskName $t -Confirm:$false -ErrorAction SilentlyContinue }
|
|
if (Test-Path -LiteralPath $installDir) { Remove-Item -LiteralPath $installDir -Recurse -Force }
|
|
if (Test-Path -LiteralPath $kik) {
|
|
icacls $kik /reset /t /q 2>$null | Out-Null
|
|
Remove-Item -LiteralPath $kik -Recurse -Force -ErrorAction SilentlyContinue
|
|
}
|
|
# the installation's own cygrunsrv sshd takes over again
|
|
$pw = "$msys\etc\passwd"
|
|
(Get-Content $pw | ForEach-Object { if ($_ -match '^root:') { $_ -replace ':[^:]*$', ':/usr/bin/zsh' } else { $_ } }) | Set-Content $pw -Encoding ascii
|
|
icacls $pw /reset | Out-Null
|
|
if (Test-Path "$msys\etc\ssh") { icacls "$msys\etc\ssh" /reset /t /q 2>$null | Out-Null }
|
|
Set-Service $service -StartupType Automatic -ErrorAction SilentlyContinue
|
|
Start-Service $service -ErrorAction SilentlyContinue
|
|
Write-Host "Removed the tasks and $installDir; the $service service serves ssh again."
|
|
return
|
|
}
|
|
|
|
$resources = Join-Path $PSScriptRoot '..\resources'
|
|
if (-not (Get-ChildItem -LiteralPath $resources -Recurse -File -Force)) { throw "No files in $resources." }
|
|
|
|
$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, $binDir | Out-Null
|
|
|
|
# Only SYSTEM and the administrators may read or write the copies, the trusted login programs and the scripts.
|
|
$acl = New-Object System.Security.AccessControl.DirectorySecurity
|
|
$acl.SetAccessRuleProtection($true, $false)
|
|
foreach ($owner in $system, $admins) {
|
|
$acl.AddAccessRule((New-Object System.Security.AccessControl.FileSystemAccessRule($owner, 'FullControl', 'ContainerInherit,ObjectInherit', 'None', 'Allow')))
|
|
}
|
|
$acl.SetOwner($admins)
|
|
Set-Acl -LiteralPath $installDir -AclObject $acl
|
|
|
|
foreach ($item in Get-ChildItem -LiteralPath $installDir -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)
|
|
[IO.File]::WriteAllText((Join-Path $installDir 'watch.ps1'), $watchScript)
|
|
|
|
# The trusted login programs: the sshd, its helpers, the login shell and cat, with every DLL they load. The restore
|
|
# task puts these into opt/kik, which the user cannot write.
|
|
Remove-Item "$binDir\*" -Force -ErrorAction SilentlyContinue
|
|
$files = @{}
|
|
foreach ($b in $loginBinaries) {
|
|
if (-not (Test-Path -LiteralPath $b)) { throw "$b does not exist." }
|
|
$files[$b] = $true
|
|
foreach ($lib in Get-Libraries $b) { $files[$lib] = $true }
|
|
}
|
|
foreach ($f in $files.Keys) { Copy-Item -LiteralPath $f -Destination $binDir -Force }
|
|
|
|
# Remote Desktop is the way in for the administrator, and the port is open to the internet. It needs network level
|
|
# authentication, so that a scanner cannot get a session before it has proven that it knows the password.
|
|
$rdpTcp = 'HKLM:\SYSTEM\CurrentControlSet\Control\Terminal Server\WinStations\RDP-Tcp'
|
|
$nla = (Get-ItemProperty -Path $rdpTcp -Name UserAuthentication).UserAuthentication
|
|
Set-ItemProperty -Path $rdpTcp -Name UserAuthentication -Value 1 -Type DWord
|
|
if ($nla -ne 1) {
|
|
# the setting only counts when the service starts again, which drops the sessions that are open
|
|
Restart-Service TermService -Force
|
|
}
|
|
|
|
# Only the administrator may log in over Remote Desktop. The user of the website is in no group that may log in
|
|
# over it, and it must never be an administrator - that is taken away if it somehow got there.
|
|
$rootAccount = Get-LocalUser -Name 'root' -ErrorAction SilentlyContinue
|
|
if ($rootAccount) {
|
|
foreach ($group in 'Administrators', 'Remote Desktop Users') {
|
|
$member = Get-LocalGroupMember $group -ErrorAction SilentlyContinue | Where-Object { $_.SID -eq $rootAccount.SID }
|
|
if ($member) {
|
|
Remove-LocalGroupMember $group $member -Confirm:$false
|
|
Write-Host "The root user was in '$group' and has been removed from it."
|
|
}
|
|
}
|
|
}
|
|
|
|
# The sshd reads its configuration and finds its helpers in opt/kik; nothing here depends on the user's /usr.
|
|
$sshdConfig = "$msys\etc\ssh\sshd_config"
|
|
Set-Content -LiteralPath $sshdConfig -Encoding ascii -Value @(
|
|
'# The website logs in as root, the deployers as Administrator. Both come in over the websocket bridge.',
|
|
'Port 22',
|
|
'AllowUsers root Administrator',
|
|
'# Only keys may log in: the box is in the open internet and the accounts have passwords.',
|
|
'PasswordAuthentication no',
|
|
'KbdInteractiveAuthentication no',
|
|
'# Files are protected by Windows ACLs; under MSYS2''s noacl mounts the POSIX modes sshd checks are fake.',
|
|
'StrictModes no',
|
|
'Subsystem sftp internal-sftp',
|
|
'# The server and its per-connection helpers are the protected copies, not the ones the user can replace.',
|
|
'SshdSessionPath /opt/kik/sshd-session.exe',
|
|
'SshdAuthPath /opt/kik/sshd-auth.exe'
|
|
)
|
|
|
|
# The sshd runs as SYSTEM from the protected copy, with opt/kik first on the path so its own DLLs and the session's
|
|
# programs (zsh, cat) are the protected ones. The log stays in the install directory, out of the user's reach.
|
|
# The log is appended to, not overwritten: it is the only trace of what the server did, and a server that is
|
|
# restarted would otherwise take the evidence of the outage with it.
|
|
Set-Content -LiteralPath $sshdBat -Encoding ascii -Value @(
|
|
'@echo off',
|
|
'set PATH=C:\msys2\opt\kik;C:\msys2\usr\bin;%PATH%',
|
|
'rem Keep the previous log if it grew too big; the current one is appended to.',
|
|
'if exist C:\ProgramData\kik-restore\sshd.log for %%f in (C:\ProgramData\kik-restore\sshd.log) do if %%~zf GEQ 1048576 move /y C:\ProgramData\kik-restore\sshd.log C:\ProgramData\kik-restore\sshd.log.1',
|
|
'C:\msys2\opt\kik\sshd.exe -D -e >> C:\ProgramData\kik-restore\sshd.log 2>&1'
|
|
)
|
|
|
|
# The installation's own cygrunsrv sshd service would fight for port 22; the scheduled task owns the sshd instead.
|
|
Set-Service $service -StartupType Disabled -ErrorAction SilentlyContinue
|
|
Stop-Service $service -Force -ErrorAction SilentlyContinue
|
|
|
|
$atSystem = New-ScheduledTaskPrincipal -UserId 'SYSTEM' -LogonType ServiceAccount -RunLevel Highest
|
|
|
|
$sshdAction = New-ScheduledTaskAction -Execute $sshdBat
|
|
$sshdSettings = New-ScheduledTaskSettingsSet -MultipleInstances IgnoreNew -ExecutionTimeLimit (New-TimeSpan -Seconds 0) -RestartCount 999 -RestartInterval (New-TimeSpan -Minutes 1) -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries
|
|
Register-ScheduledTask -TaskName $sshdTask -Action $sshdAction -Trigger (New-ScheduledTaskTrigger -AtStartup) -Principal $atSystem -Settings $sshdSettings -Force | Out-Null
|
|
|
|
$restoreAction = New-ScheduledTaskAction -Execute 'powershell.exe' -Argument "-NoProfile -NonInteractive -ExecutionPolicy Bypass -WindowStyle Hidden -File `"$installDir\restore.ps1`" -HomeDir `"$HomeDir`""
|
|
$restoreTriggers = @(
|
|
New-ScheduledTaskTrigger -Once -At (Get-Date) -RepetitionInterval (New-TimeSpan -Minutes 30) -RepetitionDuration (New-TimeSpan -Days 3650)
|
|
New-ScheduledTaskTrigger -AtStartup
|
|
)
|
|
$restoreSettings = New-ScheduledTaskSettingsSet -StartWhenAvailable -MultipleInstances IgnoreNew -ExecutionTimeLimit (New-TimeSpan -Minutes 30) -RestartCount 3 -RestartInterval (New-TimeSpan -Minutes 5) -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries
|
|
Register-ScheduledTask -TaskName $restoreTask -Action $restoreAction -Trigger $restoreTriggers -Principal $atSystem -Settings $restoreSettings -Force | Out-Null
|
|
|
|
# The watcher looks after the server every two minutes, so that a cleanup job that is killed while it has the server
|
|
# stopped does not take the site down with it.
|
|
$watchAction = New-ScheduledTaskAction -Execute 'powershell.exe' -Argument "-NoProfile -NonInteractive -ExecutionPolicy Bypass -WindowStyle Hidden -File `"$installDir\watch.ps1`""
|
|
$watchTriggers = @(
|
|
New-ScheduledTaskTrigger -Once -At (Get-Date) -RepetitionInterval (New-TimeSpan -Minutes 2) -RepetitionDuration (New-TimeSpan -Days 3650)
|
|
New-ScheduledTaskTrigger -AtStartup
|
|
)
|
|
$watchSettings = New-ScheduledTaskSettingsSet -StartWhenAvailable -MultipleInstances IgnoreNew -ExecutionTimeLimit (New-TimeSpan -Minutes 1) -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries
|
|
Register-ScheduledTask -TaskName $watchTask -Action $watchAction -Trigger $watchTriggers -Principal $atSystem -Settings $watchSettings -Force | Out-Null
|
|
|
|
# The server is started and waited for: it must listen on port 22, or the site has no shell.
|
|
function Start-Sshd {
|
|
Start-ScheduledTask -TaskName $sshdTask
|
|
$waited = 0
|
|
while (-not (Get-NetTCPConnection -LocalPort 22 -State Listen -ErrorAction SilentlyContinue) -and $waited -lt 30) { Start-Sleep 1; $waited++ }
|
|
return [bool](Get-NetTCPConnection -LocalPort 22 -State Listen -ErrorAction SilentlyContinue)
|
|
}
|
|
|
|
# The restore task builds opt/kik and sets every permission; run it before starting the sshd that depends on it.
|
|
Start-ScheduledTask -TaskName $restoreTask
|
|
Start-Sleep -Seconds 6
|
|
$waited = 0
|
|
while ((Get-ScheduledTask -TaskName $restoreTask).State -eq 'Running' -and $waited -lt 60) { Start-Sleep 2; $waited += 2 }
|
|
|
|
# The server is not restarted here. It cannot be: the deployment runs in a session, and the port is still in use by
|
|
# that session, so a server that starts now cannot bind it. The cleanup job restarts the server when nobody is
|
|
# connected, and the new configuration and the restored programs take effect then. A server that is not running at
|
|
# all is started here, because then there is no session that could hold the port.
|
|
if (-not (Get-NetTCPConnection -LocalPort 22 -State Listen -ErrorAction SilentlyContinue)) {
|
|
if (-not (Start-Sshd)) {
|
|
throw "The sshd does not listen on port 22; the task '$sshdTask' says: $((Get-ScheduledTask -TaskName $sshdTask).State), $((Get-ScheduledTaskInfo -TaskName $sshdTask).LastTaskResult)"
|
|
}
|
|
}
|
|
if (-not (Get-NetTCPConnection -LocalPort 22 -State Listen -ErrorAction SilentlyContinue)) {
|
|
throw "The sshd does not listen on port 22."
|
|
}
|
|
|
|
Write-Host "The sshd runs as SYSTEM from C:\msys2\opt\kik (task '$sshdTask'); '$restoreTask' keeps it and the home directory in shape."
|
|
Get-Content (Join-Path $installDir 'restore.log') -Tail 10 -ErrorAction SilentlyContinue
|
|
Get-CimInstance Win32_Process -Filter "Name='sshd.exe'" | ForEach-Object { $_.CommandLine }
|