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