diff --git a/SELF_HOST.md b/SELF_HOST.md index fd34f0ceb..3ca4713d5 100644 --- a/SELF_HOST.md +++ b/SELF_HOST.md @@ -458,9 +458,13 @@ Prove it once, while the user is watching: return to the desired release. `manage uninstall` removes the service definition and installed code and keeps -`config` and `state`, reporting where they are. `manage purge` is the separate, +`config` and `state`, reporting where they are. It also keeps `manage` itself, +which is what makes the second step possible. `manage purge` is the separate, irreversible operation that deletes them; it requires typing a confirmation -phrase and is never part of a reinstall. +phrase and is never part of a reinstall. Run them in that order — uninstall, +then purge — and purge finishes by printing the single command that clears +whatever is left: the install root, plus the log directory on Linux and macOS, +where it sits outside that root. ## Checkpoint 6: limits and backup diff --git a/deploy/local/install-linux.sh b/deploy/local/install-linux.sh index b40eda7b0..69dfee3d9 100755 --- a/deploy/local/install-linux.sh +++ b/deploy/local/install-linux.sh @@ -702,11 +702,14 @@ UNIT="$LABEL.service" ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" ENV_FILE="$ROOT/config/server.env" STATE_DIR="$ROOT/state" -LOG_DIR="${XDG_STATE_HOME:-$HOME/.local/state}/dormouse-server/logs" +# LOG_ROOT is the dormouse-owned directory the logs sit in — outside ROOT on a +# real install, and what "purge" names so it does not leave an empty one behind. +LOG_ROOT="${XDG_STATE_HOME:-$HOME/.local/state}/dormouse-server" +LOG_DIR="$LOG_ROOT/logs" UNIT_FILE="${XDG_CONFIG_HOME:-$HOME/.config}/systemd/user/$UNIT" # A test install (DORMOUSE_INSTALL_ROOT) keeps its logs and unit inside its own # root, so `manage` must follow them there rather than at the real HOME paths. -[ -d "$ROOT/logs" ] && LOG_DIR="$ROOT/logs" +[ -d "$ROOT/logs" ] && { LOG_DIR="$ROOT/logs"; LOG_ROOT="$ROOT"; } [ -f "$ROOT/systemd/$UNIT" ] && UNIT_FILE="$ROOT/systemd/$UNIT" if [ -t 1 ]; then @@ -1167,7 +1170,8 @@ cmd_uninstall() { printf 'It PRESERVES your configuration and state:\n' printf ' config : %s\n' "$ROOT/config" printf ' state : %s\n' "$STATE_DIR" - printf '\nUse "manage purge" separately to delete those irreversibly.\n\n' + printf '\nThis script is left in place so "purge" can still delete them\n' + printf 'irreversibly afterwards:\n\n "%s" purge\n\n' "$ROOT/bin/manage" if [ ! -t 0 ]; then printf 'refusing to uninstall with no terminal to confirm at\n' >&2 return 1 @@ -1189,8 +1193,12 @@ cmd_uninstall() { else printf 'left the Serve config alone (it does not point at 127.0.0.1:%s)\n' "$PORT" fi - rm -rf "$ROOT/releases" "$ROOT/current" "$ROOT/previous" "$ROOT/bin" "$ROOT/run" + # bin/run-server, not bin: this script lives there too, and "purge" — the + # command the message above points at — is unreachable once it is deleted. + rm -rf "$ROOT/releases" "$ROOT/current" "$ROOT/previous" "$ROOT/run" + rm -f "$ROOT/bin/run-server" printf '\nuninstalled. config and state remain at:\n %s\n %s\n\n' "$ROOT/config" "$STATE_DIR" + printf 'delete them irreversibly with:\n\n "%s" purge\n\n' "$ROOT/bin/manage" printf 'lingering, if you enabled it, is left as it is: loginctl disable-linger %s\n\n' "$USER" } @@ -1204,6 +1212,15 @@ cmd_purge() { if [ "$reply" != "DELETE DORMOUSE STATE" ]; then printf 'aborted\n'; return 1; fi rm -rf "$STATE_DIR" "$ROOT/config" printf 'purged.\n' + # bin/run-server is what "uninstall" removes, so its absence means the service + # and the code are already gone and this script is the last thing standing. It + # cannot delete itself out from under the shell running it, so say how. The + # logs live outside ROOT on a real install, so LOG_ROOT has to be named too or + # the printed command leaves them behind. + if [ ! -e "$ROOT/bin/run-server" ]; then + printf '\nthe service and code were already uninstalled; what remains is\n' + printf 'this script and the logs:\n\n rm -rf "%s" "%s"\n\n' "$ROOT" "$LOG_ROOT" + fi } case "${1:-status}" in @@ -1227,7 +1244,7 @@ usage: manage show-password warn, then display the setup password locally serve re-apply the Tailscale Serve mapping for this server rollback switch to the retained previous release, preserving state - uninstall remove the unit + code (keeps config and state) + uninstall remove the unit + code (keeps config, state, and this script) purge irreversibly delete config and state USAGE exit 64 diff --git a/deploy/local/install-macos.sh b/deploy/local/install-macos.sh index 5d6f9aa4e..68edf7d84 100755 --- a/deploy/local/install-macos.sh +++ b/deploy/local/install-macos.sh @@ -962,7 +962,8 @@ cmd_uninstall() { printf 'It PRESERVES your configuration and state:\n' printf ' config : %s\n' "$ROOT/config" printf ' state : %s\n' "$STATE_DIR" - printf '\nUse "manage purge" separately to delete those irreversibly.\n\n' + printf '\nThis script is left in place so "purge" can still delete them\n' + printf 'irreversibly afterwards:\n\n "%s" purge\n\n' "$ROOT/bin/manage" if [ ! -t 0 ]; then printf 'refusing to uninstall with no terminal to confirm at\n' >&2 return 1 @@ -983,8 +984,12 @@ cmd_uninstall() { else printf 'left the Serve config alone (it does not point at 127.0.0.1:%s)\n' "$PORT" fi - rm -rf "$ROOT/releases" "$ROOT/current" "$ROOT/previous" "$ROOT/bin" "$ROOT/run" + # bin/run-server, not bin: this script lives there too, and "purge" — the + # command the message above points at — is unreachable once it is deleted. + rm -rf "$ROOT/releases" "$ROOT/current" "$ROOT/previous" "$ROOT/run" + rm -f "$ROOT/bin/run-server" printf '\nuninstalled. config and state remain at:\n %s\n %s\n\n' "$ROOT/config" "$STATE_DIR" + printf 'delete them irreversibly with:\n\n "%s" purge\n\n' "$ROOT/bin/manage" } cmd_purge() { @@ -997,6 +1002,16 @@ cmd_purge() { if [ "$reply" != "DELETE DORMOUSE STATE" ]; then printf 'aborted\n'; return 1; fi rm -rf "$STATE_DIR" "$ROOT/config" printf 'purged.\n' + # bin/run-server is what "uninstall" removes, so its absence means the + # LaunchAgent and the code are already gone and this script is the last thing + # standing. It cannot delete itself out from under the shell running it. The + # logs live outside ROOT on a real install, so LOG_DIR has to be named too or + # the printed command leaves them behind. (~/Library/Logs/Dormouse Server is + # dormouse-owned, so deleting it leaves no empty directory behind.) + if [ ! -e "$ROOT/bin/run-server" ]; then + printf '\nthe LaunchAgent and code were already uninstalled; what remains\n' + printf 'is this script and the logs:\n\n rm -rf "%s" "%s"\n\n' "$ROOT" "$LOG_DIR" + fi } case "${1:-status}" in @@ -1020,7 +1035,7 @@ usage: manage show-password warn, then display the setup password locally serve re-apply the Tailscale Serve mapping for this server rollback switch to the retained previous release, preserving state - uninstall remove LaunchAgent + code (keeps config and state) + uninstall remove LaunchAgent + code (keeps config, state, this script) purge irreversibly delete config and state USAGE exit 64 diff --git a/deploy/local/install-windows.ps1 b/deploy/local/install-windows.ps1 index 3dce29f38..b7bd634b2 100644 --- a/deploy/local/install-windows.ps1 +++ b/deploy/local/install-windows.ps1 @@ -1747,7 +1747,10 @@ function Invoke-Uninstall { Write-Host " config : $Root\config" Write-Host " state : $StateDir" Write-Host "" - Write-Host 'Use "manage purge" separately to delete those irreversibly.' + Write-Host 'This script is left in place so "purge" can still delete them' + Write-Host 'irreversibly afterwards:' + Write-Host "" + Write-Host (' "{0}\bin\manage.cmd" purge' -f $Root) Write-Host "" if ([Console]::IsInputRedirected) { [Console]::Error.WriteLine('refusing to uninstall with no terminal to confirm at') @@ -1776,9 +1779,13 @@ function Invoke-Uninstall { Write-Host "left the Serve config alone (it does not point at 127.0.0.1:$PORT)" } - foreach ($p in @((Join-Path $Root 'releases'), (Join-Path $Root 'bin'), (Join-Path $Root 'run'))) { + foreach ($p in @((Join-Path $Root 'releases'), (Join-Path $Root 'run'))) { Remove-Tree $p } + # bin\run-server.ps1, not bin: this script lives there too, and "purge" -- the + # command the message above points at -- is unreachable once it is deleted. + $runServer = Join-Path $Root 'bin\run-server.ps1' + if (Test-Path -LiteralPath $runServer) { [IO.File]::Delete($runServer) } foreach ($p in @($CurrentPointer, $PreviousPointer)) { if (Test-Path -LiteralPath $p) { [IO.File]::Delete($p) } } @@ -1787,6 +1794,10 @@ function Invoke-Uninstall { Write-Host " $Root\config" Write-Host " $StateDir" Write-Host "" + Write-Host "delete them irreversibly with:" + Write-Host "" + Write-Host (' "{0}\bin\manage.cmd" purge' -f $Root) + Write-Host "" return 0 } @@ -1805,6 +1816,17 @@ function Invoke-Purge { Remove-Tree $p } Write-Host 'purged.' + # bin\run-server.ps1 is what "uninstall" removes, so its absence means the + # Scheduled Task and the code are already gone and this script is the last + # thing standing. It cannot delete itself out from under the shell running it. + if (-not (Test-Path -LiteralPath (Join-Path $Root 'bin\run-server.ps1'))) { + Write-Host "" + Write-Host "the Scheduled Task and code were already uninstalled; this script" + Write-Host "is all that remains:" + Write-Host "" + Write-Host (' Remove-Item -Recurse -Force "{0}"' -f $Root) + Write-Host "" + } return 0 } @@ -1830,7 +1852,7 @@ usage: manage show-password warn, then display the setup password locally serve re-apply the Tailscale Serve mapping for this server rollback switch to the retained previous release, preserving state - uninstall remove the Scheduled Task + code (keeps config and state) + uninstall remove the Scheduled Task + code (keeps config, state, this script) purge irreversibly delete config and state "@ exit 64 @@ -1845,13 +1867,16 @@ usage: manage @echo off rem Installed by deploy/local/install-windows.ps1. rem The trailing "& exit" matters. cmd.exe seeks back into this file after -rem every command, and "manage uninstall" deletes bin -- this very file -- so -rem that read fails with "The system cannot find the path specified." and -rem returns 1, making a clean uninstall look broken. "exit" ends cmd.exe -rem outright so it never seeks back, and with no argument it exits with the -rem current ERRORLEVEL. "exit /b" does NOT work here: it only returns from the -rem batch, which still requires reading the file. Tradeoff: calling this .cmd -rem from another batch script ends that script too; it is meant to be run +rem every command, so a manage command that removes the file it is being read +rem from makes that read fail with "The system cannot find the path specified." +rem and return 1, making a clean run look broken. "uninstall" used to do exactly +rem that -- it deleted bin, this very file included. It now deletes only +rem bin\run-server.ps1, so nothing manage does removes this file; the guard is +rem kept so the exit code does not depend on that staying true. "exit" ends +rem cmd.exe outright so it never seeks back, and with no argument it exits with +rem the current ERRORLEVEL. "exit /b" does NOT work here: it only returns from +rem the batch, which still requires reading the file. Tradeoff: calling this +rem .cmd from another batch script ends that script too; it is meant to be run rem directly. "$POWERSHELL_EXE" -NoProfile -ExecutionPolicy Bypass -File "%~dp0manage.ps1" %* & exit "@ diff --git a/docs/specs/server.md b/docs/specs/server.md index 6b9fced63..4245e78c1 100644 --- a/docs/specs/server.md +++ b/docs/specs/server.md @@ -1025,6 +1025,23 @@ nonzero on any failure), `logs`, `restart`, `show-password`, `serve` (re-apply the Serve mapping after a dev session repointed it), `rollback`, `uninstall`, and the separately-confirmed `purge`. +Teardown is two steps in that order, and `uninstall` has to leave `manage` +itself behind for the second one to be reachable at all: it removes the service +definition, the releases, the pointers, `run/` and `bin/run-server`, but not the +`bin` directory `manage` lives in — deleting that would strand `config/` and +`state/`, the data the message it prints tells you to run `purge` for. `purge` +deletes `state/` and `config/` after its typed confirmation and, when +`bin/run-server` is already gone, closes by printing the one command that +removes what is left; it cannot delete itself out from under the shell running +it. That command names the dormouse-owned log directory alongside the install +root, because on Linux and macOS the logs live outside it — `LOG_ROOT` +(`$XDG_STATE_HOME/dormouse-server`) and `~/Library/Logs/Dormouse Server` +respectively, each named at the level dormouse owns so no empty directory +survives. On Windows `logs` is inside the root, so the root alone is enough. +Source of truth: +`cmd_uninstall` / `cmd_purge` (`Invoke-Uninstall` / `Invoke-Purge` on Windows) +in the `manage` script each installer generates. + The Host that connects to such a server needs a build whose baked relay allowlist admits the origin — see "Where a Host may reach a relay server" above; a `*.ts.net` origin requires `DORMOUSE_REMOTE_CONNECT_SRC` at build time.