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