diff --git a/server/README.md b/server/README.md index 7bd2437..b8c2b1c 100644 --- a/server/README.md +++ b/server/README.md @@ -21,9 +21,14 @@ We only keep the basic user journey working. which the user cannot change, because `/etc` is read-only for them. They can delete their home directory, though. - `cat README`, `cat README.en`, `cat CODE_OF_CONDUCT` and `cat CODE_OF_CONDUCT.en` always work. The four files are always there and always regular text files. +- The website can log in: the key it uses is in `~/.ssh/authorized_keys`. Modifying the four files is allowed; the next cleanup undoes it. Deleting them is not permanent either. +The user may add keys of their own to `authorized_keys`; they stay. The key of the website is put back, so that the +site keeps working. The user owns their home directory, so they can always delete the file - nothing on Windows can +stop them from that - but the next cleanup brings it back. Until then, the site cannot log in. + ## What we do not keep working Everything else. If the user breaks the shell, the packages, the disk or the connection, we fix that by hand or @@ -46,7 +51,8 @@ different one, without administrator rights, and it never executes any of it. What the cleanup does: 1. Creates `/home/root` if it is gone, and gives the `root` account write access on it. -2. Puts every copy in `files/` back into the home directory: the shell configuration and the four text files. +2. Puts every copy in `files/` back into the home directory: the shell configuration, the keys and the four text + files. Anything that is not a regular file - a directory or a link - is removed and replaced by the copy, so that the files cannot become anything but text. The contents are restored as well: the user may edit the files, the next run @@ -54,13 +60,15 @@ undoes that. ## This directory -- `shell/` - the shell configuration, `~/.zshrc` and `~/.zshenv`, which is where the prompt is set. -- `resources/` - the copies the cleanup restores from: the shell configuration and the four text files. +- `home/` - the files that belong in the home directory of the user: `~/.zshrc`, `~/.zshenv` and the key in + `~/.ssh/authorized_keys` that the website logs in with. This is where they are edited. +- `resources/` - the copies the cleanup restores from: the four text files and the files of `home/`. - `scripts/make_files.py` - builds the resources. - `scripts/deploy.sh` - deploys the cleanup to the server and installs it. - `scripts/install_restore_task.ps1` - installs the scheduled task. +- `scripts/restore_ssh_access.cmd` - puts the SSH access back, for when the home directory was deleted. -The resources are built from the website's pages and the shell configuration: +The resources are built from the website's pages and the files of `home/`: python3 server/scripts/make_files.py @@ -70,8 +78,8 @@ The resources are built from the website's pages and the shell configuration: The script logs in as `Administrator` - whose shell is a Windows one - copies the scripts and the resources to the Administrator's home directory and runs the installer there. It uses the key the website uses, which is authorized -for the Administrator account as well, and checks the host key against the one the website pins. `--uninstall` -removes the task again. +for the Administrator account as well, checks the host key against the one the website pins, and checks that the key +in `home/.ssh/authorized_keys` is the one the website uses. `--uninstall` removes the task again. The installer can also be run by hand, in an elevated PowerShell on the server: @@ -79,3 +87,9 @@ The installer can also be run by hand, in an elevated PowerShell on the server: The task runs as SYSTEM, so the `root` user can neither stop it nor change the copies it restores from. It also runs at startup. Remove it again with `-Uninstall`. + +## Getting the SSH access back + +If the home directory was deleted, the key that lets the website in went with it, and so did the Administrator's. +Over RDP, as Administrator, run `scripts/restore_ssh_access.cmd`. From then on the cleanup puts the key back by +itself. diff --git a/server/home/.ssh/authorized_keys b/server/home/.ssh/authorized_keys new file mode 100644 index 0000000..873f53b --- /dev/null +++ b/server/home/.ssh/authorized_keys @@ -0,0 +1 @@ +ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIJDweEH8bhZAHVaAedhTVDb2j/oMEn3vm97xCwCw9LC9 root@nowhere.wirs.ing cygwin-2222 diff --git a/server/shell/.zshenv b/server/home/.zshenv similarity index 100% rename from server/shell/.zshenv rename to server/home/.zshenv diff --git a/server/shell/.zshrc b/server/home/.zshrc similarity index 100% rename from server/shell/.zshrc rename to server/home/.zshrc diff --git a/server/resources/.ssh/authorized_keys b/server/resources/.ssh/authorized_keys new file mode 100644 index 0000000..873f53b --- /dev/null +++ b/server/resources/.ssh/authorized_keys @@ -0,0 +1 @@ +ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIJDweEH8bhZAHVaAedhTVDb2j/oMEn3vm97xCwCw9LC9 root@nowhere.wirs.ing cygwin-2222 diff --git a/server/scripts/deploy.sh b/server/scripts/deploy.sh index fb457fc..f115bb6 100755 --- a/server/scripts/deploy.sh +++ b/server/scripts/deploy.sh @@ -36,6 +36,14 @@ if [ -z "$KEYFILE" ]; then printf '%s' "$boring" | base64 -d | tr 'A-Za-z' 'N-ZA-Mn-za-m' > "$KEYFILE" fi +# The site logs in with the key in the authorized_keys of the user, so the two have to be the same. +public=$(ssh-keygen -y -f "$KEYFILE" | awk 'NF {print $1" "$2}') +authorized=$(awk 'NF {print $1" "$2; exit}' "$server/resources/.ssh/authorized_keys") +if [ "$public" != "$authorized" ]; then + echo "the key in server/resources/.ssh/authorized_keys is not the one the website uses" >&2 + exit 1 +fi + # The host key is the one the website pins, so that the deployment cannot be intercepted. expected=$(sed -n "s/.*const HOST_KEY = '\([^']*\)'.*/\1/p" "$repo/src/scripts/terminal.ts") known=$(mktemp) diff --git a/server/scripts/install_restore_task.ps1 b/server/scripts/install_restore_task.ps1 index 088bb0b..2e56660 100644 --- a/server/scripts/install_restore_task.ps1 +++ b/server/scripts/install_restore_task.ps1 @@ -21,8 +21,8 @@ $taskName = 'KiK restore shell files' # administrators may read or write. $restoreScript = @' # Puts the shell server's home directory back into shape: the directory itself, the shell configuration that gives -# the prompt, and the four text files. Runs as SYSTEM every 30 minutes; the shell server's root user can neither stop -# it nor change the copies it restores from. +# the prompt, the keys that let the website in, and the four text files. Runs as SYSTEM every 30 minutes; the shell +# server's root user can neither stop it nor change the copies it restores from. param([string]$HomeDir = 'C:\msys2\home\root') $log = Join-Path $PSScriptRoot 'restore.log' $copies = Join-Path $PSScriptRoot 'files' @@ -47,8 +47,21 @@ function Remove-Anything($path) { } } -# The home directory is the one from /etc/passwd, which the user cannot change, but which they can delete. -function New-Home($path) { +# 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() }) + $blob = ($required[0] -split '\s+')[1] + $keys = @() + if (Test-Path -LiteralPath $target) { + $keys = @(Get-Content -LiteralPath $target | Where-Object { $_.Trim() -and ($_ -split '\s+')[1] -ne $blob }) + } + [IO.File]::WriteAllText($target, (($keys + $required) -join "`n") + "`n") +} + +# The home directory is the one from /etc/passwd, which the user cannot change, but which they can delete. The +# directories in it belong to the user, so that they can put their own keys in .ssh. +function New-Dir($path) { New-Item -ItemType Directory -Path $path | Out-Null # The parent directory gives the users read access only, so the owner gets write access on their own home. if ($sid) { @@ -58,17 +71,26 @@ function New-Home($path) { } if (-not (Test-Path -LiteralPath $HomeDir)) { - New-Home $HomeDir + New-Dir $HomeDir Write-Log "created $HomeDir" } elseif ((Get-Item -LiteralPath $HomeDir -Force).Attributes -band [IO.FileAttributes]::ReparsePoint) { Remove-Anything $HomeDir - New-Home $HomeDir + New-Dir $HomeDir Write-Log "replaced the link $HomeDir" } -foreach ($copy in Get-ChildItem -LiteralPath $copies -File -Force) { - $target = Join-Path $HomeDir $copy.Name +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 } + if ($sid) { + icacls $dir /grant "*${sid}:(OI)(CI)F" | Out-Null + icacls $dir /setowner "*$sid" | Out-Null + } if (Test-Path -LiteralPath $target) { $item = Get-Item -LiteralPath $target -Force if ($item.PSIsContainer -or $item.Attributes -band [IO.FileAttributes]::ReparsePoint) { @@ -80,7 +102,11 @@ foreach ($copy in Get-ChildItem -LiteralPath $copies -File -Force) { icacls $target /reset | Out-Null } } - Copy-Item -LiteralPath $copy.FullName -Destination $target -Force + if ($copy.Name -eq 'authorized_keys') { + Restore-Keys $target $copy.FullName + } else { + Copy-Item -LiteralPath $copy.FullName -Destination $target -Force + } if ($sid) { icacls $target /setowner "*$sid" | Out-Null } Write-Log "restored $($copy.Name)" } catch { @@ -113,20 +139,29 @@ New-Item -ItemType Directory -Force -Path $copiesDir | Out-Null $acl = New-Object System.Security.AccessControl.DirectorySecurity $acl.SetAccessRuleProtection($true, $false) foreach ($sid in 'S-1-5-18', 'S-1-5-32-544') { - $acl.AddAccessRule((New-Object System.Security.AccessControl.FileSystemAccessRule -ArgumentList $sid, 'FullControl', 'ContainerInherit,ObjectInherit', 'None', 'Allow')) + $identity = New-Object System.Security.Principal.SecurityIdentifier $sid + $acl.AddAccessRule((New-Object System.Security.AccessControl.FileSystemAccessRule -ArgumentList $identity, 'FullControl', 'ContainerInherit,ObjectInherit', 'None', 'Allow')) } $acl.SetOwner((New-Object System.Security.Principal.SecurityIdentifier 'S-1-5-32-544')) Set-Acl -LiteralPath $installDir -AclObject $acl # Files the root user prepared are removed, the copies are written fresh and inherit the restricted permissions. -foreach ($copy in Get-ChildItem -LiteralPath $resources -File -Force) { - $path = Join-Path $copiesDir $copy.Name - if (Test-Path -LiteralPath $path) { - $item = Get-Item -LiteralPath $path -Force - if ($item.Attributes -band [IO.FileAttributes]::ReparsePoint) { throw "$path is a link, refusing to install." } - Remove-Item -LiteralPath $path -Recurse -Force - } - Copy-Item -LiteralPath $copy.FullName -Destination $path -Force +foreach ($item in Get-ChildItem -LiteralPath $copiesDir -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 same key, so that the deployment keeps working. The keys that are already +# there stay. +$adminKeys = 'C:\Users\Administrator\.ssh\authorized_keys' +$required = @(Get-Content -LiteralPath (Join-Path $resources '.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) diff --git a/server/scripts/make_files.py b/server/scripts/make_files.py index fe2100a..e9bd1cd 100644 --- a/server/scripts/make_files.py +++ b/server/scripts/make_files.py @@ -1,6 +1,6 @@ # Builds the files for the home directory of the shell server's root user in ../resources: README, README.en, # CODE_OF_CONDUCT and CODE_OF_CONDUCT.en as the website's pages in terminal text with escape sequences, and the -# shell configuration from ../shell. +# files of ../home, which are copied as they are. # Usage: python3 server/scripts/make_files.py import pathlib import shutil @@ -336,6 +336,12 @@ out.mkdir(exist_ok=True) for name, text in [('README', readme(DE)), ('README.en', readme(EN)), ('CODE_OF_CONDUCT', code_of_conduct(COC_DE)), ('CODE_OF_CONDUCT.en', code_of_conduct(COC_EN))]: (out / name).write_text(text, encoding='utf-8') -# The shell configuration is not generated, it is copied from ../shell, where it is edited. -for name in '.zshrc', '.zshenv': - shutil.copyfile(pathlib.Path(__file__).parent.parent / 'shell' / name, out / name) +# The files for the home directory are not generated, they are copied from ../home, where they are edited. +home = pathlib.Path(__file__).parent.parent / 'home' +for path in home.rglob('*'): + target = out / path.relative_to(home) + if path.is_dir(): + target.mkdir(parents=True, exist_ok=True) + else: + target.parent.mkdir(parents=True, exist_ok=True) + shutil.copyfile(path, target) diff --git a/server/scripts/restore_ssh_access.cmd b/server/scripts/restore_ssh_access.cmd new file mode 100644 index 0000000..259c4c1 --- /dev/null +++ b/server/scripts/restore_ssh_access.cmd @@ -0,0 +1,13 @@ +@echo off +REM Puts the SSH access back that a deleted home directory took with it: the key the website logs in with, in the +REM home of the root user and in the home of the Administrator. Run it as Administrator on the box, for example +REM over RDP. The cleanup job puts the key back by itself from then on. +set KEY=ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIJDweEH8bhZAHVaAedhTVDb2j/oMEn3vm97xCwCw9LC9 root@nowhere.wirs.ing cygwin-2222 + +if not exist C:\msys2\home\root\.ssh mkdir C:\msys2\home\root\.ssh +> C:\msys2\home\root\.ssh\authorized_keys echo %KEY% + +if not exist C:\Users\Administrator\.ssh mkdir C:\Users\Administrator\.ssh +>> C:\Users\Administrator\.ssh\authorized_keys echo %KEY% + +echo The SSH access is back.