From 23896bced5a58fdc4e687756d3c17d620e298df1 Mon Sep 17 00:00:00 2001 From: Zebreus Date: Sun, 25 Oct 2026 13:02:25 +0100 Subject: [PATCH] Keep the shell server's basic user journey working The cleanup job now restores the whole home directory instead of only the four text files: the directory itself, the shell configuration that gives the prompt, and the files. Anything that is not a regular file is replaced, so the files stay text files. The README in server/ describes the security model: the box is a disposable VPS that may be compromised, the root user may do whatever they want, and only the prompt, the home directory and the four files are kept working. Co-Authored-By: Claude Code --- server/README.md | 63 +++++++++++++--- server/resources/.zshenv | 3 + server/resources/.zshrc | 36 +++++++++ server/scripts/install_restore_task.ps1 | 98 ++++++++++++++++++------- 4 files changed, 165 insertions(+), 35 deletions(-) create mode 100644 server/resources/.zshenv create mode 100644 server/resources/.zshrc diff --git a/server/README.md b/server/README.md index dc3cc87..4a5ac15 100644 --- a/server/README.md +++ b/server/README.md @@ -1,23 +1,68 @@ # The shell server -The website's terminal connects to a shell server, whose root user logs in at `wss://web.nowhere.wirs.ing`. +The website's terminal connects to a shell server, whose `root` user logs in at `wss://web.nowhere.wirs.ing` and +gets a shell on the box. -## Layout +## The security model -- `resources/` — the files in the root user's home directory: `README`, `README.en`, `CODE_OF_CONDUCT` and - `CODE_OF_CONDUCT.en`. -- `scripts/make_files.py` — builds the resources, the website's pages as terminal text. -- `scripts/install_restore_task.ps1` — installs the scheduled task that puts the resources back every 30 minutes. +The box is an isolated, disposable VPS at a random hoster. We assume that it can be compromised and that this is +acceptable: the worst case is that the gimmick on the website stops working. Nothing else is on the box. The +website and the websocket bridge that carries the connection run on other servers, so a compromised shell server +cannot get at them. -## Rebuilding the resources +The `root` user on the box is a normal Windows account without administrator rights. It may do a lot of things +there, including things we did not plan for. We do not try to contain it and we do not try to undo what it does. +We only keep the basic user journey working. + +## What we keep working + +- The prompt: `root@khaos-im-kiez:~#`, which `~/.zshrc` sets. +- The home directory the user ends up in: `/home/root`. It is the one from the `root` entry in `/etc/passwd`, + 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. + +Modifying the four files is allowed; the next cleanup undoes it. Deleting them is not permanent either. + +## 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 +throw the box away. The cleanup does not try. + +Two things that the user cannot break and therefore do not need to be restored: the entry in `/etc/passwd` with +the home directory and the shell, and the host keys in `/etc/ssh`, which the website pins. The sshd runs as SYSTEM, +so the user cannot restart it with a different key either. + +## The cleanup + +The scheduled task `KiK restore shell files` runs every 30 minutes and at every startup, as SYSTEM. The user cannot +stop it: the task, its script and the copies it restores from are readable and writable only by SYSTEM and the +administrators. + +What it 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. + +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 +undoes that. + +## This directory + +- `resources/` - the copies the cleanup restores from: the four text files and the shell configuration. +- `scripts/make_files.py` - builds the four text files from the website's pages. +- `scripts/install_restore_task.ps1` - installs the scheduled task. + +The four text files are generated, the shell configuration is copied from the server by hand: python3 server/scripts/make_files.py -## Installing the restore task on the server +## Installing the cleanup on the server Copy `server/scripts` and `server/resources` to the server and run this in an elevated PowerShell: powershell -ExecutionPolicy Bypass -File .\scripts\install_restore_task.ps1 -The task runs as SYSTEM, so the root user can neither stop it nor change the copies it restores from. It also +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`. diff --git a/server/resources/.zshenv b/server/resources/.zshenv new file mode 100644 index 0000000..f990c34 --- /dev/null +++ b/server/resources/.zshenv @@ -0,0 +1,3 @@ +# Preset what MSYS2's /etc/profile would otherwise start a helper process for (~90 ms each here). +# TZ has to match the Windows timezone (W. Europe Standard Time). +export LANG=en_US.UTF-8 TZ=Europe/Berlin diff --git a/server/resources/.zshrc b/server/resources/.zshrc new file mode 100644 index 0000000..310de44 --- /dev/null +++ b/server/resources/.zshrc @@ -0,0 +1,36 @@ +# History shared between sessions. +HISTFILE=~/.zsh_history HISTSIZE=10000 SAVEHIST=10000 +setopt share_history hist_ignore_dups hist_ignore_space interactive_comments auto_cd + +# Completion: menu, case-insensitive, colored. -C skips compaudit, which is slow on +# Windows and meaningless under MSYS2's noacl mounts (rm ~/.zcompdump after adding completions). +autoload -Uz compinit && compinit -C +zstyle ':completion:*' menu select +zstyle ':completion:*' matcher-list 'm:{a-z}={A-Z}' +zstyle ':completion:*' list-colors ${(s.:.)LS_COLORS} + +# Emacs keys; Up/Down search history for what is already typed. +bindkey -e +autoload -Uz up-line-or-beginning-search down-line-or-beginning-search +zle -N up-line-or-beginning-search && zle -N down-line-or-beginning-search +bindkey '^[[A' up-line-or-beginning-search '^[OA' up-line-or-beginning-search +bindkey '^[[B' down-line-or-beginning-search '^[OB' down-line-or-beginning-search + +# Prompt: user@host:directory, git branch, exit status of the last command if it failed. +# A literal '#' as on Linux: zsh's %# would show '%', since this root is not uid 0. +# The branch is read from .git/HEAD directly: starting git on every prompt is slow on Windows. +precmd() { + local d=$PWD head= + until [[ -e $d/.git || $d == / ]]; do d=${d:h}; done + [[ -f $d/.git/HEAD ]] && read -r head < $d/.git/HEAD + [[ $head == 'ref: '* ]] && git_branch=${head#ref: refs/heads/} || git_branch=${head:0:7} + print -Pn '\e]0;%n@%m: %~\a' +} +setopt prompt_subst +PROMPT='%F{green}%n@%m%f:%F{blue}%~%f${git_branch:+ %5F($git_branch)%f}%(?.. %F{red}[%?]%f)# ' + +alias ls='ls --color=auto' ll='ls -lah' grep='grep --color=auto' + +# Process monitor: htop does not exist for MSYS2 (its /proc only shows MSYS2 processes). +# bottom is a native Windows program; winpty lets it draw in the pty, as Server 2016 has no ConPTY. +alias btm='winpty /ucrt64/bin/btm' diff --git a/server/scripts/install_restore_task.ps1 b/server/scripts/install_restore_task.ps1 index 9c4469d..088bb0b 100644 --- a/server/scripts/install_restore_task.ps1 +++ b/server/scripts/install_restore_task.ps1 @@ -1,6 +1,6 @@ -# Installs the scheduled task that puts the shell server's README and Code of Conduct files back every 30 -# minutes. The task runs as SYSTEM, so the shell server's root user can neither stop it nor change the copies it -# restores from. +# Installs the scheduled task that puts the shell server's home directory back into shape every 30 minutes: the +# prompt, the home directory and the four text files. The task runs as SYSTEM, so the shell server's root user can +# neither stop it nor change the copies it restores from. # # 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 @@ -8,36 +8,83 @@ # powershell -ExecutionPolicy Bypass -File .\install_restore_task.ps1 -Uninstall [CmdletBinding()] param( - [string]$Target = 'C:\msys2\home\root', + [string]$HomeDir = 'C:\msys2\home\root', [switch]$Uninstall ) $ErrorActionPreference = 'Stop' $installDir = 'C:\ProgramData\kik-restore' +$copiesDir = Join-Path $installDir 'files' $taskName = 'KiK restore shell files' -$files = 'README', 'README.en', 'CODE_OF_CONDUCT', 'CODE_OF_CONDUCT.en' # 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's README and Code of Conduct files back from the copies next to this script. -# Runs as SYSTEM every 30 minutes; the shell server's root user can neither stop it nor change the copies. -param([string]$Target = 'C:\msys2\home\root') +# 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. +param([string]$HomeDir = 'C:\msys2\home\root') $log = Join-Path $PSScriptRoot 'restore.log' +$copies = Join-Path $PSScriptRoot 'files' -foreach ($name in @('README', 'README.en', 'CODE_OF_CONDUCT', 'CODE_OF_CONDUCT.en')) { - $path = Join-Path $Target $name +# The account of the shell server's root user, whose id the files in its home directory belong to. +$passwd = Get-Content 'C:\msys2\etc\passwd' -ErrorAction SilentlyContinue | Where-Object { $_ -like 'root:*' } +$sid = if ($passwd) { ($passwd -split ':')[4].Split(',')[-1] } + +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 + } +} + +# The home directory is the one from /etc/passwd, which the user cannot change, but which they can delete. +function New-Home($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) { + icacls $path /grant "*${sid}:(OI)(CI)F" | Out-Null + icacls $path /setowner "*$sid" | Out-Null + } +} + +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" +} + +foreach ($copy in Get-ChildItem -LiteralPath $copies -File -Force) { + $target = Join-Path $HomeDir $copy.Name try { - if (Test-Path -LiteralPath $path) { - # the root user may have replaced the file or locked everyone else out - takeown /f $path /a | Out-Null - icacls $path /reset | Out-Null - if ((Get-Item -LiteralPath $path -Force).PSIsContainer) { Remove-Item -LiteralPath $path -Recurse -Force } + 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 + icacls $target /reset | Out-Null + } } - Copy-Item -LiteralPath (Join-Path $PSScriptRoot $name) -Destination $path -Force - "$(Get-Date -Format s) restored $name" | Out-File -FilePath $log -Append + Copy-Item -LiteralPath $copy.FullName -Destination $target -Force + if ($sid) { icacls $target /setowner "*$sid" | Out-Null } + Write-Log "restored $($copy.Name)" } catch { - "$(Get-Date -Format s) failed $name : $_" | Out-File -FilePath $log -Append + Write-Log "failed $($copy.Name) : $_" } } '@ @@ -54,14 +101,13 @@ if ($Uninstall) { } $resources = Join-Path $PSScriptRoot '..\resources' -$missing = $files | Where-Object { -not (Test-Path -LiteralPath (Join-Path $resources $_)) } -if ($missing) { throw "Missing files in $resources : $($missing -join ', ')" } +if (-not (Get-ChildItem -LiteralPath $resources -File -Force)) { throw "No files in $resources." } # The root user may have prepared the directory or the file names, so nothing that looks like a link is used. $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 $installDir | Out-Null +New-Item -ItemType Directory -Force -Path $copiesDir | Out-Null # Only SYSTEM and the administrators may read or write the copies and the script. $acl = New-Object System.Security.AccessControl.DirectorySecurity @@ -73,18 +119,18 @@ $acl.SetOwner((New-Object System.Security.Principal.SecurityIdentifier 'S-1-5-32 Set-Acl -LiteralPath $installDir -AclObject $acl # Files the root user prepared are removed, the copies are written fresh and inherit the restricted permissions. -foreach ($name in $files + 'restore.ps1') { - $path = Join-Path $installDir $name +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 } -Copy-Item -Path (Join-Path $resources '*') -Destination $installDir -Force [IO.File]::WriteAllText((Join-Path $installDir 'restore.ps1'), $restoreScript) -$action = New-ScheduledTaskAction -Execute 'powershell.exe' -Argument "-NoProfile -NonInteractive -ExecutionPolicy Bypass -WindowStyle Hidden -File `"$installDir\restore.ps1`" -Target `"$Target`"" +$action = New-ScheduledTaskAction -Execute 'powershell.exe' -Argument "-NoProfile -NonInteractive -ExecutionPolicy Bypass -WindowStyle Hidden -File `"$installDir\restore.ps1`" -HomeDir `"$HomeDir`"" $triggers = @( New-ScheduledTaskTrigger -Once -At (Get-Date) -RepetitionInterval (New-TimeSpan -Minutes 30) -RepetitionDuration (New-TimeSpan -Days 3650) New-ScheduledTaskTrigger -AtStartup @@ -97,5 +143,5 @@ Start-ScheduledTask -TaskName $taskName Start-Sleep -Seconds 5 Write-Host "Task '$taskName' runs as SYSTEM every 30 minutes and at startup." Get-ScheduledTaskInfo -TaskName $taskName | Format-List LastRunTime, LastTaskResult, NextRunTime -Get-Content (Join-Path $installDir 'restore.log') -Tail 4 -ErrorAction SilentlyContinue +Get-Content (Join-Path $installDir 'restore.log') -Tail 8 -ErrorAction SilentlyContinue icacls $installDir