Files
kaidi/deploy/purge.sh
T
2026-08-18 22:51:35 +08:00

323 lines
12 KiB
Bash
Executable File

#!/usr/bin/env bash
set -Eeuo pipefail
umask 077
APP_ROOT=/opt/kaidi
BAOTA_ROOT=/www/wwwroot/kaidi
CONFIG_ROOT=/etc/kaidi
STATE_ROOT=/var/lib/kaidi
UPDATE_STATE_ROOT=/var/lib/kaidi-update
LOG_ROOT=/var/log/kaidi
FIRST_LOGIN_FILE=/root/kaidi-first-login.txt
BACKUP_ROOT=${KAIDI_PURGE_BACKUP_ROOT:-/root/kaidi-reinstall-backups}
CONFIRMATION=${KAIDI_PURGE_CONFIRM:-}
REQUIRED_CONFIRMATION=DELETE_LOCAL_KAIDI_INSTALLATION
SERVICE_USER=kaidi
SERVICE_GROUP=kaidi
BACKUP_ARCHIVE=
LOG_LOCALE=${KAIDI_LOG_LOCALE:-zh-CN}
DELETE_RECOVERY_BACKUP=${KAIDI_PURGE_DELETE_BACKUP:-false}
localize_message() {
local message=$1
[ "$LOG_LOCALE" = en ] && { printf '%s' "$message"; return; }
case "$message" in
"Run with sudo or as root") printf '请使用 sudo 或 root 运行' ;;
"The purge script only supports Linux") printf '卸载程序仅支持 Linux' ;;
"Set KAIDI_PURGE_CONFIRM="*) printf '请设置确认变量:KAIDI_PURGE_CONFIRM=%s' "${message#Set KAIDI_PURGE_CONFIRM=}" ;;
"KAIDI_PURGE_BACKUP_ROOT must be an absolute path") printf 'KAIDI_PURGE_BACKUP_ROOT 必须是绝对路径' ;;
"KAIDI_PURGE_DELETE_BACKUP must be true or false") printf 'KAIDI_PURGE_DELETE_BACKUP 只能是 true 或 false' ;;
"The recovery backup must be outside"*) printf '恢复备份目录必须位于 Kaidi 管理目录之外' ;;
"The recovery backup root must not be a symbolic link") printf '恢复备份目录不能是符号链接' ;;
"Failed to stop "*) printf '停止服务失败:%s' "${message#Failed to stop }" ;;
"Stopping processes owned by"*) printf '正在停止专用服务账号进程:%s' "${message##*: }" ;;
"Stopping remaining Kaidi processes: "*) printf '正在停止剩余 Kaidi 进程:%s' "${message#Stopping remaining Kaidi processes: }" ;;
"A process manager restarted"*) printf '检测到宝塔等进程管理器正在重新拉起 Kaidi;请先停止并删除宝塔中的 Kaidi 项目,再重试卸载' ;;
"No local configuration or data"*) printf '没有需要备份的本地配置或数据' ;;
"Creating a root-only recovery backup at "*) printf '正在创建仅 root 可读的恢复备份:%s' "${message#Creating a root-only recovery backup at }" ;;
"Failed to remove the dedicated"*) printf '删除 Kaidi 专用服务账号失败' ;;
"Processes owned by the removed"*) printf '删除服务账号后仍有进程运行' ;;
"Keeping pre-existing user"*) printf '保留预先存在的用户:%s' "${message#Keeping pre-existing user }" ;;
"Keeping group "*) printf '保留仍被其他账号使用的用户组:%s' "${message#Keeping group }" ;;
"External MySQL data and reverse-proxy configuration will not be modified") printf '不会修改外部 MySQL 数据和反向代理配置' ;;
"Local Kaidi installation state has been removed") printf '本地 Kaidi 安装文件、服务和运行状态已删除' ;;
"Recovery backup: "*) printf '恢复备份:%s' "${message#Recovery backup: }" ;;
"Use a new empty MySQL database for the next installation") printf '重新安装时请使用新的空 MySQL 数据库' ;;
*) printf '%s' "$message" ;;
esac
}
log() { printf '[kaidi-purge] %s\n' "$(localize_message "$*")"; }
die() { printf '[kaidi-purge] 错误:%s\n' "$(localize_message "$*")" >&2; exit 1; }
systemd_available() {
command -v systemctl >/dev/null 2>&1 && [ -d /run/systemd/system ]
}
validate_inputs() {
[ "$(id -u)" -eq 0 ] || die "Run with sudo or as root"
[ "$(uname -s)" = Linux ] || die "The purge script only supports Linux"
[ "$CONFIRMATION" = "$REQUIRED_CONFIRMATION" ] \
|| die "Set KAIDI_PURGE_CONFIRM=$REQUIRED_CONFIRMATION to confirm local removal"
case "$BACKUP_ROOT" in
/*) ;;
*) die "KAIDI_PURGE_BACKUP_ROOT must be an absolute path" ;;
esac
case "$BACKUP_ROOT/" in
"$APP_ROOT/"*|"$BAOTA_ROOT/"*|"$CONFIG_ROOT/"*|"$STATE_ROOT/"*|\
"$UPDATE_STATE_ROOT/"*|"$LOG_ROOT/"*)
die "The recovery backup must be outside every managed Kaidi directory"
;;
esac
[ ! -L "$BACKUP_ROOT" ] || die "The recovery backup root must not be a symbolic link"
case "$DELETE_RECOVERY_BACKUP" in
true|false) ;;
*) die "KAIDI_PURGE_DELETE_BACKUP must be true or false" ;;
esac
}
stop_systemd_units() {
local unit
systemd_available || return 0
for unit in kaidi-update.path kaidi-update.service kaidi-finance.service; do
if systemctl cat "$unit" >/dev/null 2>&1; then
systemctl stop "$unit" >/dev/null 2>&1 \
|| die "Failed to stop $unit"
systemctl disable "$unit" >/dev/null 2>&1 || true
fi
done
}
related_pids() {
local proc pid command_line
for proc in /proc/[0-9]*; do
[ -r "$proc/cmdline" ] || continue
pid=${proc##*/}
[ "$pid" != "$$" ] && [ "$pid" != "$PPID" ] || continue
command_line=$(tr '\000' ' ' < "$proc/cmdline" 2>/dev/null || true)
case "$command_line" in
*"$APP_ROOT/"*|*"$BAOTA_ROOT/"*) printf '%s\n' "$pid" ;;
esac
done
}
managed_service_account() {
local entry home shell
entry=$(getent passwd "$SERVICE_USER" 2>/dev/null || true)
[ -n "$entry" ] || return 1
home=$(printf '%s\n' "$entry" | awk -F: '{print $6}')
shell=$(printf '%s\n' "$entry" | awk -F: '{print $7}')
case "$home:$shell" in
"$STATE_ROOT:"*/nologin|"$STATE_ROOT:"*/false) return 0 ;;
*) return 1 ;;
esac
}
service_user_pids() {
local uid=$1 proc pid owner_uid
for proc in /proc/[0-9]*; do
[ -d "$proc" ] || continue
pid=${proc##*/}
owner_uid=$(stat -c '%u' "$proc" 2>/dev/null || true)
[ "$owner_uid" = "$uid" ] && printf '%s\n' "$pid"
done
}
terminate_uid_processes() {
local uid=$1 deadline
local -a pids=()
mapfile -t pids < <(service_user_pids "$uid")
if [ "${#pids[@]}" -gt 0 ]; then
log "Stopping processes owned by the dedicated $SERVICE_USER account: ${pids[*]}"
kill -TERM "${pids[@]}" 2>/dev/null || true
fi
deadline=$((SECONDS + 5))
while [ "$SECONDS" -lt "$deadline" ]; do
mapfile -t pids < <(service_user_pids "$uid")
[ "${#pids[@]}" -gt 0 ] || return 0
sleep 1
done
kill -KILL "${pids[@]}" 2>/dev/null || true
sleep 1
mapfile -t pids < <(service_user_pids "$uid")
[ "${#pids[@]}" -eq 0 ]
}
all_pids_owned_by_service_user() {
local service_uid pid owner_uid
managed_service_account || return 1
service_uid=$(id -u "$SERVICE_USER")
for pid in "$@"; do
[ -d "/proc/$pid" ] || continue
owner_uid=$(stat -c '%u' "/proc/$pid" 2>/dev/null || true)
[ "$owner_uid" = "$service_uid" ] || return 1
done
}
stop_managed_processes() {
local allow_service_restart=${1:-false} deadline
local -a pids=()
mapfile -t pids < <(related_pids)
if [ "${#pids[@]}" -gt 0 ]; then
log "Stopping remaining Kaidi processes: ${pids[*]}"
kill -TERM "${pids[@]}" 2>/dev/null || true
fi
deadline=$((SECONDS + 5))
while [ "$SECONDS" -lt "$deadline" ]; do
mapfile -t pids < <(related_pids)
[ "${#pids[@]}" -gt 0 ] || return 0
sleep 1
done
kill -KILL "${pids[@]}" 2>/dev/null || true
sleep 1
mapfile -t pids < <(related_pids)
[ "${#pids[@]}" -eq 0 ] && return 0
if [ "$allow_service_restart" = true ] && all_pids_owned_by_service_user "${pids[@]}"; then
log "A process manager restarted the dedicated $SERVICE_USER account; forced account cleanup will stop it"
return 0
fi
die "A process manager is restarting Kaidi; remove the Kaidi project from Baota and run this command again"
}
create_recovery_backup() {
local path relative temporary timestamp
local -a paths=()
for path in \
"$CONFIG_ROOT" \
"$STATE_ROOT" \
"$UPDATE_STATE_ROOT/backups" \
"$UPDATE_STATE_ROOT/failed" \
"$UPDATE_STATE_ROOT/transactions" \
"$FIRST_LOGIN_FILE"; do
if [ -e "$path" ] || [ -L "$path" ]; then
relative=${path#/}
paths+=("$relative")
fi
done
if [ "${#paths[@]}" -eq 0 ]; then
log "No local configuration or data needs a recovery backup"
return 0
fi
install -d -o root -g root -m 0700 "$BACKUP_ROOT"
timestamp=$(date -u +%Y%m%dT%H%M%SZ)
BACKUP_ARCHIVE="$BACKUP_ROOT/kaidi-local-state-$timestamp.tar.gz"
temporary="$BACKUP_ARCHIVE.next.$$"
log "Creating a root-only recovery backup at $BACKUP_ARCHIVE"
tar -czf "$temporary" -C / -- "${paths[@]}"
tar -tzf "$temporary" >/dev/null
chmod 0600 "$temporary"
mv -f "$temporary" "$BACKUP_ARCHIVE"
}
remove_systemd_units() {
local unit
rm -rf \
/etc/systemd/system/kaidi-finance.service.d \
/etc/systemd/system/kaidi-update.service.d \
/etc/systemd/system/kaidi-update.path.d
rm -f \
/etc/systemd/system/kaidi-finance.service \
/etc/systemd/system/kaidi-update.service \
/etc/systemd/system/kaidi-update.path \
/etc/systemd/system/multi-user.target.wants/kaidi-finance.service \
/etc/systemd/system/multi-user.target.wants/kaidi-update.path
systemd_available || return 0
systemctl daemon-reload
for unit in kaidi-finance.service kaidi-update.service kaidi-update.path; do
systemctl reset-failed "$unit" >/dev/null 2>&1 || true
done
}
remove_managed_paths() {
rm -rf -- \
"$APP_ROOT" \
"$BAOTA_ROOT" \
"$CONFIG_ROOT" \
"$STATE_ROOT" \
"$UPDATE_STATE_ROOT" \
"$LOG_ROOT"
rm -f -- "$FIRST_LOGIN_FILE"
}
remove_download_archives() {
rm -f -- \
/tmp/kaidi-finance-*.tar.gz \
/tmp/kaidi-purge.sh \
/tmp/kaidi-SHA256SUMS \
/tmp/kaidi-install.sh
}
verify_managed_paths_removed() {
local path
for path in "$APP_ROOT" "$BAOTA_ROOT" "$CONFIG_ROOT" "$STATE_ROOT" \
"$UPDATE_STATE_ROOT" "$LOG_ROOT" "$FIRST_LOGIN_FILE"; do
[ ! -e "$path" ] && [ ! -L "$path" ] \
|| die "卸载后仍发现受管路径:$path"
done
}
remove_service_identity() {
local entry home shell service_uid group_entry gid members primary_users
entry=$(getent passwd "$SERVICE_USER" 2>/dev/null || true)
if [ -n "$entry" ]; then
home=$(printf '%s\n' "$entry" | awk -F: '{print $6}')
shell=$(printf '%s\n' "$entry" | awk -F: '{print $7}')
case "$home:$shell" in
"$STATE_ROOT:"*/nologin|"$STATE_ROOT:"*/false)
service_uid=$(id -u "$SERVICE_USER")
terminate_uid_processes "$service_uid" || true
if ! userdel --force "$SERVICE_USER"; then
terminate_uid_processes "$service_uid" || true
userdel --force "$SERVICE_USER" \
|| die "Failed to remove the dedicated $SERVICE_USER service account"
fi
terminate_uid_processes "$service_uid" \
|| die "Processes owned by the removed $SERVICE_USER account are still running"
rm -rf "/run/user/$service_uid"
;;
*)
log "Keeping pre-existing user $SERVICE_USER because its home or shell is not Kaidi-managed"
;;
esac
fi
group_entry=$(getent group "$SERVICE_GROUP" 2>/dev/null || true)
[ -n "$group_entry" ] || return 0
gid=$(printf '%s\n' "$group_entry" | awk -F: '{print $3}')
members=$(printf '%s\n' "$group_entry" | awk -F: '{print $4}')
primary_users=$(getent passwd | awk -F: -v gid="$gid" '$4 == gid { print $1 }')
if [ -z "$members" ] && [ -z "$primary_users" ]; then
groupdel "$SERVICE_GROUP" \
|| die "Failed to remove the dedicated $SERVICE_GROUP service group"
else
log "Keeping group $SERVICE_GROUP because another account still uses it"
fi
}
main() {
validate_inputs
log "External MySQL data and reverse-proxy configuration will not be modified"
stop_systemd_units
stop_managed_processes true
create_recovery_backup
remove_systemd_units
remove_managed_paths
remove_download_archives
remove_service_identity
stop_managed_processes false
verify_managed_paths_removed
log "Local Kaidi installation state has been removed"
if [ -n "$BACKUP_ARCHIVE" ]; then
log "Recovery backup: $BACKUP_ARCHIVE"
if [ "$DELETE_RECOVERY_BACKUP" = true ]; then
rm -f -- "$BACKUP_ARCHIVE"
rmdir -- "$BACKUP_ROOT" >/dev/null 2>&1 || true
log "已按 KAIDI_PURGE_DELETE_BACKUP=true 删除恢复备份"
fi
fi
log "Use a new empty MySQL database for the next installation"
}
main "$@"