Files
TallyNote/uninstall.sh
T
Qiufeng 344985f514
TallyNote release / linux-x64 (push) Successful in 6m26s
release: 1.1.12
2026-09-02 20:23:46 +08:00

498 lines
18 KiB
Bash
Executable File
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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:-}
TEST_DATA_OWNER_UID=${TALLYNOTE_UNINSTALL_TEST_DATA_OWNER_UID:-}
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
SYSTEMCTL_TIMEOUT_SECONDS=${TALLYNOTE_UNINSTALL_SYSTEMCTL_TIMEOUT_SECONDS:-30}
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
[[ "$SYSTEMCTL_TIMEOUT_SECONDS" =~ ^[1-9][0-9]*$ ]] || die 'TALLYNOTE_UNINSTALL_SYSTEMCTL_TIMEOUT_SECONDS must be a positive integer'
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 || ( -n "$TEST_DATA_OWNER_UID" && "$uid" == "$TEST_DATA_OWNER_UID" ) ]]
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"
# The target itself is checked by validate_target with its path-specific
# owner policy (data may belong to the tallynote service user). Keep all
# ancestor directories root-owned, but do not apply that policy twice to
# the final target.
if [[ "$current" != "$target" ]]; then
allowed_owner "$current" || die "路径目录的所有者不受信任:$current"
fi
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|admin\ initializer|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|admin-init.js' "$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
# A stuck systemd/dbus call must not leave the uninstaller looking frozen.
# Test fixtures intentionally bypass the external timeout command.
if [[ "$TEST_MODE" != true ]] && command -v timeout >/dev/null 2>&1; then
timeout "$SYSTEMCTL_TIMEOUT_SECONDS" "$SYSTEMCTL_BIN" "$@"
else
"$SYSTEMCTL_BIN" "$@"
fi
}
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
log "正在停止 TallyNote 服务(systemd 操作超时 ${SYSTEMCTL_TIMEOUT_SECONDS} 秒)"
for unit in tallynote-update.path tallynote-update.service tallynote.service; do
active=0
log "检查服务:$unit"
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
log "停止服务:$unit"
run_systemctl stop "$unit" || die "无法停止服务:$unit(如果 systemd 正在等待进程退出,请稍后重试)"
log "已停止服务:$unit"
fi
if [[ -e "$UNIT_DIR/$unit" ]]; then
log "禁用服务:$unit"
run_systemctl disable "$unit" >/dev/null 2>&1 || die "无法禁用服务:$unit"
fi
done
run_systemctl daemon-reload >/dev/null 2>&1 || die 'systemd daemon-reload 失败'
log 'systemd 服务已停止并禁用'
}
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"
log '开始移除 TallyNote 文件和服务配置'
stop_services
remove_prefix
log '发布文件和更新组件已移除'
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-admin-init" 'admin initializer'
remove_file_if_owned "$SBIN_DIR/tallynote-uninstall" 'uninstaller'
remove_config
remove_data
log 'uninstall complete'
}
main "$@"