kik-website/server/scripts/install_restore_task.ps1
Zebreus b142a1c5d2 Rebuild the shell server from a pristine installation it cannot break
On 6 Nov a visitor deleted the whole MSYS2 installation, the locked
/etc/passwd included, and the site was down for a week: the cleanup job
repaired the runtime with pacman, which was gone too, and never tried to
start the server again because a port-22 query without results counted as
a failed query.

The installer now builds a pristine installation from the official base
archive (hash pinned) and the site's packages in the administrators'
folder. Everything the SYSTEM server trusts comes from there and is kept
in place and locked by the cleanup job: opt/kik, the runtime, passwd,
group, nsswitch.conf, fstab, fstab.d and etc/ssh. The user gets modify
rather than full control, so locked files can no longer be deleted, and
the folders leading to them cannot be renamed. When pacman is gone the
job copies the missing files back from the pristine installation.

Nothing that runs as SYSTEM or Administrator runs a file the user can
write any more: the job ran /usr/bin/pacman.exe as SYSTEM, the installer
ran ldd and pacman from the installation, the Administrator's shell was
a cmdshell.exe in /usr/local/bin, and the Administrator's sessions
searched /usr/bin for programs. The Administrator's shell is PowerShell
now, and deploy.sh speaks it.

server/scripts/rdp.sh opens a headless remote desktop session for when
SSH is down. The shell finds the CLANG64 programs and runs helix through
winpty.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-11-14 02:19:41 +01:00

