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:
parent
54607982cf
commit
23896bced5
4 changed files with 165 additions and 35 deletions
|
|
@ -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
3
server/resources/.zshenv
Normal 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
36
server/resources/.zshrc
Normal 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'
|
||||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue