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 <noreply@anthropic.com>
This commit is contained in:
Zebreus 2026-10-25 13:02:25 +01:00
commit 23896bced5
4 changed files with 165 additions and 35 deletions

View file

@ -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`.

3
server/resources/.zshenv Normal file
View file

@ -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

36
server/resources/.zshrc Normal file
View file

@ -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'

View file

@ -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