Restore the SSH access in the cleanup
The key that the website logs in with lives in the home directory of the user, so a deleted home directory took it away and the site could not connect any more. The key is now one of the copies the cleanup restores, and it is merged into the authorized_keys of the user, so that keys they added stay. The installer writes the same key for the Administrator, and the deploy script checks that it is the key the website uses. Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
parent
e48e0644ba
commit
22106dcd7d
9 changed files with 106 additions and 28 deletions
|
|
@ -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.
|
||||
|
|
|
|||
1
server/home/.ssh/authorized_keys
Normal file
1
server/home/.ssh/authorized_keys
Normal file
|
|
@ -0,0 +1 @@
|
|||
ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIJDweEH8bhZAHVaAedhTVDb2j/oMEn3vm97xCwCw9LC9 root@nowhere.wirs.ing cygwin-2222
|
||||
3
server/home/.zshenv
Normal file
3
server/home/.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/home/.zshrc
Normal file
36
server/home/.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
server/resources/.ssh/authorized_keys
Normal file
1
server/resources/.ssh/authorized_keys
Normal file
|
|
@ -0,0 +1 @@
|
|||
ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIJDweEH8bhZAHVaAedhTVDb2j/oMEn3vm97xCwCw9LC9 root@nowhere.wirs.ing cygwin-2222
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
13
server/scripts/restore_ssh_access.cmd
Normal file
13
server/scripts/restore_ssh_access.cmd
Normal file
|
|
@ -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.
|
||||
Loading…
Reference in a new issue