feat: add safe one-click uninstall
TallyNote release / linux-x64 (push) Successful in 6m16s

This commit is contained in:
Qiufeng
2026-09-01 06:57:52 +08:00
parent 4434acf697
commit 4b9c80cc3a
10 changed files with 723 additions and 28 deletions
Executable
+473
View File
@@ -0,0 +1,473 @@
#!/usr/bin/env bash
set -Eeuo pipefail
# TallyNote native uninstaller. The default operation removes only the
# application and service integration; the database and attachments stay in
# place until --purge-data --yes is explicitly requested.
PATH=/usr/sbin:/usr/bin:/sbin:/bin:/usr/local/bin
export PATH
umask 077
TEST_MODE=${TALLYNOTE_UNINSTALL_TEST_MODE:-false}
TEST_ROOT=${TALLYNOTE_UNINSTALL_ROOT:-}
PREFIX=${TALLYNOTE_PREFIX:-/opt/tallynote}
DATA_DIR=${TALLYNOTE_DATA_DIR:-/var/lib/tallynote}
CONFIG_DIR=${TALLYNOTE_CONFIG_DIR:-/etc/tallynote}
UNIT_DIR=${TALLYNOTE_SYSTEMD_UNIT_DIR:-/etc/systemd/system}
SBIN_DIR=${TALLYNOTE_SBIN_DIR:-/usr/local/sbin}
LIBEXEC_DIR=${TALLYNOTE_LIBEXEC_DIR:-/usr/local/libexec}
SYSTEMCTL_BIN=systemctl
SYSTEMCTL_AVAILABLE=0
PURGE_DATA=0
PURGE_CONFIG=0
YES=0
DRY_RUN=0
FORCE=0
EXPLICIT_PREFIX=0
EXPLICIT_DATA=0
EXPLICIT_CONFIG=0
die() { printf 'tallynote uninstaller: %s\n' "$*" >&2; exit 1; }
log() { printf 'tallynote uninstaller: %s\n' "$*"; }
usage() {
cat <<'EOF'
Usage: tallynote-uninstall [--yes] [--purge-data] [--purge-config]
[--dry-run] [--force]
[--prefix PATH] [--data-dir PATH] [--config-dir PATH]
By default, remove the TallyNote release tree, systemd units, update helpers,
and known configuration files. The database, attachments, staging, exports,
update queue, and update backups are preserved. Data removal requires both
--purge-data and --yes. --force is only for an operator who has verified that
no update is in progress; it overrides the pending-update guard.
EOF
}
is_true() { [[ "$1" == true || "$1" == 1 ]]; }
if [[ "$TEST_MODE" != true && "$TEST_MODE" != false && "$TEST_MODE" != 1 && "$TEST_MODE" != 0 ]]; then
die 'TALLYNOTE_UNINSTALL_TEST_MODE must be true or false'
fi
if [[ "$TEST_MODE" == 1 ]]; then TEST_MODE=true; fi
if [[ "$TEST_MODE" == 0 ]]; then TEST_MODE=false; fi
while (($#)); do
case "$1" in
--yes) YES=1 ;;
--purge-data) PURGE_DATA=1 ;;
--purge-config) PURGE_CONFIG=1 ;;
--dry-run) DRY_RUN=1 ;;
--force) FORCE=1 ;;
--prefix) PREFIX=${2:?missing value for --prefix}; EXPLICIT_PREFIX=1; shift ;;
--data-dir) DATA_DIR=${2:?missing value for --data-dir}; EXPLICIT_DATA=1; shift ;;
--config-dir) CONFIG_DIR=${2:?missing value for --config-dir}; EXPLICIT_CONFIG=1; shift ;;
-h|--help) usage; exit 0 ;;
*) die "unknown option: $1" ;;
esac
shift
done
if [[ "$TEST_MODE" == true ]]; then
[[ -n "$TEST_ROOT" ]] || die 'test mode requires TALLYNOTE_UNINSTALL_ROOT'
[[ "$TEST_ROOT" = /* && "$TEST_ROOT" != *'..'* && "$TEST_ROOT" != *'//'* && "$TEST_ROOT" != *$'\n'* && "$TEST_ROOT" != *$'\r'* ]] || die 'test root is invalid'
(( EXPLICIT_PREFIX )) || PREFIX=${TALLYNOTE_PREFIX:-$TEST_ROOT/opt/tallynote}
(( EXPLICIT_DATA )) || DATA_DIR=${TALLYNOTE_DATA_DIR:-$TEST_ROOT/var/lib/tallynote}
(( EXPLICIT_CONFIG )) || CONFIG_DIR=${TALLYNOTE_CONFIG_DIR:-$TEST_ROOT/etc/tallynote}
UNIT_DIR=${TALLYNOTE_SYSTEMD_UNIT_DIR:-$TEST_ROOT/etc/systemd/system}
SBIN_DIR=${TALLYNOTE_SBIN_DIR:-$TEST_ROOT/usr/local/sbin}
LIBEXEC_DIR=${TALLYNOTE_LIBEXEC_DIR:-$TEST_ROOT/usr/local/libexec}
SYSTEMCTL_BIN=${TALLYNOTE_SYSTEMCTL_BIN:-systemctl}
fi
stat_uid() { stat -c '%u' "$1" 2>/dev/null || stat -f '%u' "$1"; }
stat_mode() { stat -c '%a' "$1" 2>/dev/null || stat -f '%Lp' "$1"; }
stat_mode_bits() {
local mode
mode=$(stat_mode "$1")
[[ "$mode" =~ ^[0-7]+$ ]] || die "无法读取路径权限:$1"
printf '%d' "$((8#$mode))"
}
allowed_owner() {
local path=$1 uid
uid=$(stat_uid "$path")
if [[ "$TEST_MODE" == true ]]; then
[[ "$uid" == "$(id -u)" || "$uid" == 0 ]]
else
[[ "$uid" == 0 ]]
fi
}
allowed_data_owner() {
local path=$1 uid tallynote_uid
uid=$(stat_uid "$path")
if [[ "$TEST_MODE" == true ]]; then
[[ "$uid" == "$(id -u)" || "$uid" == 0 ]]
return
fi
[[ "$uid" == 0 ]] && return 0
tallynote_uid=$(id -u tallynote 2>/dev/null || true)
[[ -n "$tallynote_uid" && "$uid" == "$tallynote_uid" ]]
}
validate_path_value() {
local value=$1 label=$2
[[ "$value" = /* && "$value" != *$'\n'* && "$value" != *$'\r'* ]] || die "$label 必须是绝对路径"
[[ "$value" =~ ^/[A-Za-z0-9._/-]+$ && "$value" != *"//"* && "$value" != *"/../"* && "$value" != */.. && "$value" != *"/./"* && "$value" != */. && "$value" != / && "$value" != */ ]] || die "$label 包含不受支持的路径字符"
case "$value" in
/opt|/var|/etc|/usr|/usr/local|/bin|/sbin|/home|/root|/tmp) die "$label 不能指向系统顶层目录" ;;
esac
}
validate_parent_chain() {
local target=$1 current=/ component relative
relative=${target#/}
IFS='/' read -r -a _parts <<< "$relative"
for component in "${_parts[@]}"; do
[[ -n "$component" ]] || continue
current="${current%/}/$component"
if [[ -L "$current" ]]; then die "路径不能包含符号链接:$current"; fi
if [[ -e "$current" ]]; then
[[ -d "$current" ]] || die "路径不是目录:$current"
allowed_owner "$current" || die "路径目录的所有者不受信任:$current"
local mode_bits
mode_bits=$(stat_mode_bits "$current")
(( (mode_bits & 18) == 0 || (mode_bits & 512) != 0 )) || die "路径目录权限过宽:$current"
fi
done
}
validate_target() {
local target=$1 label=$2 owner_check=allowed_owner
[[ "${3:-}" == data ]] && owner_check=allowed_data_owner
validate_path_value "$target" "$label"
validate_parent_chain "$target"
if [[ -e "$target" || -L "$target" ]]; then
"$owner_check" "$target" || die "$label 的所有者不受信任:$target"
fi
}
read_env_value() {
local file=$1 key=$2
sed -n "s/^${key}=//p" "$file" | head -n 1
}
env_key_count() {
local file=$1 key=$2
awk -v key="$key" 'index($0, key "=") == 1 { count += 1 } END { print count + 0 }' "$file"
}
load_config() {
local env_file=$CONFIG_DIR/tallynote.env value key count
[[ -e "$env_file" || -L "$env_file" ]] || return 0
[[ -f "$env_file" && ! -L "$env_file" ]] || die '环境文件不是普通文件'
allowed_owner "$env_file" || die '环境文件的所有者不受信任'
local mode_bits
mode_bits=$(stat_mode_bits "$env_file")
(( (mode_bits & 18) == 0 )) || die '环境文件不能被组或其他用户写入'
for key in TALLYNOTE_INSTALL_PREFIX TALLYNOTE_DATA_DIR; do
count=$(env_key_count "$env_file" "$key")
[[ "$count" == 0 || "$count" == 1 ]] || die "环境文件包含重复配置:$key"
done
if (( ! EXPLICIT_PREFIX )); then
value=$(read_env_value "$env_file" TALLYNOTE_INSTALL_PREFIX)
[[ -z "$value" ]] || PREFIX=$value
fi
if (( ! EXPLICIT_DATA )); then
value=$(read_env_value "$env_file" TALLYNOTE_DATA_DIR)
[[ -z "$value" ]] || DATA_DIR=$value
fi
}
path_inside() {
local child=$1 parent=$2
[[ "$child" == "$parent"/* ]]
}
assert_disjoint_paths() {
local left left_label right right_label
local -a labels=(prefix data config unit sbin libexec)
for left_label in "${labels[@]}"; do
case "$left_label" in
prefix) left=$PREFIX ;;
data) left=$DATA_DIR ;;
config) left=$CONFIG_DIR ;;
unit) left=$UNIT_DIR ;;
sbin) left=$SBIN_DIR ;;
libexec) left=$LIBEXEC_DIR ;;
esac
for right_label in "${labels[@]}"; do
[[ "$left_label" == "$right_label" ]] && continue
case "$right_label" in
prefix) right=$PREFIX ;;
data) right=$DATA_DIR ;;
config) right=$CONFIG_DIR ;;
unit) right=$UNIT_DIR ;;
sbin) right=$SBIN_DIR ;;
libexec) right=$LIBEXEC_DIR ;;
esac
if [[ "$left" == "$right" ]] || path_inside "$left" "$right" || path_inside "$right" "$left"; then
die "卸载目录不能互相嵌套:$left 与 $right"
fi
done
done
}
assert_test_scope() {
[[ "$TEST_MODE" == true ]] || return 0
[[ -d "$TEST_ROOT" && ! -L "$TEST_ROOT" ]] || die 'test root must be an existing directory'
validate_parent_chain "$TEST_ROOT"
allowed_owner "$TEST_ROOT" || die 'test root owner is not trusted'
local value label
for label in PREFIX DATA_DIR CONFIG_DIR UNIT_DIR SBIN_DIR LIBEXEC_DIR; do
case "$label" in
PREFIX) value=$PREFIX ;;
DATA_DIR) value=$DATA_DIR ;;
CONFIG_DIR) value=$CONFIG_DIR ;;
UNIT_DIR) value=$UNIT_DIR ;;
SBIN_DIR) value=$SBIN_DIR ;;
LIBEXEC_DIR) value=$LIBEXEC_DIR ;;
esac
[[ "$value" == "$TEST_ROOT"/* ]] || die "test mode path escapes TALLYNOTE_UNINSTALL_ROOT: $value"
done
}
managed_file() {
local target=$1 label=$2
case "$label" in
service\ unit|updater\ unit|path\ unit|update\ helper|update\ runner|uninstaller)
grep -Eiq 'tallynote|TallyNote' "$target" || return 1
if [[ "$label" == 'path unit' ]]; then
grep -Fq "$DATA_DIR" "$target" || return 1
elif [[ "$label" == *unit ]]; then
grep -Fq "$PREFIX" "$target" || return 1
else
grep -Eq 'TALLYNOTE_INSTALL_PREFIX|/opt/tallynote' "$target" || return 1
fi
;;
environment\ file)
grep -q '^TALLYNOTE_INSTALL_PREFIX=' "$target" || return 1
grep -q '^TALLYNOTE_DATA_DIR=' "$target" || return 1
[[ "$(read_env_value "$target" TALLYNOTE_INSTALL_PREFIX)" == "$PREFIX" ]] || return 1
[[ "$(read_env_value "$target" TALLYNOTE_DATA_DIR)" == "$DATA_DIR" ]] || return 1
;;
update\ public\ key)
[[ -f "$CONFIG_DIR/tallynote.env" ]] || return 1
[[ "$(read_env_value "$CONFIG_DIR/tallynote.env" TALLYNOTE_UPDATE_PUBLIC_KEY_FILE)" == "$target" ]] || return 1
;;
*) return 0 ;;
esac
}
validate_release_tree() {
local tree=$1 owner_check=${2:-allowed_owner}
[[ -d "$tree" && ! -L "$tree" ]] || die "发布目录无效:$tree"
"$owner_check" "$tree" || die "发布目录的所有者不受信任:$tree"
if find "$tree" -type l -print -quit | grep -q .; then
die "发布目录包含符号链接:$tree"
fi
if find "$tree" ! -type d ! -type f -print -quit | grep -q .; then
die "发布目录包含不支持的文件类型:$tree"
fi
local node mode_bits
while IFS= read -r node; do
"$owner_check" "$node" || die "发布目录节点的所有者不受信任:$node"
mode_bits=$(stat_mode_bits "$node")
(( (mode_bits & 18) == 0 )) || die "发布目录节点权限过宽:$node"
done < <(find "$tree" -print)
}
pending_update() {
[[ -e "$PREFIX/.update-state" || -L "$PREFIX/.update-state" || -e "$DATA_DIR/update-request.json" || -L "$DATA_DIR/update-request.json" ]]
}
run_systemctl() {
(( DRY_RUN )) && return 0
if [[ "$SYSTEMCTL_BIN" == */* ]]; then
[[ -x "$SYSTEMCTL_BIN" ]] || return 0
else
command -v "$SYSTEMCTL_BIN" >/dev/null 2>&1 || return 0
fi
"$SYSTEMCTL_BIN" "$@"
}
stop_services() {
local unit active status
if (( DRY_RUN )); then
log 'dry-run: would stop/disable systemd units in path -> updater -> app order'
return 0
fi
if (( ! SYSTEMCTL_AVAILABLE )); then
for unit in tallynote-update.path tallynote-update.service tallynote.service; do
[[ ! -e "$UNIT_DIR/$unit" ]] || die 'systemctl 不可用,无法安全停止已安装服务'
done
return 0
fi
for unit in tallynote-update.path tallynote-update.service tallynote.service; do
active=0
if run_systemctl is-active --quiet "$unit" >/dev/null 2>&1; then
active=1
else
status=$?
case "$status" in
3|4) ;;
*) die "无法读取服务状态:$unit" ;;
esac
fi
if (( active )); then
run_systemctl stop "$unit" || die "无法停止服务:$unit"
fi
if [[ -e "$UNIT_DIR/$unit" ]]; then
run_systemctl disable "$unit" >/dev/null 2>&1 || die "无法禁用服务:$unit"
fi
done
run_systemctl daemon-reload >/dev/null 2>&1 || die 'systemd daemon-reload 失败'
}
validate_systemctl() {
local resolved uid mode_bits
if [[ "$TEST_MODE" == true ]]; then
if [[ "$SYSTEMCTL_BIN" == */* && -x "$SYSTEMCTL_BIN" ]]; then
SYSTEMCTL_AVAILABLE=1
fi
return 0
fi
resolved=$(command -v systemctl 2>/dev/null || true)
if [[ -z "$resolved" ]]; then
SYSTEMCTL_AVAILABLE=0
return 0
fi
[[ -x "$resolved" && ! -L "$resolved" ]] || die 'systemctl 必须是可信的普通可执行文件'
uid=$(stat_uid "$resolved")
mode_bits=$(stat_mode_bits "$resolved")
[[ "$uid" == 0 && $((mode_bits & 18)) -eq 0 ]] || die 'systemctl 必须由 root 拥有且不可被其他用户写入'
SYSTEMCTL_BIN=$resolved
SYSTEMCTL_AVAILABLE=1
}
remove_file_if_owned() {
local target=$1 label=$2
[[ -e "$target" || -L "$target" ]] || return 0
if [[ -L "$target" || ! -f "$target" ]]; then
log "warning: 保留非普通文件:$target"
return 0
fi
if ! allowed_owner "$target"; then
log "warning: 保留非本安装创建的文件:$target"
return 0
fi
if ! managed_file "$target" "$label"; then
log "warning: 保留内容不匹配的文件:$target"
return 0
fi
if (( DRY_RUN )); then
log "dry-run: remove $label $target"
else
rm -f -- "$target"
fi
}
remove_tree() {
local target=$1 label=$2 owner_check=${3:-allowed_owner}
[[ -e "$target" || -L "$target" ]] || return 0
[[ -d "$target" && ! -L "$target" ]] || die "$label 不是安全目录:$target"
"$owner_check" "$target" || die "$label 的所有者不受信任:$target"
validate_release_tree "$target" "$owner_check"
if (( DRY_RUN )); then
log "dry-run: remove $label $target"
else
rm -rf -- "$target"
fi
}
remove_prefix() {
local current=$PREFIX/current current_target releases=$PREFIX/releases
if [[ -L "$current" ]]; then
current_target=$(readlink "$current")
[[ "$current_target" = "$PREFIX/releases/"* && "$current_target" != *'..'* ]] || die 'current 符号链接指向安装目录之外'
[[ -d "$current_target" && ! -L "$current_target" ]] || die 'current 目标不是安全目录'
if (( DRY_RUN )); then
log "dry-run: remove current link $current"
else
rm -f -- "$current"
fi
elif [[ -e "$current" ]]; then
log "warning: 保留非符号链接 current:$current"
fi
remove_tree "$releases" 'releases'
remove_tree "$PREFIX/.update-work" 'update work'
remove_file_if_owned "$PREFIX/.update-state" 'update state'
if [[ -d "$PREFIX" && ! -L "$PREFIX" ]]; then
allowed_owner "$PREFIX" || die "安装目录的所有者不受信任:$PREFIX"
if (( DRY_RUN )); then
log "dry-run: remove empty install directory if empty: $PREFIX"
else
rmdir -- "$PREFIX" 2>/dev/null || true
fi
fi
}
remove_config() {
remove_file_if_owned "$CONFIG_DIR/update-signing-key.pub" 'update public key'
remove_file_if_owned "$CONFIG_DIR/tallynote.env" 'environment file'
if (( PURGE_CONFIG )) && [[ -d "$CONFIG_DIR" && ! -L "$CONFIG_DIR" ]]; then
allowed_owner "$CONFIG_DIR" || die '配置目录的所有者不受信任'
if (( DRY_RUN )); then log "dry-run: remove config directory if safe: $CONFIG_DIR"; else rmdir -- "$CONFIG_DIR" 2>/dev/null || true; fi
fi
}
remove_data() {
local backup_dir
backup_dir=$(dirname -- "$DATA_DIR")/tallynote-backups
if (( PURGE_DATA )); then
(( YES )) || die '--purge-data 必须同时提供 --yes'
remove_tree "$DATA_DIR" 'data' allowed_data_owner
remove_tree "$backup_dir" 'backup data'
else
log "保留数据目录:$DATA_DIR"
if [[ -d "$backup_dir" ]]; then
log "保留备份目录:$backup_dir"
fi
fi
return 0
}
main() {
if [[ "$TEST_MODE" != true ]]; then
[[ $EUID -eq 0 ]] || die '卸载必须以 root 运行(请使用 sudo)'
fi
if (( PURGE_DATA && ! YES )); then
die '--purge-data 必须同时提供 --yes'
fi
validate_path_value "$CONFIG_DIR" '配置目录'
validate_target "$CONFIG_DIR" '配置目录'
assert_test_scope
load_config
validate_target "$PREFIX" '安装目录'
validate_target "$DATA_DIR" '数据目录' data
validate_target "$CONFIG_DIR" '配置目录'
validate_target "$UNIT_DIR" 'systemd 单元目录'
validate_target "$SBIN_DIR" 'sbin 目录'
validate_target "$LIBEXEC_DIR" 'libexec 目录'
assert_test_scope
assert_disjoint_paths
validate_systemctl
if (( ! FORCE )) && pending_update; then
die '检测到未完成的更新状态;确认更新已停止后使用 --force 重试'
fi
log "target: prefix=$PREFIX data=$DATA_DIR config=$CONFIG_DIR"
stop_services
remove_prefix
remove_file_if_owned "$UNIT_DIR/tallynote.service" 'service unit'
remove_file_if_owned "$UNIT_DIR/tallynote-update.service" 'updater unit'
remove_file_if_owned "$UNIT_DIR/tallynote-update.path" 'path unit'
remove_file_if_owned "$SBIN_DIR/tallynote-update" 'update helper'
remove_file_if_owned "$LIBEXEC_DIR/tallynote-update-runner" 'update runner'
remove_file_if_owned "$SBIN_DIR/tallynote-uninstall" 'uninstaller'
remove_config
remove_data
log 'uninstall complete'
}
main "$@"