# 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 }