690 lines
42 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 change and delete everything in it and run pacman.
# Only what the sshd, which runs as SYSTEM, trusts is protected: the sshd itself, the login shell zsh and cat (a copy
# in C:\msys2\opt\kik, the scheduled task "KiK sshd" runs it), the runtime, the account database, the mount table
# and the ssh configuration. A second scheduled task, "KiK restore shell files", keeps those and the home directory
# with its four texts in place every 30 minutes.
#
# All of it comes from a pristine installation, built from the official MSYS2 archive in C:\ProgramData\kik-restore
# where the user cannot reach it. Nothing the user can write is ever run or trusted by the tasks or by this script.
# When the user wipes the installation, the restore task copies it back from the pristine one.
#
# 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'
$pristine = Join-Path $installDir 'msys2'
$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 = "$pristine\usr\bin\sshd.exe", "$pristine\usr\lib\ssh\sshd-session.exe", "$pristine\usr\lib\ssh\sshd-auth.exe",
"$pristine\usr\bin\zsh.exe", "$pristine\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, next to the user's programs, which
# is locked instead.
$runtimeFiles = '/usr/bin/msys-2.0.dll'
# The pristine installation: the official base archive, whose hash is pinned here, and the packages of the site.
$baseArchive = 'msys2-base-x86_64-20260927.sfx.exe'
$baseHash = 'AD336CCCFDA47758B5E15CDA993FBBA421115CB0B126697DAEF1EE4DFE37209F'
$packages = 'openssh zsh vim git tmux rsync wget curl jq python tree zip unzip man-db make diffutils patch procps-ng psmisc winpty mingw-w64-ucrt-x86_64-bottom'
# 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 pristine installation the protected files come from, which the user cannot reach.
$pristine = Join-Path $PSScriptRoot 'msys2'
# The account of the shell server's root user, whose id the files in its home directory belong to.
$passwd = Get-Content "$pristine\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 only stopped when it has to be: when it does not run, when it is older than its configuration, or
# when it holds open what has to be repaired - its programs in opt/kik or the runtime. A working server is left alone,
# because every start can fail, and a failed start has kept the site down for hours.
#
# 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++ }
}
# What the box looks like when the server did not come up. Nothing else records a failed start, and the reboot that
# ends the outage takes the evidence with it.
function Save-Snapshot($attempt) {
$task = Get-ScheduledTask -TaskName 'KiK sshd'
$info = $task | Get-ScheduledTaskInfo
$lines = @("== $(Get-Date -Format s) attempt ${attempt}: the task is $($task.State), last run $($info.LastRunTime), result 0x$('{0:X}' -f $info.LastTaskResult)")
$lines += Get-NetTCPConnection -LocalPort 22 -ErrorAction SilentlyContinue | ForEach-Object { "port 22: $($_.LocalAddress) $($_.State) pid $($_.OwningProcess)" }
$lines += Get-CimInstance Win32_Process | Sort-Object CreationDate | ForEach-Object {
'{0:MM-dd HH:mm:ss} {1} pid {2} parent {3} session {4}: {5}' -f $_.CreationDate, $_.Name, $_.ProcessId, $_.ParentProcessId, $_.SessionId, $_.CommandLine
}
$lines | Out-File -FilePath (Join-Path $PSScriptRoot 'failed-starts.log') -Append
}
# 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)"
Save-Snapshot $attempt
Stop-Sshd
}
return $false
}
# A folder that belongs to the administrators and that the user may use but not delete or rename. Only DELETE is
# denied - the deny of icacls also takes SYNCHRONIZE, and without it the user cannot even change into the folder. The
# permissions are only written when they are wrong, because writing them goes through every file below the folder.
function Set-Undeletable($path) {
$user = New-Object System.Security.Principal.SecurityIdentifier($sid)
$acl = Get-Acl -LiteralPath $path
$denied = @($acl.GetAccessRules($true, $false, [System.Security.Principal.SecurityIdentifier]) |
Where-Object { $_.AccessControlType -eq 'Deny' -and $_.IdentityReference -eq $user })
if ($acl.GetOwner([System.Security.Principal.SecurityIdentifier]) -eq $admins -and $denied.Count -eq 1 -and
$denied[0].FileSystemRights -eq 'Delete' -and $denied[0].InheritanceFlags -eq 'None') { return }
$acl.SetOwner($admins)
[void]$acl.RemoveAccessRuleAll((New-Object System.Security.AccessControl.FileSystemAccessRule($user, 'Delete', 'Deny')))
$acl.AddAccessRule((New-Object System.Security.AccessControl.FileSystemAccessRule($user, 'Delete', 'None', 'None', 'Deny')))
Set-Acl -LiteralPath $path -AclObject $acl
}
# Puts a file or a folder of the installation back to the pristine one where it differs, and locks it: the user may
# do no more with it than $userRights.
function Restore-Protected($relative, $userRights) {
$source = Join-Path $pristine $relative
$target = Join-Path $msys $relative
$folder = (Get-Item -LiteralPath $source -Force).PSIsContainer
if ($folder) { New-Item -ItemType Directory -Force -Path $target | Out-Null }
foreach ($file in @(if ($folder) { Get-ChildItem -LiteralPath $source -Recurse -File -Force } else { Get-Item -LiteralPath $source -Force })) {
$copy = $msys + $file.FullName.Substring($pristine.Length)
if ((Get-FileHash -LiteralPath $copy -ErrorAction SilentlyContinue).Hash -ne (Get-FileHash -LiteralPath $file.FullName).Hash) {
New-Item -ItemType Directory -Force -Path (Split-Path -Parent $copy) | Out-Null
Copy-Item -LiteralPath $file.FullName -Destination $copy -Force
Write-Log "restored $($copy.Substring($msys.Length + 1) -replace '\\', '/')"
}
}
Set-LockedAcl $target $folder $userRights
}
$netError = $null
# All connections are asked for: asking for those of port 22 alone fails when there are none, which is exactly when
# the server is down.
$connections = @(Get-NetTCPConnection -ErrorAction SilentlyContinue -ErrorVariable netError | Where-Object LocalPort -eq 22)
# An open session keeps the port, so the server is only stopped when there is none. 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
$mayStop = -not $netError -and -not $busy
# A user who wipes the installation takes pacman with it and could not install anything again. When pacman is gone,
# everything that is missing is copied back from the pristine installation; what the user changed or added stays.
# Only then: copying back what is missing on every run would bring back the packages the user removed or upgraded,
# and pacman's database would list both versions.
if (-not (Test-Path -LiteralPath "$msys\usr\bin\pacman.exe")) {
robocopy $pristine $msys /E /XC /XN /XO /XJ /XD "$pristine\home" "$pristine\tmp" /R:1 /W:1 /NP /NFL /NDL /NJH /NJS | Out-Null
Write-Log "copied the installation back from the pristine one (robocopy exit $LASTEXITCODE)"
}
# 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: they may change and delete every file in it.
# Modify rather than full control, which would also let them delete files whose own permissions do not allow it - the
# protected ones below. The folders that lead to those belong to the administrators and cannot be renamed either: a
# folder put in the place of one would hand the server files the user wrote. The home directory keeps its own
# protected permissions because they are set without inheritance.
if ($sid) {
icacls $msys /grant:r "*${sid}:(OI)(CI)M" | Out-Null
foreach ($folder in $msys, "$msys\etc", "$msys\usr", "$msys\usr\bin", "$msys\opt") {
New-Item -ItemType Directory -Force -Path $folder | Out-Null
Set-Undeletable $folder
}
}
# The files the server, which runs as SYSTEM, trusts besides its programs: the account database decides which account
# a login becomes, the mount table and the lookup rules which files those are, and the ssh configuration and keys who
# may log in. They are kept the pristine ones and the user may at most read them - a changed one would let the
# website's key in as another account. The server does not hold them open, so this needs no restart.
try {
Restore-Protected 'etc\passwd' 'Read'
Restore-Protected 'etc\group' 'Read'
Restore-Protected 'etc\nsswitch.conf' 'Read'
Restore-Protected 'etc\fstab' 'Read'
Restore-Protected 'etc\fstab.d' 'ReadAndExecute'
Restore-Protected 'etc\ssh' 'None'
} catch {
Write-Log "the protected files are not fully in place: $_"
}
# the server does not start without the directory it drops its privileges into, and the shell wants /tmp, which is
# not copied from the pristine installation with the rest
New-Item -ItemType Directory -Force -Path "$msys\var\empty", "$msys\tmp" | Out-Null
# The runtime is locked as well, so it only differs from the pristine one when a deployment brought a new one.
$runtime = 'usr\bin\msys-2.0.dll'
$runtimeOld = (Get-FileHash -LiteralPath "$msys\$runtime" -ErrorAction SilentlyContinue).Hash -ne (Get-FileHash -LiteralPath "$pristine\$runtime").Hash
# 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 originals are in
# bin\, taken from the pristine installation; anything missing from opt/kik or different from its original is put
# back. Replacing them and the runtime is the only work that needs the server to be stopped, because a running server
# holds them open.
$stale = @(if (Test-Path -LiteralPath $binDir) {
Get-ChildItem -LiteralPath $binDir -File -Force | Where-Object {
$target = Join-Path $kik $_.Name
-not (Test-Path -LiteralPath $target) -or (Get-Item -LiteralPath $target -Force).Length -ne $_.Length
}
})
# A deployment writes the configuration but cannot restart the server, so a server that is older than its
# configuration is restarted here.
$listener = Get-NetTCPConnection -LocalPort 22 -State Listen -ErrorAction SilentlyContinue | Select-Object -First 1
$outdated = $listener -and @(Get-Item -LiteralPath "$msys\etc\ssh\sshd_config", (Join-Path $PSScriptRoot 'run-sshd.bat') |
Where-Object { $_.LastWriteTime -gt (Get-Process -Id $listener.OwningProcess).StartTime }).Count -gt 0
$restart = $mayStop -and (-not $listener -or $outdated -or $runtimeOld -or $stale.Count -gt 0)
if ($restart) {
Stop-Sshd
if ($runtimeOld) { Restore-Protected $runtime 'ReadAndExecute' }
if (-not (Test-Path -LiteralPath $kik)) { New-Item -ItemType Directory -Path $kik | Out-Null }
foreach ($file in $stale) {
try {
Copy-Item -LiteralPath $file.FullName -Destination (Join-Path $kik $file.Name) -Force
Write-Log "restored opt/kik/$($file.Name)"
} catch {
# a process the server left behind may still hold it open; it is replaced in one of the next runs
Write-Log "failed opt/kik/$($file.Name) : $_"
}
}
}
# The permissions do not need the server to be stopped, so they are put back on every run.
try {
Set-LockedAcl "$msys\$runtime" $false 'ReadAndExecute'
if (Test-Path -LiteralPath $binDir) {
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 }
}
} catch {
Write-Log "the protected login path is not fully in place: $_"
}
if ($restart) {
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')
$rootAccount = Get-LocalUser -Name 'root' -ErrorAction SilentlyContinue
if (-not $rootAccount) { throw 'There is no local user root.' }
$sid = $rootAccount.SID.Value
# The DLLs a program of the pristine installation loads, as Windows paths. Its own ldd is asked: what runs here is
# only ever the pristine installation, never the one the user can write.
function Get-Libraries($program) {
$paths = @()
$posix = $program.Substring($pristine.Length) -replace '\\', '/'
foreach ($line in & "$pristine\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 { $pristine + ($_ -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, and the protected files are the user's again
$pw = "$msys\etc\passwd"
(Get-Content $pw | ForEach-Object { if ($_ -match '^root:') { $_ -replace ':[^:]*$', ':/usr/bin/zsh' } else { $_ } }) | Set-Content $pw -Encoding ascii
foreach ($p in 'etc\passwd', 'etc\group', 'etc\nsswitch.conf', 'etc\fstab', 'etc\fstab.d', 'etc\ssh', 'usr\bin\msys-2.0.dll') {
if (Test-Path "$msys\$p") { icacls "$msys\$p" /reset /t /q 2>$null | Out-Null }
}
foreach ($folder in $msys, "$msys\etc", "$msys\usr", "$msys\usr\bin", "$msys\opt") { icacls $folder /remove:d "*$sid" | 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
# The folders this script empties must not be links; the pristine installation is only ever written by this script.
foreach ($item in @(Get-ChildItem -LiteralPath $installDir -Force) + @(Get-ChildItem -LiteralPath $copiesDir, $binDir -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")
}
# The pristine installation: the official base archive, checked against its pinned hash, and the packages of the
# site, built where the user cannot reach it. It is built again only when the base or the packages change.
$built = Join-Path $installDir 'msys2.built'
if ((Get-Content -LiteralPath $built -ErrorAction SilentlyContinue) -ne "$baseHash $packages") {
$archive = Join-Path $installDir $baseArchive
if ((Get-FileHash -LiteralPath $archive -ErrorAction SilentlyContinue).Hash -ne $baseHash) {
[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12
Invoke-WebRequest "https://repo.msys2.org/distrib/x86_64/$baseArchive" -OutFile $archive -UseBasicParsing
if ((Get-FileHash -LiteralPath $archive).Hash -ne $baseHash) { throw "$baseArchive does not have the pinned hash." }
}
$staging = Join-Path $installDir 'msys2-new'
if (Test-Path -LiteralPath $staging) { Remove-Item -LiteralPath $staging -Recurse -Force }
Start-Process -FilePath $archive -ArgumentList '-y', "-o$staging" -Wait -NoNewWindow
# The first login sets the installation up. The first update ends the shell when it replaces the runtime, so it
# runs twice, and whether the packages are there in the end is what decides.
foreach ($command in 'exit', 'pacman -Syuu --noconfirm', 'pacman -Syuu --noconfirm', "pacman -S --needed --noconfirm $packages") {
& "$staging\msys64\usr\bin\bash.exe" -lc $command
}
& "$staging\msys64\usr\bin\bash.exe" -lc "pacman -Q $packages" | Out-Null
if ($LASTEXITCODE) { throw 'The packages did not install into the pristine installation.' }
# the agents the setup started hold the installation open
Get-CimInstance Win32_Process | Where-Object { $_.ExecutablePath -like "$staging\*" -or $_.ExecutablePath -like "$pristine\*" } |
ForEach-Object { Stop-Process -Id $_.ProcessId -Force }
if (Test-Path -LiteralPath $pristine) { Remove-Item -LiteralPath $pristine -Recurse -Force }
Move-Item -LiteralPath "$staging\msys64" -Destination $pristine
Remove-Item -LiteralPath $staging
Set-Content -LiteralPath $built -Value "$baseHash $packages"
}
# 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 }
# The account database: the website's root user logs in to the protected zsh, the deployers as Administrator to
# PowerShell, which nothing in the installation can replace. The ids are the ones MSYS2 derives from the SIDs of the
# local accounts, in the group None that MSYS2 gives them. The group database is empty and the folder of per-user
# mount tables too, so that the user cannot put their own in place.
$accounts = @(
@{ Name = 'root'; Home = '/home/root'; Shell = '/opt/kik/zsh.exe' },
@{ Name = 'Administrator'; Home = '/c/Users/Administrator'; Shell = '/c/Windows/System32/WindowsPowerShell/v1.0/powershell.exe' }
)
$passwd = foreach ($account in $accounts) {
$user = Get-LocalUser -Name $account.Name
$rid = [int]($user.SID.Value -split '-')[-1]
'{0}:*:{1}:197121:U-{2}\{0},{3}:{4}:{5}' -f $account.Name, (0x30000 + $rid), $env:COMPUTERNAME, $user.SID.Value, $account.Home, $account.Shell
}
[IO.File]::WriteAllText("$pristine\etc\passwd", ($passwd -join "`n") + "`n")
[IO.File]::WriteAllText("$pristine\etc\group", '')
New-Item -ItemType Directory -Force -Path "$pristine\etc\fstab.d", "$pristine\etc\ssh" | Out-Null
# The host keys are the server's identity, which the website pins: they are carried over from the installation, where
# the user cannot change them, and are only made anew when there are none at all.
if (Test-Path "$msys\etc\ssh\ssh_host_ed25519_key") { Copy-Item "$msys\etc\ssh\ssh_host_*" "$pristine\etc\ssh" -Force }
if (-not (Test-Path "$pristine\etc\ssh\ssh_host_ed25519_key")) { & "$pristine\usr\bin\bash.exe" -lc 'ssh-keygen -A' }
# The scripts of the tasks come last: a task that runs during the installation must not find a new script without the
# pristine installation and the protected files it works from.
[IO.File]::WriteAllText((Join-Path $installDir 'restore.ps1'), $restoreScript)
[IO.File]::WriteAllText((Join-Path $installDir 'watch.ps1'), $watchScript)
# 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.
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. It is
# written to the pristine installation, and the restore task puts it in place.
$sshdConfig = "$pristine\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 sessions inherit the path of the server, which leads to the runtime in /usr/bin - where the user of the',
'# website can put programs. The deployers'' PowerShell must only find the programs of Windows.',
'Match User Administrator',
' SetEnv PATH=/c/Windows/System32:/c/Windows:/c/Windows/System32/Wbem:/c/Windows/System32/WindowsPowerShell/v1.0'
)
# 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. It is rotated at a hundred megabytes, which
# takes years to reach.
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 104857600 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 event logs of windows are made bigger as well: they are the only record of what the box itself did. The
# security log gets the most, because the scanners of the remote desktop alone fill a hundred megabytes in a week.
foreach ($eventLog in 'System', 'Application') {
wevtutil sl $eventLog /ms:104857600 | Out-Null
}
wevtutil sl Security /ms:1073741824 | Out-Null
# A start of the server that fails leaves no trace in its own log, so windows records what the task of the server
# launched and how each process ended: the history of the scheduled tasks, and the start and end of every process.
wevtutil sl Microsoft-Windows-TaskScheduler/Operational /e:true /ms:104857600 | Out-Null
auditpol /set '/subcategory:{0CCE922B-69AE-11D9-BED3-505054503030},{0CCE922C-69AE-11D9-BED3-505054503030}' /success:enable | Out-Null
# 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.
# It can take long: after a wipe it copies the whole installation back.
Start-ScheduledTask -TaskName $restoreTask
Start-Sleep -Seconds 6
while ((Get-ScheduledTask -TaskName $restoreTask).State -eq 'Running') { Start-Sleep 5 }
# 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 }