#!/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_managed_node 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_managed_node() { local node_root="$PREFIX/nodejs" marker [[ -e "$node_root" || -L "$node_root" ]] || return 0 [[ -d "$node_root" && ! -L "$node_root" ]] || die "Node.js 管理目录不是安全目录:$node_root" marker="$node_root/.tallynote-managed" if [[ ! -f "$marker" || "$(sed -n '1p' "$marker" 2>/dev/null)" != tallynote-managed-node-v1 ]]; then log "保留非 TallyNote 管理的 Node.js 目录:$node_root" return 0 fi allowed_owner "$node_root" || die "Node.js 管理目录的所有者不受信任:$node_root" # Node distributions contain npm/corepack symlinks. They are safe to remove # because rm never follows symlinks; validate ownership and permissions for # every node while deliberately permitting those internal links. local node mode_bits while IFS= read -r node; do allowed_owner "$node" || die "Node.js 管理目录节点的所有者不受信任:$node" [[ -L "$node" ]] && continue mode_bits=$(stat_mode_bits "$node") (( (mode_bits & 18) == 0 )) || die "Node.js 管理目录权限过宽:$node" done < <(find "$node_root" -print) if (( DRY_RUN )); then log "dry-run: remove managed Node.js $node_root" else rm -rf -- "$node_root" 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 "$@"